@bettercms-ai/mcp 0.63.2 → 0.64.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.
Files changed (30) hide show
  1. package/README.md +1 -0
  2. package/SKILL.md +1 -1
  3. package/dist/index.js +912 -489
  4. package/dist/index.js.map +1 -1
  5. package/dist/skills/bettercms/SKILL.md +175 -0
  6. package/dist/skills/bettercms/references/astro.md +67 -0
  7. package/dist/skills/bettercms/references/bindings.md +201 -0
  8. package/dist/skills/bettercms/references/componentize.md +106 -0
  9. package/dist/skills/bettercms/references/components.md +228 -0
  10. package/dist/skills/bettercms/references/content-experiments.md +62 -0
  11. package/dist/skills/bettercms/references/content-modeling.md +185 -0
  12. package/dist/skills/bettercms/references/editor-chrome.md +88 -0
  13. package/dist/skills/bettercms/references/external-content.md +78 -0
  14. package/dist/skills/bettercms/references/finish-site.md +123 -0
  15. package/dist/skills/bettercms/references/forms-and-interactions.md +203 -0
  16. package/dist/skills/bettercms/references/frameworks.md +149 -0
  17. package/dist/skills/bettercms/references/getting-started.md +106 -0
  18. package/dist/skills/bettercms/references/localization.md +81 -0
  19. package/dist/skills/bettercms/references/media-and-brand.md +142 -0
  20. package/dist/skills/bettercms/references/next.md +93 -0
  21. package/dist/skills/bettercms/references/other-frameworks.md +66 -0
  22. package/dist/skills/bettercms/references/portable-text.md +206 -0
  23. package/dist/skills/bettercms/references/programmatic-seo.md +98 -0
  24. package/dist/skills/bettercms/references/publishing.md +146 -0
  25. package/dist/skills/bettercms/references/seo.md +156 -0
  26. package/dist/skills/bettercms/references/shopify-headless.md +52 -0
  27. package/dist/skills/bettercms/references/site-blueprints.md +250 -0
  28. package/dist/skills/bettercms/references/site-review.md +73 -0
  29. package/dist/skills/bettercms/references/write-limits.md +84 -0
  30. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { realpathSync } from "fs";
5
- import { fileURLToPath } from "url";
5
+ import { fileURLToPath as fileURLToPath2 } from "url";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { loadConfig, FileTokenStore, DeviceAuthClient } from "@bettercms-ai/device-auth";
8
8
 
@@ -2266,7 +2266,7 @@ import { DeviceAuthPendingError } from "@bettercms-ai/device-auth";
2266
2266
  // src/structure-playbook.ts
2267
2267
  var STRUCTURE_PLAYBOOK_URI = "bettercms://playbook/structure";
2268
2268
  var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, organise them per the structure playbook: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure (read-only), show the user its outline, then apply it with set_content_structure and the version it returned (the If-Match). A project that already has a structure keeps it: file only what you created, with move_to_folder, unless the user asks for a full re-organisation.`;
2269
- var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that.";
2269
+ var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that. Without the skill installed, read the same files as resources: bettercms://skill/bettercms, then bettercms://skill/<reference>.";
2270
2270
  var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
2271
2271
  var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. Fix what it lists, or say why you are leaving it.";
2272
2272
  var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items: missing meta descriptions, drafts, empty collections, unnotified forms, writes awaiting approval, unstamped bindings or sections, \`authoring-undecided\` and the Layout lane (\`layout-write-blocked\`, \`layout-field-unread\`, \`layout-locator-stale\`, \`layout-rebuild-required\`, \`chrome-duplicated\`). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. Call it again after a batch of edits and before telling the user you are done. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
@@ -2348,6 +2348,29 @@ ever changes.
2348
2348
  reading needs only content access. A 403 \`SCHEMA_ACCESS_REQUIRED\` means this connection is
2349
2349
  neither: stop and tell the user, do not retry.
2350
2350
 
2351
+ ## Refusals
2352
+
2353
+ What the structure writes refuse, and the code each refusal carries:
2354
+
2355
+ - **System folders.** \`home:pages\` (Other pages), \`home:collections\` (Shared) and
2356
+ \`home:globals\` (Settings) always exist and can never be moved or deleted (400
2357
+ \`NAV_SYSTEM_FOLDER\`); \`rename_folder\` and \`set_icon\` work on them.
2358
+ - **Type purity** (400 \`NAV_TYPE_MISMATCH\`): Other pages holds only pages at any depth, Shared
2359
+ only collections, Settings neither \u2014 globals are not sidebar items.
2360
+ - **Pins** sit above the folders in their own order, pages or collections. In a document a pin is
2361
+ \`folderId: "home:root"\`, a virtual id with no folder record; a folder record with that id is
2362
+ 400 \`NAV_SYSTEM_FOLDER\`. In \`move_to_folder\`, \`folderId: 'root'\` pins and \`null\` returns
2363
+ the item to its home (Other pages / Shared).
2364
+ - **Kind.** \`move_to_folder\` and \`set_icon\` VERIFY what an id is: a page id sent as a
2365
+ collection is 400 \`NAV_KIND_MISMATCH\` naming the kind to use. \`set_content_structure\` never
2366
+ refuses a kind mismatch: it returns \`warnings\` and \`kindMismatches\` instead.
2367
+ - **Page-bound models** are refused with 400 \`NAV_PAGE_BOUND_MODEL\` naming their page; organise
2368
+ the page, by its page id.
2369
+ - **Shape:** folders nest at most 4 levels deep; names are 1-80 characters; icons are lucide
2370
+ names (a-z, 0-9, dashes) and null clears one.
2371
+ - **Access:** 403 \`SCHEMA_ACCESS_REQUIRED\` (rule 12).
2372
+ - **Version:** 412 \`VERSION_CONFLICT\` (workflow step 4).
2373
+
2351
2374
  ## The workflow
2352
2375
 
2353
2376
  1. \`get_content_structure\` \u2014 note \`version\` and whether a structure exists.
@@ -2396,6 +2419,316 @@ ${JSON.stringify(STRUCTURE_EXAMPLE_PAYLOAD, null, 2)}
2396
2419
  \`\`\`
2397
2420
  `;
2398
2421
 
2422
+ // src/entry-merge.ts
2423
+ function planEntryMerge(stored, patch, callerIfMatch) {
2424
+ const data = { ...stored.data ?? {}, ...patch };
2425
+ const ifMatch = callerIfMatch ?? (typeof stored.optimisticVersion === "number" ? stored.optimisticVersion : void 0);
2426
+ return ifMatch === void 0 ? { data } : { data, ifMatch };
2427
+ }
2428
+
2429
+ // src/whoami.ts
2430
+ var WHOAMI_DESCRIPTION = "Who this connection acts as. Call it first when a call is refused for access. Returns the principal (`device_grant` from the connect flow, or `api_key`), the workspace, `scope` ('project' = bound to one project; 'workspace' = pass projectId on every project tool) and, where the server can see them, `grants`: content:publish takes content live, artifact:write runs the code and deploy tools, schema:manage changes the Content sidebar. `grants: null` means this server cannot see them, not that there are none. Reads nothing else and changes nothing.";
2431
+
2432
+ // src/model-field-edit.ts
2433
+ function fieldRemovalRequest(args) {
2434
+ const { removeFields, allowFieldRemoval, pageId } = args;
2435
+ if (removeFields === void 0 && allowFieldRemoval === void 0) return { kind: "none" };
2436
+ if (removeFields === void 0) {
2437
+ return { kind: "refuse", error: "allowFieldRemoval names no field: pass removeFields with the top-level keys to delete." };
2438
+ }
2439
+ if (!Array.isArray(removeFields) || removeFields.length === 0 || !removeFields.every((k) => typeof k === "string" && k)) {
2440
+ return { kind: "refuse", error: "removeFields must be a non-empty list of top-level field keys (from get_content_model)." };
2441
+ }
2442
+ if (pageId !== void 0) {
2443
+ return { kind: "refuse", error: "A page's fields are removed with remove_page_fields, which has a dry run; removeFields takes modelId." };
2444
+ }
2445
+ if (allowFieldRemoval !== true) {
2446
+ return {
2447
+ kind: "refuse",
2448
+ error: "removeFields deletes those fields and DISCARDS their values in every entry: send allowFieldRemoval: true with it, once the user has agreed."
2449
+ };
2450
+ }
2451
+ return { kind: "remove", keys: [...new Set(removeFields)] };
2452
+ }
2453
+ function planFieldRemoval(model, keys, callerIfMatch) {
2454
+ const fields = model.fields ?? [];
2455
+ const present = new Set(fields.map((f) => f.key));
2456
+ const unknown = keys.filter((k) => !present.has(k));
2457
+ if (unknown.length > 0) {
2458
+ return {
2459
+ ok: false,
2460
+ error: `Not top-level fields of this model: ${unknown.join(", ")}. Nothing was removed; read get_content_model for its keys (a group's child goes with its group, or is edited in the dashboard).`
2461
+ };
2462
+ }
2463
+ const drop = new Set(keys);
2464
+ const ifMatch = callerIfMatch ?? (typeof model.optimisticVersion === "number" ? model.optimisticVersion : void 0);
2465
+ const kept = fields.filter((f) => !drop.has(f.key));
2466
+ return ifMatch === void 0 ? { ok: true, fields: kept } : { ok: true, fields: kept, ifMatch };
2467
+ }
2468
+ var CHILD_LISTS = [
2469
+ ["fields"],
2470
+ ["config", "zones", "nonRepeatable"],
2471
+ ["config", "zones", "repeatable", "fields"],
2472
+ ["config", "fields"],
2473
+ ["config", "itemFields"]
2474
+ ];
2475
+ var getIn = (node, at) => at.reduce((n, k) => n && typeof n === "object" ? n[k] : void 0, node);
2476
+ var setIn = (node, [head, ...rest], value) => ({ ...node, [head]: rest.length === 0 ? value : setIn(node[head] ?? {}, rest, value) });
2477
+ function patchAt(fields, [key, ...rest], edit) {
2478
+ const at = fields.findIndex((f) => f.key === key);
2479
+ if (at === -1) return null;
2480
+ const field = fields[at];
2481
+ let next = null;
2482
+ if (rest.length === 0) next = edit(field);
2483
+ else {
2484
+ for (const list of CHILD_LISTS) {
2485
+ const kids = getIn(field, list);
2486
+ const patched = Array.isArray(kids) ? patchAt(kids, rest, edit) : null;
2487
+ if (patched) {
2488
+ next = setIn(field, list, patched);
2489
+ break;
2490
+ }
2491
+ }
2492
+ }
2493
+ return next && fields.map((f, i) => i === at ? next : f);
2494
+ }
2495
+ function fieldAt(fields, keys) {
2496
+ let found;
2497
+ patchAt(fields, keys, (f) => (found = f, f));
2498
+ return found;
2499
+ }
2500
+ function allKeys(fields) {
2501
+ return fields.flatMap((f) => [
2502
+ String(f.key),
2503
+ ...CHILD_LISTS.flatMap((list) => {
2504
+ const kids = getIn(f, list);
2505
+ return Array.isArray(kids) ? allKeys(kids) : [];
2506
+ })
2507
+ ]);
2508
+ }
2509
+ var CLEARABLE = ["helpText", "showIf", "ui", "placement"];
2510
+ var SETTABLE = ["label", "required", ...CLEARABLE];
2511
+ function mergeConfig(current, patch) {
2512
+ const out = { ...current ?? {} };
2513
+ for (const [k, v] of Object.entries(patch)) {
2514
+ if (v === null) delete out[k];
2515
+ else out[k] = v;
2516
+ }
2517
+ return out;
2518
+ }
2519
+ function planFieldEdit(model, edit, callerIfMatch) {
2520
+ const fields = model.fields ?? [];
2521
+ const keys = typeof edit.path === "string" && edit.path ? edit.path.split(".") : [];
2522
+ const current = keys.length ? fieldAt(fields, keys) : void 0;
2523
+ if (!current) {
2524
+ return { ok: false, error: `No field at path '${String(edit.path ?? "")}'. Paths are dot-separated keys from get_content_model or get_page, e.g. 'hero.title'.` };
2525
+ }
2526
+ const retype = edit.type !== void 0 && edit.type !== current.type;
2527
+ if (retype && edit.allowRetype !== true) {
2528
+ return {
2529
+ ok: false,
2530
+ error: `Changing '${keys.join(".")}' from ${String(current.type)} to ${String(edit.type)} changes how every stored value is read, and values that do not fit are lost. Send allowRetype: true with it, once the user has agreed.`
2531
+ };
2532
+ }
2533
+ const rename = typeof edit.key === "string" && edit.key !== current.key ? edit.key : void 0;
2534
+ if (rename !== void 0 && allKeys(fields).includes(rename)) {
2535
+ return { ok: false, error: `The key '${rename}' is already used in this schema; keys share one flat namespace, nested fields included.` };
2536
+ }
2537
+ const changes = SETTABLE.filter((k) => edit[k] !== void 0);
2538
+ if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
2539
+ return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, type or key." };
2540
+ }
2541
+ const next = patchAt(fields, keys, (f) => {
2542
+ let out = { ...f };
2543
+ for (const k of changes) {
2544
+ if (edit[k] === null && CLEARABLE.includes(k)) delete out[k];
2545
+ else out[k] = edit[k];
2546
+ }
2547
+ if (edit.config && typeof edit.config === "object") out = { ...out, config: mergeConfig(f.config, edit.config) };
2548
+ if (retype) out.type = edit.type;
2549
+ if (rename !== void 0) out.key = rename;
2550
+ return out;
2551
+ });
2552
+ const ifMatch = callerIfMatch ?? (typeof model.optimisticVersion === "number" ? model.optimisticVersion : void 0);
2553
+ return {
2554
+ ok: true,
2555
+ fields: next,
2556
+ ...ifMatch === void 0 ? {} : { ifMatch },
2557
+ ...rename === void 0 ? {} : { renamed: { from: String(current.key), to: rename } }
2558
+ };
2559
+ }
2560
+
2561
+ // src/toolsets.ts
2562
+ var LISTING_TAIL = /* @__PURE__ */ new Set([
2563
+ // The agent-job lane: a dashboard surface an agent rarely drives.
2564
+ "list_ai_jobs",
2565
+ "get_ai_job",
2566
+ "approve_ai_job",
2567
+ "reject_ai_job",
2568
+ "list_ai_reports",
2569
+ "connect_native_ai",
2570
+ // Component validation: a CI lane, not an authoring one.
2571
+ "request_component_validation",
2572
+ "declare_component_route",
2573
+ "clear_component_source",
2574
+ "submit_componentize_receipt",
2575
+ // Reporting, history and workflow chrome.
2576
+ "get_analytics_overview",
2577
+ "get_analytics_top_pages",
2578
+ "list_activity",
2579
+ "get_workflow_board",
2580
+ "move_stage",
2581
+ "list_entry_versions",
2582
+ "restore_entry_version",
2583
+ // One-off project operations and the extraction lane.
2584
+ "clone_project",
2585
+ "promote_project",
2586
+ "generate_pages_from_dataset",
2587
+ "extract_component",
2588
+ "list_extraction_candidates",
2589
+ "create_deploy_upload",
2590
+ "set_authoring_preference",
2591
+ "set_media_delivery",
2592
+ "delete_form_submission",
2593
+ "remove_page_fields",
2594
+ // Access troubleshooting and platform feedback: called when something is refused or missing.
2595
+ "whoami",
2596
+ "give_feedback",
2597
+ "search_docs",
2598
+ // Search, batch and localisation: reached for once the site exists.
2599
+ "search_content",
2600
+ "discard_draft",
2601
+ "publish_entries",
2602
+ "create_content_entries",
2603
+ "translate_entry",
2604
+ "update_field",
2605
+ "refresh_search_index",
2606
+ // Automation, branches and AI images.
2607
+ "list_webhooks",
2608
+ "create_webhook",
2609
+ "list_branches",
2610
+ "create_branch",
2611
+ "apply_branch",
2612
+ "generate_image",
2613
+ "transform_image",
2614
+ // The ABM campaign lane — also hidden from the default listing (ABM_TOOLS below).
2615
+ "research_account",
2616
+ "build_abm_page",
2617
+ "claim_abm_task",
2618
+ "complete_abm_task",
2619
+ "fail_abm_task",
2620
+ "get_campaign_review",
2621
+ "regenerate_entry",
2622
+ "approve_campaign_entries",
2623
+ "publish_campaign",
2624
+ "get_campaign_share_link",
2625
+ "revoke_campaign_share_link"
2626
+ ]);
2627
+ var ABM_TOOLS = /* @__PURE__ */ new Set([
2628
+ "research_account",
2629
+ "build_abm_page",
2630
+ "claim_abm_task",
2631
+ "complete_abm_task",
2632
+ "fail_abm_task",
2633
+ "get_campaign_review",
2634
+ "regenerate_entry",
2635
+ "approve_campaign_entries",
2636
+ "publish_campaign",
2637
+ "get_campaign_share_link",
2638
+ "revoke_campaign_share_link"
2639
+ ]);
2640
+ var TOOLSETS = ["core", "abm", "all"];
2641
+ function toolsetsOf(raw) {
2642
+ const names = (raw ?? "").toLowerCase().split(",").map((s) => s.trim());
2643
+ return new Set(names.filter((n) => TOOLSETS.includes(n)));
2644
+ }
2645
+ function listsTool(name, sets) {
2646
+ if (sets.has("all")) return true;
2647
+ if (ABM_TOOLS.has(name)) return sets.has("abm");
2648
+ return !sets.has("core") || !LISTING_TAIL.has(name);
2649
+ }
2650
+
2651
+ // src/tool-text.ts
2652
+ var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. To change a field that exists, use update_field.";
2653
+ var MOVE_STAGE_DESCRIPTION = "Move a content entry (`entryId`) or a page (`pageId`) to another editorial stage \u2014 this is how you take it OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board; pass `fromStage` to 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry or update_page status:'published' \u2014 approving and publishing are deliberately two separate calls.";
2654
+ var WEBHOOK_EVENTS = [
2655
+ "content.publish",
2656
+ "content.unpublish",
2657
+ "content.stage_changed",
2658
+ "content.created",
2659
+ "content.updated",
2660
+ "content.deleted",
2661
+ "deployment.triggered"
2662
+ ];
2663
+ var NEW_TOOL_TEXT = {
2664
+ search_content: "Full-text search over the project's PUBLISHED pages and entries (the site-search index). Returns id, type ('page'|'entry'), title, slug and a snippet per hit; `type` narrows to one kind. Drafts are not indexed: find those with list_content_entries `where`. `meta.unavailable` means site search is off for this project.",
2665
+ discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
2666
+ give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
2667
+ publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
2668
+ create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts both. Publish afterwards with publish_entries.",
2669
+ translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
2670
+ list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
2671
+ create_webhook: "Create an outgoing webhook: BetterCMS POSTs each subscribed event to your https `url`, signed with HMAC-SHA256 in X-BetterCMS-Signature. The response carries the signing `secret` ONCE: store it now. `test: true` also sends a sample delivery. Payloads carry ids and metadata, never the content: fetch it from the API. A receiver that writes back on content.updated loops unless it skips its own writes.",
2672
+ list_branches: "List the project's content branches (id, name, slug, parent, status, whether main) and the `currentBranchId` this connection is on. To work inside a branch, pass its id as `branchId` on any tool call; apply_branch merges it into its parent.",
2673
+ create_branch: "Create a content branch: a copy of the project's content to edit without touching the live site. Returns the branch; pass its id as `branchId` on later calls to work in it, then apply_branch. 409 means the name or slug is taken.",
2674
+ apply_branch: "Merge a branch into its parent. dryRun: true returns the diff (changes, summary, conflicts) and writes nothing: ALWAYS read it and show the user first. `include` limits the merge to those change keys; `fields` resolves a conflicting field per change key ('keep_branch' or 'keep_parent'). 409 names unresolved conflicts, missing dependencies, or a merge already running.",
2675
+ search_docs: "Search the BetterCMS documentation. Returns the best pages (title, url, breadcrumbs, snippet). Use it before guessing how a feature works; `meta.unavailable` means docs search is down right now.",
2676
+ generate_image: "Generate an image with AI and add it to the Media Library (5 AI credits; free with the workspace's own Together key). `width`/`height` are 256-1440 in multiples of 16 (default 1024x768). Returns the new asset (id, url, altText): use the url as an 'image' field value. Always set `altText`.",
2677
+ refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
2678
+ transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
2679
+ };
2680
+ var PARAM_TEXT = {
2681
+ where: "field filters: {field: value} is equality, {field: {op: value}} with op eq|ne|gt|gte|lt|lte|contains|in, and an array means `in`. System fields slug, status, publishedAt, locale work anywhere; a data field needs modelId. Compares the served copy (live for published, working for draft)",
2682
+ sort: "comma list, a leading - sorts descending, e.g. '-publishedAt,title'",
2683
+ page: "1-based page of `limit` rows; the response's meta says totalPages",
2684
+ locale: "BCP-47 locale, one of the project's locales; null = the default language",
2685
+ translationOf: "the SOURCE's id this translates (never another translation); null unlinks. One per locale: set locale on the translation and translationOf to the source",
2686
+ pageScheduledAt: "ISO datetime to publish this page's draft at (needs publish permission); null cancels. An edit that omits scheduledAt CANCELS a pending schedule",
2687
+ pageUnpublishAt: "ISO datetime at which the published page returns to draft; null cancels; omitting it never cancels it",
2688
+ sourceUrl: "instead of data: a public http(s) URL the server fetches and stores (private or internal addresses are refused)",
2689
+ focalX: "focal point, 0-1 across from the left edge; null resets to the centre",
2690
+ focalY: "focal point, 0-1 down from the top edge; null resets to the centre"
2691
+ };
2692
+ var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui or placement (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename.";
2693
+ var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
2694
+ var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
2695
+ var SEO_KEY_TEXT = {
2696
+ seoDefaults: "site-wide meta defaults every page inherits",
2697
+ siteSchema: "the site's JSON-LD: an object or an array of objects",
2698
+ robotsConfig: "robots.txt: 'auto', or 'custom' with its text",
2699
+ sitemapConfig: "sitemap.xml: generated ('auto') or 'custom', defaults and extra URLs",
2700
+ rssConfig: "the RSS feed on or off",
2701
+ indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
2702
+ searchConfig: "site search: on/off, the box's placeholder, AI answers"
2703
+ };
2704
+ var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
2705
+ var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
2706
+ var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
2707
+
2708
+ // src/entry-query.ts
2709
+ var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
2710
+ function whereToQuery(where) {
2711
+ const out = [];
2712
+ for (const [field, cond] of Object.entries(where ?? {})) {
2713
+ if (cond === void 0 || cond === null) continue;
2714
+ if (Array.isArray(cond)) out.push([`filter[${field}][in]`, text(cond)]);
2715
+ else if (typeof cond === "object") {
2716
+ for (const [op, v] of Object.entries(cond)) {
2717
+ if (v !== void 0 && v !== null) out.push([`filter[${field}][${op}]`, text(v)]);
2718
+ }
2719
+ } else out.push([`filter[${field}]`, text(cond)]);
2720
+ }
2721
+ return out;
2722
+ }
2723
+ function entryListSearch(args) {
2724
+ const q = new URLSearchParams();
2725
+ for (const key of ["modelId", "pageId", "status"]) if (args[key]) q.set(key, String(args[key]));
2726
+ for (const [k, v] of whereToQuery(args.where)) q.append(k, v);
2727
+ for (const key of ["sort", "page", "limit"]) if (args[key] !== void 0 && args[key] !== null) q.set(key, String(args[key]));
2728
+ const s = q.toString();
2729
+ return s ? `?${s}` : "";
2730
+ }
2731
+
2399
2732
  // src/tools.ts
2400
2733
  async function askHuman(deps, q) {
2401
2734
  if (!deps.elicit) return { prompt: q.prompt };
@@ -2647,7 +2980,34 @@ var fieldsetsArg = z.array(
2647
2980
  );
2648
2981
  var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
2649
2982
  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.";
2650
- var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_page_field / set_page_content), never a one-entry collection.";
2983
+ var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"];
2984
+ var changefreq = z.enum(["auto", "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
2985
+ var jsonObject = z.record(z.string(), z.unknown());
2986
+ var seoPatchShape = {
2987
+ seoDefaults: z.object({
2988
+ metaTitle: z.string().max(255).optional(),
2989
+ metaDescription: z.string().max(500).optional(),
2990
+ ogImage: z.string().url().max(2e3).nullable().optional(),
2991
+ twitterHandle: z.string().max(255).nullable().optional(),
2992
+ og: z.object({ image: z.string().max(2048).optional(), type: z.string().max(50).optional() }).optional(),
2993
+ twitter: z.object({ card: z.string().max(50).optional() }).optional(),
2994
+ collectionSchemaTypes: z.record(z.string().min(1).max(64), z.enum(SCHEMA_ORG_TYPES)).optional()
2995
+ }).optional().describe(SEO_KEY_TEXT.seoDefaults),
2996
+ siteSchema: z.union([jsonObject, z.array(jsonObject)]).optional().describe(SEO_KEY_TEXT.siteSchema),
2997
+ robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
2998
+ sitemapConfig: z.object({
2999
+ enabled: z.boolean(),
3000
+ mode: z.enum(["auto", "custom"]).optional(),
3001
+ custom: z.string().max(5e5).optional(),
3002
+ defaultChangefreq: changefreq.optional(),
3003
+ defaultPriority: z.number().min(0).max(1).optional(),
3004
+ extraUrls: z.array(z.object({ loc: z.string().min(1).max(2e3), changefreq: changefreq.optional(), priority: z.number().min(0).max(1).optional() })).max(1e3).optional()
3005
+ }).optional().describe(SEO_KEY_TEXT.sitemapConfig),
3006
+ rssConfig: z.object({ enabled: z.boolean() }).optional().describe(SEO_KEY_TEXT.rssConfig),
3007
+ indexnowConfig: z.object({ enabled: z.boolean(), excludePatterns: z.array(z.string().max(2e3)).max(200).optional() }).optional().describe(SEO_KEY_TEXT.indexnowConfig),
3008
+ searchConfig: z.object({ enabled: z.boolean(), placeholder: z.string().max(120), aiEnabled: z.boolean().optional() }).optional().describe(SEO_KEY_TEXT.searchConfig)
3009
+ };
3010
+ var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_field with pageId / set_page_content), never a one-entry collection.";
2651
3011
  function flatFieldKeys(fields) {
2652
3012
  const out = [];
2653
3013
  const childrenOf = (f) => {
@@ -2730,7 +3090,9 @@ function toField(f) {
2730
3090
  };
2731
3091
  }
2732
3092
  var BRAND_KIT_NOTE = "`brandKit` (colors, typography, radius and any typeScale/spacing/elevation/motion/graphics) is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics; every write is a version a person can restore. ASSETS: a media asset OF THIS PROJECT by id; null clears one; a slot a person chose needs `replace: true`. GRAPHICS: `kind`, optional `angle`, 2-8 stops naming `colors.*` or `extras` keys \u2014 never a CSS string and never a hex. CONSUME, DO NOT COPY: a hosted page exposes `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key), never copied values. Never invent brand facts: read them from get_project.";
2733
- var UPDATE_MODEL_DESCRIPTION = "Edit a content model: rename it, change description/slug/urlPattern, set its `fieldsets`, or reshape how its EXISTING fields look in the editor. Never adds, removes or retypes a field (use add_field). Send modelId, or pageId for the model a page's schema lives in. `fieldsets` REPLACES the list (null drops all); a field joins one with fieldFieldset or add_field's `fieldsetId`. The field* args address fields by dotted key path ('hero.title'). Read with get_content_model, then pass `ifMatch` = the version from that read. A taken slug is 409 `slug_taken`. Section 14 of bettercms://playbook/schema.";
3093
+ var UPDATE_MODEL_DESCRIPTION = "Edit a content model: rename it, change description/slug/urlPattern, set its `fieldsets`, or reshape how its EXISTING fields look in the editor. Never adds or retypes a field (use add_field). It removes one only with `removeFields` plus `allowFieldRemoval: true`, which DISCARDS its data in every entry and has no dry run; a page's fields go through remove_page_fields. Send modelId, or pageId for the model a page's schema lives in. `fieldsets` REPLACES the list (null drops all); a field joins one with fieldFieldset or add_field's `fieldsetId`. The field* args address fields by dotted key path ('hero.title'). Read with get_content_model, then pass `ifMatch` = the version from that read. A taken slug is 409 `slug_taken`. Section 14 of bettercms://playbook/schema.";
3094
+ var FIELD_REMOVAL_NOTE = "top-level field keys (from get_content_model) to DELETE from this model with every entry's value for them; needs allowFieldRemoval: true. An unknown key refuses the whole call. Never for a rename: rename the label instead.";
3095
+ var ALLOW_FIELD_REMOVAL_NOTE = "true = the user accepts that removeFields discards those fields' data in every entry. There is no dry run on this route: confirm first.";
2734
3096
  var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model (with pageId: get_page backingModel). The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply, never retry blindly. REQUIRED with field*, override or a fieldset kind/tab/icon/collapsed (428 without); otherwise, omitted, the last write wins.";
2735
3097
  var COMPONENT_IF_MATCH_NOTE = "optimisticVersion from get_component. The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply. REQUIRED with prop*, override or a prop ui icon/itemLabel/group (428 without).";
2736
3098
  var PROP_UI_ARG_NOTE = "top-level prop key \u2192 ui patch, the same vocabulary as a prop's `ui`: each key merges, a null key clears it, null clears the whole ui";
@@ -2750,9 +3112,15 @@ var MODEL_UPDATE_KEYS = [
2750
3112
  "override"
2751
3113
  ];
2752
3114
  var ENTRY_META_NOTE = "SEO is native: set this entry's metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType or schema in `meta` (never as model fields). `meta` MERGES into what is stored: a key you leave out is kept, null or an empty string clears it.";
3115
+ var ENTRY_MERGE_NOTE = "true = keep the fields you leave out: the entry is read, `data` is laid over its stored data by top-level key (a group or repeater value replaces that whole key) and written at the version read. Default false: `data` REPLACES the whole object.";
3116
+ var ENTRY_IF_MATCH_NOTE = "optimisticVersion from get_content_entry: the write applies only at that version, and a 409 means it changed since: read it again and re-apply. With merge it defaults to the version merge read.";
3117
+ var ENTRY_SCHEDULED_AT_NOTE = "ISO datetime to publish this draft at (needs publish permission); null cancels. An edit that omits scheduledAt CANCELS a pending publish schedule, so send it again with every edit made before it fires.";
3118
+ var ENTRY_UNPUBLISH_AT_NOTE = "ISO datetime at which the published entry returns to draft; null cancels. Omitting it never cancels it.";
3119
+ var ENTRY_LIST_LIMIT_NOTE = "entries per page, 1-200; 50 when omitted. Read `meta.totalPages` and ask for the next `page`.";
2753
3120
  var PAGE_HEAD_NOTE = "noindex, canonical, og and twitter set the page's head extras; each MERGES into what is stored (a key you leave out is kept, null or an empty string clears it).";
2754
- var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, reading only content access; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. PINS sit above the folders in their own order, pages or collections; in a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a bound model that is NOT a collection: never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
2755
3121
  var STRUCTURE_FENCE = "ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered; URL folders (page folders) are set in the dashboard. Read the resource bettercms://playbook/structure before you organise anything: it holds the rules this refuses on \u2014 system folders, type purity, the Admin/Developer access check, root pins and page-bound models. A resource is never truncated; a long description is.";
3122
+ var STRUCTURE_POINTER = "Sidebar only, never a URL; rules: bettercms://playbook/structure.";
3123
+ var BRAND_KIT_POINTER = "The brand kit's rules (consume its tokens, never copy values or invent brand facts) are on get_project.";
2756
3124
  function structureResult(d) {
2757
3125
  const message = d?.message;
2758
3126
  return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
@@ -2844,97 +3212,51 @@ function refusalDetail(err) {
2844
3212
  ${lines.join("\n")}` : "";
2845
3213
  }
2846
3214
  function authPrompt(err) {
2847
- const text = [
3215
+ const text2 = [
2848
3216
  "\u{1F510} BetterCMS authorization required \u2014 you're not signed in yet.",
2849
3217
  "",
2850
3218
  `1. Open this link and approve: ${err.verificationUriComplete}`,
2851
3219
  ` (or visit ${err.verificationUri} and enter code ${err.userCode})`,
2852
3220
  "2. Once approved, run this tool again \u2014 it resumes automatically and completes your request."
2853
3221
  ].join("\n");
2854
- return { content: [{ type: "text", text }], isError: true };
3222
+ return { content: [{ type: "text", text: text2 }], isError: true };
2855
3223
  }
2856
3224
  var currentProject = new AsyncLocalStorage();
3225
+ var currentBranch = new AsyncLocalStorage();
2857
3226
  var projectIdArg = z.string().min(1).optional().describe(
2858
3227
  "target project id (from list_projects); only for a workspace-wide grant."
2859
3228
  );
3229
+ var branchIdArg = z.string().min(1).optional().describe("content branch id (from list_branches); omit to work on main");
2860
3230
  var withProjectId = (def) => ({
2861
3231
  ...def,
2862
- config: def.config.inputSchema.projectId ? def.config : { ...def.config, inputSchema: { ...def.config.inputSchema, projectId: projectIdArg } },
3232
+ config: {
3233
+ ...def.config,
3234
+ inputSchema: {
3235
+ ...def.config.inputSchema,
3236
+ ...def.config.inputSchema.projectId ? {} : { projectId: projectIdArg },
3237
+ ...def.config.inputSchema.branchId ? {} : { branchId: branchIdArg }
3238
+ }
3239
+ },
2863
3240
  handler: (args) => {
2864
- const project = typeof args?.projectId === "string" && args.projectId ? args.projectId : void 0;
2865
- return currentProject.run(project, () => def.handler(args));
3241
+ const arg = (key) => typeof args?.[key] === "string" && args[key] ? args[key] : void 0;
3242
+ return currentProject.run(arg("projectId"), () => currentBranch.run(arg("branchId"), () => def.handler(args)));
2866
3243
  }
2867
3244
  });
2868
- var LISTING_TAIL = /* @__PURE__ */ new Set([
2869
- // The agent-job lane: a dashboard surface an agent rarely drives.
2870
- "list_ai_jobs",
2871
- "get_ai_job",
2872
- "approve_ai_job",
2873
- "reject_ai_job",
2874
- "list_ai_reports",
2875
- "connect_native_ai",
2876
- "native_bridge_heartbeat",
2877
- // Section evidence and component validation: a CI lane, not an authoring one.
2878
- "claim_section_validation_request",
2879
- "complete_section_validation_request",
2880
- "fail_section_validation_request",
2881
- "list_section_validation_requests",
2882
- "submit_section_validation",
2883
- "submit_section_manifest",
2884
- "request_component_validation",
2885
- "declare_component_route",
2886
- "clear_component_source",
2887
- "submit_componentize_receipt",
2888
- // Reporting, history and workflow chrome.
2889
- "get_analytics_overview",
2890
- "get_analytics_top_pages",
2891
- "list_activity",
2892
- "get_workflow_board",
2893
- "move_entry_stage",
2894
- "move_page_stage",
2895
- "list_entry_versions",
2896
- "restore_entry_version",
2897
- // One-off project operations and the extraction lane.
2898
- "clone_project",
2899
- "promote_project",
2900
- "generate_pages_from_dataset",
2901
- "extract_component",
2902
- "list_extraction_candidates",
2903
- "create_deploy_upload",
2904
- "deploy_from_upload",
2905
- "set_authoring_preference",
2906
- "set_media_delivery",
2907
- "delete_form_submission",
2908
- "remove_page_fields",
2909
- // The ABM campaign lane: a marketer's dashboard surface, plus the worker tools a task runner
2910
- // claims with. Ten more tools than the tail was written for, and a connector that truncates
2911
- // would otherwise lose `get_deploy_status` and the GitHub lane to make room for them.
2912
- "research_account",
2913
- "build_abm_page",
2914
- "claim_abm_task",
2915
- "complete_abm_task",
2916
- "fail_abm_task",
2917
- "get_campaign_review",
2918
- "regenerate_entry",
2919
- "approve_campaign_entries",
2920
- "publish_campaign",
2921
- "get_campaign_share_link",
2922
- "revoke_campaign_share_link"
2923
- ]);
2924
3245
  function buildToolDefs(deps) {
2925
3246
  async function withClient(fn) {
2926
3247
  const token = await deps.auth.getAccessToken();
2927
3248
  const project = currentProject.getStore();
3249
+ const branch = currentBranch.getStore();
2928
3250
  try {
2929
- return await fn(deps.createClient(token, project));
3251
+ return await fn(deps.createClient(token, project, branch));
2930
3252
  } catch (err) {
2931
3253
  if (err instanceof BetterCMSError && err.status === 401) {
2932
3254
  const next = await deps.auth.refresh() ?? await deps.auth.getAccessToken();
2933
- return await fn(deps.createClient(next, project));
3255
+ return await fn(deps.createClient(next, project, branch));
2934
3256
  }
2935
3257
  if (err instanceof BetterCMSError && err.status === 409 && err.bodyCode === "PROJECT_DELETED") {
2936
3258
  const next = await deps.auth.resetAndReauthorize();
2937
- return await fn(deps.createClient(next, project));
3259
+ return await fn(deps.createClient(next, project, branch));
2938
3260
  }
2939
3261
  throw err;
2940
3262
  }
@@ -2997,7 +3319,9 @@ function buildToolDefs(deps) {
2997
3319
  "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."
2998
3320
  ),
2999
3321
  metaTitle: z.string().optional().describe("SEO meta title"),
3000
- metaDescription: z.string().optional().describe("SEO meta description")
3322
+ metaDescription: z.string().optional().describe("SEO meta description"),
3323
+ locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
3324
+ translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
3001
3325
  });
3002
3326
  const writeContentInput = z.object({
3003
3327
  action: z.enum(["write", "rewrite", "translate"]).describe("'write' = draft from a brief, 'rewrite' = improve existing copy, 'translate' = needs targetLang"),
@@ -3015,13 +3339,6 @@ function buildToolDefs(deps) {
3015
3339
  "optional \u2014 any of inspect, propose, implement, cancel, question. Defaults to inspect+propose+cancel."
3016
3340
  )
3017
3341
  });
3018
- const nativeBridgeHeartbeatInput = z.object({
3019
- protocolVersion: z.string().min(1).describe("executor protocol version; currently '1'"),
3020
- capabilities: z.array(z.string().min(1)).min(1).describe(
3021
- "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."
3022
- ),
3023
- 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")
3024
- });
3025
3342
  const createModelInput = z.object({
3026
3343
  name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
3027
3344
  slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
@@ -3039,18 +3356,15 @@ function buildToolDefs(deps) {
3039
3356
  "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."
3040
3357
  );
3041
3358
  const addFieldInput = z.object({
3042
- modelId: z.string().min(1).describe("id of the content model to extend"),
3043
- ...fieldShape,
3044
- after: afterArg
3045
- });
3046
- const addPageFieldInput = z.object({
3047
- pageId: z.string().min(1).describe("id of the page to extend (from list_pages / create_page)"),
3359
+ modelId: z.string().min(1).optional().describe("the content model to extend (from list_content_models); or send pageId"),
3360
+ pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to extend (from list_pages / create_page)"),
3048
3361
  ...fieldShape,
3049
3362
  after: afterArg
3050
3363
  });
3051
3364
  const uploadAssetInput = z.object({
3052
3365
  localPath: z.string().min(1).optional().describe("absolute path to a local file (e.g. a repo image); provide this OR url"),
3053
- url: z.string().url().optional().describe("remote image URL to ingest; provide this OR localPath"),
3366
+ url: z.string().url().optional().describe("remote image URL this client downloads and uploads; provide this OR localPath OR sourceUrl"),
3367
+ sourceUrl: z.string().url().optional().describe(PARAM_TEXT.sourceUrl),
3054
3368
  filename: z.string().optional().describe("override the stored filename"),
3055
3369
  altText: z.string().optional().describe("accessibility alt text"),
3056
3370
  caption: z.string().optional(),
@@ -3061,7 +3375,9 @@ function buildToolDefs(deps) {
3061
3375
  contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
3062
3376
  slug: slug.optional(),
3063
3377
  status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
3064
- data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key")
3378
+ data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
3379
+ locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
3380
+ translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
3065
3381
  });
3066
3382
  const getPageInput = z.object({
3067
3383
  pageId: z.string().min(1).describe("page id (from list_pages / create_page)")
@@ -3080,7 +3396,11 @@ function buildToolDefs(deps) {
3080
3396
  const listEntriesInput = z.object({
3081
3397
  modelId: z.string().optional().describe("filter by content model id"),
3082
3398
  pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
3083
- status: z.enum(["draft", "published"]).optional()
3399
+ status: z.enum(["draft", "published"]).optional(),
3400
+ where: z.record(z.string(), z.unknown()).optional().describe(PARAM_TEXT.where),
3401
+ sort: z.string().max(100).optional().describe(PARAM_TEXT.sort),
3402
+ page: z.number().int().min(1).optional().describe(PARAM_TEXT.page),
3403
+ limit: z.number().int().min(1).max(200).optional().describe(ENTRY_LIST_LIMIT_NOTE)
3084
3404
  });
3085
3405
  const clearableText = (max) => z.string().max(max).nullable().optional();
3086
3406
  const headPatchShape = {
@@ -3101,7 +3421,13 @@ function buildToolDefs(deps) {
3101
3421
  data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
3102
3422
  status: z.enum(["draft", "published"]).optional(),
3103
3423
  slug: slug.optional(),
3104
- meta: entryMetaInput.optional().describe("the entry's native SEO; merges into what is stored")
3424
+ meta: entryMetaInput.optional().describe("the entry's native SEO; merges into what is stored"),
3425
+ merge: z.boolean().optional().describe(ENTRY_MERGE_NOTE),
3426
+ ifMatch: z.number().int().min(0).optional().describe(ENTRY_IF_MATCH_NOTE),
3427
+ scheduledAt: z.string().datetime().nullable().optional().describe(ENTRY_SCHEDULED_AT_NOTE),
3428
+ unpublishAt: z.string().datetime().nullable().optional().describe(ENTRY_UNPUBLISH_AT_NOTE),
3429
+ locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
3430
+ translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
3105
3431
  });
3106
3432
  const deletePageInput = z.object({
3107
3433
  pageId: z.string().min(1).describe("id of the page to delete (from list_pages)")
@@ -3298,7 +3624,7 @@ function buildToolDefs(deps) {
3298
3624
  description: z.string().optional(),
3299
3625
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
3300
3626
  props: z.array(componentPropObject).default([]).describe(
3301
- "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."
3627
+ "overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
3302
3628
  )
3303
3629
  });
3304
3630
  const updateComponentInput = z.object({
@@ -3347,14 +3673,6 @@ function buildToolDefs(deps) {
3347
3673
  const getComponentInput = z.object({
3348
3674
  componentId: z.string().min(1).describe("component id (from list_components)")
3349
3675
  });
3350
- const duplicateComponentInput = z.object({
3351
- componentId: z.string().min(1).describe("the look to copy (from list_components)"),
3352
- name: z.string().min(1).max(255).describe("the new look's name, unique in its family, e.g. 'Ghost'"),
3353
- preset: z.enum(["primary", "secondary", "outline", "ghost", "link"]).optional().describe("restyle the copy's button (see above); omit for a plain copy")
3354
- });
3355
- const createComponentsInput = z.object({
3356
- components: z.array(createComponentInput).min(1).max(50).describe("up to 50 components, each exactly the create_component input")
3357
- });
3358
3676
  const publishComponentsInput = z.object({
3359
3677
  componentIds: z.array(z.string().min(1)).min(1).max(100).describe("component ids (from list_components)")
3360
3678
  });
@@ -3379,83 +3697,6 @@ function buildToolDefs(deps) {
3379
3697
  slug: slug.describe("url-safe unique slug (lowercase letters/numbers/hyphens)"),
3380
3698
  projectId: z.string().min(1).optional().describe("only needed for a workspace-wide key")
3381
3699
  });
3382
- const sectionEvidenceProjectId = z.string().min(1).max(64).describe("exact project id from get_project (not a slug); Section evidence is project-local and never reusable across projects");
3383
- const sectionId = z.string().uuid().describe("Section definition id from Section Studio");
3384
- const sectionVersion = z.number().int().positive().describe("exact Section schema version implemented or tested");
3385
- const sha256 = z.string().regex(/^[0-9a-f]{64}$/i, "Expected a SHA-256 hex digest");
3386
- const sectionViewport = z.object({
3387
- name: z.string().trim().min(1).max(64),
3388
- width: z.number().int().min(240).max(7680),
3389
- height: z.number().int().min(240).max(7680)
3390
- });
3391
- const sectionEvidenceDigest = z.string().regex(/^(?:sha256:)?[0-9a-f]{64}$/i, "Expected a SHA-256 digest");
3392
- const nonEmptyDescriptor = z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "Descriptor cannot be empty");
3393
- const sectionRequestId = z.string().uuid().describe("durable Section validation request id returned by list_section_validation_requests or the dashboard");
3394
- const sectionHttpUrl = z.string().url().max(2048).refine((value) => {
3395
- try {
3396
- const protocol = new URL(value).protocol;
3397
- return protocol === "http:" || protocol === "https:";
3398
- } catch {
3399
- return false;
3400
- }
3401
- }, "Expected an HTTP or HTTPS URL");
3402
- const sectionValidationViewport = sectionViewport.extend({
3403
- status: z.enum(["passed", "failed"]),
3404
- evidenceDigest: sectionEvidenceDigest
3405
- });
3406
- const submitSectionManifestInput = z.object({
3407
- projectId: sectionEvidenceProjectId,
3408
- requestId: sectionRequestId.optional().describe("include when fulfilling a claimed dashboard request so the evidence is bound to that exact run"),
3409
- sectionId,
3410
- version: sectionVersion,
3411
- apiId: z.string().regex(/^[a-z][A-Za-z0-9]*$/, "Use lower camelCase").max(100),
3412
- schemaHash: sha256.describe("exact SHA-256 hex hash shown for this Section version"),
3413
- commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i).describe("git commit containing the implementation"),
3414
- loader: nonEmptyDescriptor.describe("non-empty serializable loader descriptor emitted by the repo build; never executable code"),
3415
- previewAdapter: nonEmptyDescriptor.describe("non-empty serializable adapter descriptor for the customer's real app shell; never executable code"),
3416
- nativeViewports: z.array(sectionViewport).max(20).optional().describe("the customer's named responsive viewports; BetterCMS also enforces Desktop 1440x900, Tablet 768x1024, and Mobile 390x844")
3417
- });
3418
- const submitSectionValidationInput = z.object({
3419
- projectId: sectionEvidenceProjectId.describe("exact project id from get_project (not a slug); must match the manifest's project"),
3420
- requestId: sectionRequestId.optional().describe("same claimed request id used for the manifest; binds and terminalizes that run"),
3421
- sectionId: sectionId.describe("exact Section definition id"),
3422
- version: sectionVersion,
3423
- manifestId: z.string().uuid().describe("manifest id returned by submit_section_manifest; it pins the schema hash and commit SHA"),
3424
- status: z.enum(["passed", "failed"]).describe("the actual result of the external repo/app-shell validation"),
3425
- fixtureHash: sha256.describe("SHA-256 hex hash of the canonical preview dataset and stress fixtures used"),
3426
- evidenceDigest: sectionEvidenceDigest.describe("SHA-256 digest of the immutable validation evidence bundle (screenshots/report/results)"),
3427
- appShell: z.object({
3428
- kind: z.literal("actual-app"),
3429
- identifier: z.string().trim().min(1).max(255),
3430
- url: sectionHttpUrl.optional()
3431
- }).describe("the real customer application shell used for the validation run"),
3432
- viewportResults: z.array(sectionValidationViewport).min(1).max(23).describe("one signed result for every required and native manifest viewport")
3433
- });
3434
- const listSectionValidationRequestsInput = z.object({
3435
- projectId: sectionEvidenceProjectId,
3436
- limit: z.number().int().min(1).max(100).optional()
3437
- });
3438
- const claimSectionValidationRequestInput = z.object({
3439
- projectId: sectionEvidenceProjectId,
3440
- requestId: sectionRequestId,
3441
- commitSha: z.string().regex(/^[0-9a-f]{7,64}$/i).describe("exact git commit the agent will inspect and validate; evidence must use this same commit"),
3442
- providerRunId: z.string().trim().min(1).max(255).optional(),
3443
- providerRunUrl: sectionHttpUrl.optional().describe("optional HTTP(S) link to the user's external agent run")
3444
- });
3445
- const completeSectionValidationRequestInput = z.object({
3446
- projectId: sectionEvidenceProjectId,
3447
- requestId: sectionRequestId,
3448
- manifestId: z.string().uuid().optional(),
3449
- validationRunId: z.string().uuid().optional()
3450
- });
3451
- const failSectionValidationRequestInput = z.object({
3452
- projectId: sectionEvidenceProjectId,
3453
- requestId: sectionRequestId,
3454
- errorCode: z.string().trim().regex(/^[A-Z][A-Z0-9_]*$/).max(100),
3455
- errorMessage: z.string().trim().min(1).max(2e3),
3456
- providerRunId: z.string().trim().min(1).max(255).optional(),
3457
- providerRunUrl: sectionHttpUrl.optional().describe("optional HTTP(S) link to the user's external agent run")
3458
- });
3459
3700
  function lifecycleTools() {
3460
3701
  const def = (name, title, description, shape, run) => ({
3461
3702
  name,
@@ -3508,14 +3749,16 @@ function buildToolDefs(deps) {
3508
3749
  def(
3509
3750
  "update_media",
3510
3751
  "Update a media asset",
3511
- "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.",
3752
+ "Describe an existing media asset: set its alt text, caption, transcript or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
3512
3753
  z.object({
3513
3754
  assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
3514
3755
  altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
3515
3756
  caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
3516
- transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only.")
3757
+ transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only."),
3758
+ focalX: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalX),
3759
+ focalY: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalY)
3517
3760
  }).shape,
3518
- 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 }))
3761
+ async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, focalX: a.focalX, focalY: a.focalY }))
3519
3762
  ),
3520
3763
  def(
3521
3764
  "list_media",
@@ -3563,7 +3806,7 @@ function buildToolDefs(deps) {
3563
3806
  def(
3564
3807
  "get_content_structure",
3565
3808
  "Get the Content sidebar structure",
3566
- `Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. ${STRUCTURE_NOTE}`,
3809
+ `Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. Changing it needs Admin or Developer; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. ${STRUCTURE_FENCE}`,
3567
3810
  z.object({}).shape,
3568
3811
  async (c) => {
3569
3812
  const d = await data(c, "GET", `/management/content-structure`);
@@ -3624,7 +3867,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3624
3867
  def(
3625
3868
  "rename_folder",
3626
3869
  "Rename a Content sidebar folder",
3627
- `Rename a Content sidebar folder. ${STRUCTURE_FENCE}`,
3870
+ `Rename a Content sidebar folder. ${STRUCTURE_POINTER}`,
3628
3871
  z.object({
3629
3872
  folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
3630
3873
  name: z.string().min(1).describe("the new label, 1-80 chars")
@@ -3634,7 +3877,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3634
3877
  def(
3635
3878
  "move_to_folder",
3636
3879
  "Move into a Content sidebar folder",
3637
- `Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_FENCE}`,
3880
+ `Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_POINTER}`,
3638
3881
  z.object({
3639
3882
  kind: z.enum(["page", "collection", "folder"]).describe(
3640
3883
  "what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
@@ -3647,7 +3890,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3647
3890
  def(
3648
3891
  "set_brand_assets",
3649
3892
  "Point the brand's logo or favicon at a media asset",
3650
- `Point the brand's logo (mark) or favicon at a media asset of this project \u2014 upload it to this project's media library first if it is not there. A slot a PERSON chose is refused without replace: true, and the refusal says so. ${BRAND_KIT_NOTE}`,
3893
+ `Point the brand's logo (mark) or favicon at a media asset of this project \u2014 upload it to this project's media library first if it is not there. A slot a PERSON chose is refused without replace: true, and the refusal says so. ${BRAND_KIT_POINTER}`,
3651
3894
  z.object({
3652
3895
  mark: z.string().min(1).nullable().optional().describe("media asset id for the logo; null clears it"),
3653
3896
  favicon: z.string().min(1).nullable().optional().describe("media asset id for the favicon; null clears it"),
@@ -3658,7 +3901,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3658
3901
  def(
3659
3902
  "set_brand_graphics",
3660
3903
  "Add, replace or remove the brand's gradients",
3661
- `Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media. A stop naming a colour the kit does not have is refused; add it to \`extras\` first. Upsert matches BY KEY. ${BRAND_KIT_NOTE}`,
3904
+ `Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media. A stop naming a colour the kit does not have is refused; add it to \`extras\` first. Upsert matches BY KEY. ${BRAND_KIT_POINTER}`,
3662
3905
  z.object({
3663
3906
  upsert: z.array(z.object({
3664
3907
  key: z.string().min(1).describe("token key: lowercase letters, digits and hyphens. Emitted as --brand-gradient-<key>"),
@@ -3677,7 +3920,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3677
3920
  def(
3678
3921
  "set_icon",
3679
3922
  "Set a Content sidebar icon",
3680
- `Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_FENCE}`,
3923
+ `Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_POINTER}`,
3681
3924
  z.object({
3682
3925
  kind: z.enum(["page", "collection", "folder"]).describe(
3683
3926
  "what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
@@ -3690,7 +3933,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3690
3933
  def(
3691
3934
  "delete_folder",
3692
3935
  "Delete a folder (its contents move up; nothing is deleted)",
3693
- `Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_FENCE}`,
3936
+ `Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_POINTER}`,
3694
3937
  z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
3695
3938
  async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
3696
3939
  ),
@@ -3725,16 +3968,16 @@ ${d.outline}` : "Proposed Content structure.", d);
3725
3968
  def(
3726
3969
  "get_seo",
3727
3970
  "Get site SEO",
3728
- "Get the connected project's site-wide SEO settings \u2014 default meta (title/description/ogImage), JSON-LD siteSchema, and robots/sitemap/rss config.",
3971
+ GET_SEO_DESCRIPTION,
3729
3972
  z.object({}).shape,
3730
3973
  async (c) => ok("SEO settings.", await data(c, "GET", `/management/seo`))
3731
3974
  ),
3732
3975
  def(
3733
3976
  "update_seo",
3734
3977
  "Update site SEO",
3735
- "Set site-wide SEO defaults. `seoDefaults` { metaTitle, metaDescription, ogImage, twitterHandle } applies to every page unless overridden. Optionally robotsConfig / sitemapConfig / rssConfig. Each field is REPLACED whole. Rebuilds the site.",
3736
- z.object({ seoDefaults: z.record(z.string(), z.unknown()).optional(), siteSchema: z.record(z.string(), z.unknown()).optional(), robotsConfig: z.record(z.string(), z.unknown()).optional(), sitemapConfig: z.record(z.string(), z.unknown()).optional(), rssConfig: z.record(z.string(), z.unknown()).optional() }).shape,
3737
- async (c, a) => ok("Updated SEO.", await data(c, "PATCH", `/management/seo`, a))
3978
+ UPDATE_SEO_DESCRIPTION,
3979
+ seoPatchShape,
3980
+ async (c, a) => ok("Updated SEO.", await data(c, "PATCH", `/management/seo`, Object.fromEntries(Object.keys(seoPatchShape).filter((k) => a[k] !== void 0).map((k) => [k, a[k]]))))
3738
3981
  ),
3739
3982
  def(
3740
3983
  "get_site_files",
@@ -3765,23 +4008,25 @@ ${d.outline}` : "Proposed Content structure.", d);
3765
4008
  def(
3766
4009
  "get_workflow_board",
3767
4010
  "List editorial workflow stages",
3768
- "List the connected project's editorial board columns in order \u2014 each stage's `key`, name, and whether it is the `publishGate` (the one stage content may go live from). Read this BEFORE move_entry_stage / move_page_stage: stage keys are per-project and not guessable, because a project can rename or replace every column.",
4011
+ "List the connected project's editorial board columns in order \u2014 each stage's `key`, name, and whether it is the `publishGate` (the one stage content may go live from). Read this BEFORE move_stage: stage keys are per-project and not guessable, because a project can rename or replace every column.",
3769
4012
  z.object({}).shape,
3770
4013
  async (c) => ok("Workflow stages.", await data(c, "GET", `/management/workflow/stages`))
3771
4014
  ),
3772
4015
  def(
3773
- "move_entry_stage",
3774
- "Move a content entry across the board",
3775
- "Move a content entry to another editorial stage \u2014 this is how you get an entry OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board (null clears it). Optionally set `workflowAssigneeIds` / `workflowDueDate`, or pass `fromStage` to fail with 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry status:'published' \u2014 approving and publishing are deliberately two separate calls.",
3776
- z.object({ entryId: z.string().min(1), workflowStage: z.string().max(64).describe("stage key from get_workflow_board"), fromStage: z.string().max(64).optional().describe("the stage you believe it is in \u2014 409s if it has moved") }).shape,
3777
- async (c, a) => ok("Moved entry.", await data(c, "PATCH", `/management/content/entries/${s(a.entryId)}/workflow`, { workflowStage: a.workflowStage, fromStage: a.fromStage }))
3778
- ),
3779
- def(
3780
- "move_page_stage",
3781
- "Move a page across the board",
3782
- "Move a page to another editorial stage \u2014 the page twin of move_entry_stage, and the only way to take a page out of review so update_page (status:'published') can go live. `workflowStage` is a stage key from get_workflow_board. Moving into the publish-gate stage is an approval and needs a publish-enabled connection.",
3783
- z.object({ pageId: z.string().min(1), workflowStage: z.string().max(64).describe("stage key from get_workflow_board"), fromStage: z.string().max(64).optional().describe("the stage you believe it is in \u2014 409s if it has moved") }).shape,
3784
- async (c, a) => ok("Moved page.", await data(c, "PATCH", `/management/pages/${s(a.pageId)}/workflow`, { workflowStage: a.workflowStage, fromStage: a.fromStage }))
4016
+ "move_stage",
4017
+ "Move an entry or a page across the board",
4018
+ MOVE_STAGE_DESCRIPTION,
4019
+ z.object({
4020
+ entryId: z.string().min(1).optional().describe("the entry to move; or send pageId"),
4021
+ pageId: z.string().min(1).optional().describe("instead of entryId: the page to move"),
4022
+ workflowStage: z.string().max(64).describe("stage key from get_workflow_board"),
4023
+ fromStage: z.string().max(64).optional().describe("the stage you believe it is in \u2014 409s if it has moved")
4024
+ }).shape,
4025
+ async (c, a) => {
4026
+ if (!a.entryId === !a.pageId) return fail("Pass exactly one of entryId (a content entry) or pageId (a page).");
4027
+ const path = a.pageId ? `/management/pages/${s(a.pageId)}/workflow` : `/management/content/entries/${s(a.entryId)}/workflow`;
4028
+ return ok(a.pageId ? "Moved page." : "Moved entry.", await data(c, "PATCH", path, { workflowStage: a.workflowStage, fromStage: a.fromStage }));
4029
+ }
3785
4030
  ),
3786
4031
  def(
3787
4032
  "promote_project",
@@ -3819,6 +4064,48 @@ ${d.outline}` : "Proposed Content structure.", d);
3819
4064
  z.object({}).shape,
3820
4065
  async (c) => ok("Projects.", await data(c, "GET", `/management/projects`))
3821
4066
  ),
4067
+ {
4068
+ name: "whoami",
4069
+ config: { title: "Who this connection acts as", description: WHOAMI_DESCRIPTION, inputSchema: {} },
4070
+ /**
4071
+ * The stdio server holds a device-grant token and nothing that lists its grants, so it
4072
+ * reports only what it can observe: whether GET /projects/current resolves a project with
4073
+ * none named (a project-bound grant) or refuses with 400 (a workspace-wide one). The
4074
+ * lifted `projectId`, when sent, is checked separately — it must not decide the scope.
4075
+ */
4076
+ handler: guard(async () => {
4077
+ const requested = currentProject.getStore();
4078
+ const current = (c) => data(c, "GET", `/management/projects/current`);
4079
+ const bound = await currentProject.run(
4080
+ void 0,
4081
+ () => withClient(async (c) => {
4082
+ try {
4083
+ return { apiBaseUrl: c.url(""), project: await current(c) };
4084
+ } catch (err) {
4085
+ if (err instanceof BetterCMSError && err.status === 400) return { apiBaseUrl: c.url(""), project: null };
4086
+ throw err;
4087
+ }
4088
+ })
4089
+ );
4090
+ const target = requested ? await withClient(current) : null;
4091
+ const pick = (p) => p ? { id: p.id, name: p.name, slug: p.slug } : null;
4092
+ const scope = bound.project ? "project" : "workspace";
4093
+ return ok(
4094
+ bound.project ? `Bound to project '${bound.project.slug}' through a device grant.` : "A workspace-wide device grant: pass projectId on every project tool.",
4095
+ {
4096
+ surface: "stdio",
4097
+ principal: { kind: "device_grant" },
4098
+ apiBaseUrl: bound.apiBaseUrl,
4099
+ scope,
4100
+ workspaceSlug: (bound.project ?? target)?.workspaceSlug ?? null,
4101
+ project: pick(bound.project),
4102
+ ...requested ? { requestedProject: pick(target) } : {},
4103
+ grants: null,
4104
+ note: "This server cannot see the connection's grants; a missing one is a 403 naming it on the call that needs it. The hosted connector's whoami lists them."
4105
+ }
4106
+ );
4107
+ })
4108
+ },
3822
4109
  def(
3823
4110
  "update_project",
3824
4111
  "Update the connected project",
@@ -3848,7 +4135,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3848
4135
  def(
3849
4136
  "set_authoring_preference",
3850
4137
  "Set the site's authoring architecture",
3851
- "Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project, deploy_from_upload and promote_project all refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, carrying this project's real page counts to show the user. Unset, the release lane already acts as `components`; only `fields` stops it. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Asked once per project; re-callable if the user changes their mind.",
4138
+ "Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project and promote_project both refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, carrying this project's real page counts to show the user. Unset, the release lane already acts as `components`; only `fields` stops it. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Asked once per project; re-callable if the user changes their mind.",
3852
4139
  // Optional in the schema for exactly the reason `framework` is above: a required arg is
3853
4140
  // rejected by the SDK before the handler runs, which would kill the elicitation below
3854
4141
  // and leave the model guessing. Optional here, answered by a human there. The backend
@@ -3892,7 +4179,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3892
4179
  def(
3893
4180
  "submit_conversion_receipt",
3894
4181
  "Record what the conversion codemod could and could not do",
3895
- "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL: a `--forms` run's receipt has all-zero `paths` and a `forms` block; write it to its own file (`--receipt forms-receipt.json`) and submit it under the same `briefDigest` (stored beside the binding receipt, never counted in coverage), then publish every form its `forms.pending` names. \u{1F534} THE SITE'S DESIGN IS NOT YOURS TO CHANGE: run `npx @bettercms-ai/convert --verify-styles --base <commit before the conversion> --root .` and include its `styleEdits` in the receipt; one listing any changed class, className or style is refused with 409 `style-edits` naming every token \u2014 restore them. Requires artifact:write.",
4182
+ "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt, submitted as a SECOND call under the same `briefDigest`, and the `styleEdits` of `npx @bettercms-ai/convert --verify-styles` inside this receipt \u2014 one listing a changed class, className or style is refused with 409 `style-edits`. Requires artifact:write.",
3896
4183
  z.object({
3897
4184
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
3898
4185
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim: `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }` plus `styleEdits`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length; each pending `reason` is the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026); a path with no row reads `not-declared`.")
@@ -3906,20 +4193,6 @@ ${d.outline}` : "Proposed Content structure.", d);
3906
4193
  z.object({ sourceProjectId: z.string().optional(), name: z.string().optional(), slug: z.string().optional() }).shape,
3907
4194
  async (c, a) => ok("Cloned project.", await data(c, "POST", `/management/projects/clone`, a))
3908
4195
  ),
3909
- def(
3910
- "create_template",
3911
- "Save a project/page as a template",
3912
- "Save a project (or one page) as a REUSABLE template \u2014 a frozen snapshot of its content models, pages, entries, components, and forms (secrets/domains/analytics excluded). Later seed a new project from it with create_project { templateId }. Set visibility 'public' to list it in the cross-workspace gallery. Returns the new template's id.",
3913
- z.object({ sourceProjectId: z.string().min(1), name: z.string().min(1), scope: z.string().optional(), sourcePageId: z.string().optional(), description: z.string().optional(), visibility: z.string().optional() }).shape,
3914
- async (c, a) => ok("Created template.", await data(c, "POST", `/management/templates`, a))
3915
- ),
3916
- def(
3917
- "list_templates",
3918
- "List saved templates",
3919
- "List your workspace's saved templates (id, name, scope, visibility). Use a template's id as create_project { templateId } to seed a new project from it.",
3920
- z.object({}).shape,
3921
- async (c) => ok("Templates.", await data(c, "GET", `/management/templates`))
3922
- ),
3923
4196
  // ── Content models (read/metadata; parity with remote /mcp) ──
3924
4197
  def(
3925
4198
  "list_content_models",
@@ -3931,7 +4204,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3931
4204
  def(
3932
4205
  "get_content_model",
3933
4206
  "Get a content model",
3934
- "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.",
4207
+ "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. Its `optimisticVersion` is the ifMatch for update_content_model.",
3935
4208
  z.object({ modelId: z.string().min(1) }).shape,
3936
4209
  async (c, a) => ok("Content model.", await data(c, "GET", `/management/content/models/${s(a.modelId)}`))
3937
4210
  ),
@@ -3953,21 +4226,34 @@ ${d.outline}` : "Proposed Content structure.", d);
3953
4226
  fieldLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe("path \u2192 label"),
3954
4227
  fieldHelpText: z.record(z.string(), z.string().max(500).nullable()).optional().describe("path \u2192 help text, null clears it"),
3955
4228
  override: z.boolean().optional().describe(OVERRIDE_NOTE),
3956
- ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE)
4229
+ ifMatch: z.number().int().min(0).optional().describe(MODEL_IF_MATCH_NOTE),
4230
+ removeFields: z.array(z.string().min(1)).min(1).max(100).optional().describe(FIELD_REMOVAL_NOTE),
4231
+ allowFieldRemoval: z.boolean().optional().describe(ALLOW_FIELD_REMOVAL_NOTE)
3957
4232
  }).shape,
3958
- // `fields` is deliberately not sent: the PATCH replaces `fields` wholesale when present.
4233
+ // `fields` is sent only by removeFields, rebuilt from the model just read: the PATCH
4234
+ // replaces `fields` wholesale when present.
3959
4235
  async (c, a) => {
3960
4236
  if (Boolean(a.modelId) === Boolean(a.pageId)) return fail("Send exactly one of modelId or pageId.");
3961
4237
  const path = a.pageId ? `/management/pages/${s(a.pageId)}/model` : `/management/content/models/${s(a.modelId)}`;
3962
4238
  const body = Object.fromEntries(
3963
4239
  MODEL_UPDATE_KEYS.filter((k) => a[k] !== void 0).map((k) => [k, k === "fieldsets" ? dropProvenance(a[k]) : a[k]])
3964
4240
  );
4241
+ let ifMatch = a.ifMatch;
4242
+ const removal = fieldRemovalRequest(a);
4243
+ if (removal.kind === "refuse") return fail(removal.error);
4244
+ if (removal.kind === "remove") {
4245
+ const plan = planFieldRemoval(await data(c, "GET", path), removal.keys, a.ifMatch);
4246
+ if (!plan.ok) return fail(plan.error);
4247
+ body.fields = plan.fields;
4248
+ body.allowFieldRemoval = true;
4249
+ ifMatch = plan.ifMatch;
4250
+ }
3965
4251
  return ok(
3966
4252
  "Updated content model.",
3967
4253
  (await c.fetchJSON(c.url(path), {
3968
4254
  method: "PATCH",
3969
4255
  body: JSON.stringify(body),
3970
- ...a.ifMatch !== void 0 ? { headers: { "if-match": `W/"${a.ifMatch}"` } } : {}
4256
+ ...ifMatch !== void 0 ? { headers: { "if-match": `W/"${ifMatch}"` } } : {}
3971
4257
  })).data
3972
4258
  );
3973
4259
  }
@@ -4002,14 +4288,15 @@ ${d.outline}` : "Proposed Content structure.", d);
4002
4288
  def(
4003
4289
  "update_page",
4004
4290
  "Edit a page",
4005
- "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,
4006
- 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,
4291
+ "Edit a page: title, slug, SEO metaTitle/metaDescription, structured data (`schemaType`, `schema`), publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first and send its `optimisticVersion` as `ifMatch`). A `blockJson` that swaps a component block to another component comes back with `warnings` (COMPONENT_SWAP_DROPS_OVERRIDES) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use add_field (pageId) / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call \u2014 and on a singleton it publishes the page's content entry too; if the entry's workflow stage refuses, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT with the entry id and the reason. STRUCTURED DATA is native SEO, never a field: leave it out and the page gets Automatic JSON-LD from its content (WebSite on the home page, Blog/CollectionPage on a collection's list page, WebPage otherwise); `schemaType` picks an explicit schema.org type whose properties are filled from the content; `schema` is pasted JSON-LD and wins over both (null clears it). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
4292
+ 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(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE) }).shape,
4007
4293
  async (c, a) => {
4008
4294
  const res = await c.fetchJSON(
4009
4295
  c.url(`/management/pages/${s(a.pageId)}/meta`),
4010
4296
  {
4011
4297
  method: "PATCH",
4012
- body: JSON.stringify({ title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, schemaType: a.schemaType, schema: a.schema, status: a.status, noindex: a.noindex, canonical: a.canonical, og: a.og, twitter: a.twitter })
4298
+ ...a.ifMatch !== void 0 ? { headers: { "If-Match": `W/"${a.ifMatch}"` } } : {},
4299
+ body: JSON.stringify({ title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, schemaType: a.schemaType, schema: a.schema, status: a.status, noindex: a.noindex, canonical: a.canonical, og: a.og, twitter: a.twitter, scheduledAt: a.scheduledAt, unpublishAt: a.unpublishAt, locale: a.locale, translationOf: a.translationOf })
4013
4300
  }
4014
4301
  );
4015
4302
  const note = res.redirectNote;
@@ -4031,24 +4318,24 @@ ${res.warnings.join("\n")}` : summary, res.data);
4031
4318
  def(
4032
4319
  "deploy_project",
4033
4320
  "Deploy new source/build",
4034
- "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`: SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. For a LARGE archive (or a 413/502) use create_deploy_upload + deploy_from_upload, which uploads straight to storage with no size ceiling. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
4035
- z.object({ data: z.string().min(1).describe("base64 .tgz/.zip of the project"), mimeType: z.string().optional() }).shape,
4036
- async (c, a) => ok("Deploy queued.", await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType))
4321
+ "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
4322
+ z.object({
4323
+ data: z.string().min(1).optional().describe("base64 .tgz/.zip of the project; or send uploadKey"),
4324
+ uploadKey: z.string().min(1).optional().describe("instead of data: the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl"),
4325
+ mimeType: z.string().optional()
4326
+ }).shape,
4327
+ async (c, a) => {
4328
+ if (!a.data === !a.uploadKey) return fail("Pass exactly one of data (the archive, base64) or uploadKey (from create_deploy_upload).");
4329
+ return ok("Deploy queued.", a.uploadKey ? await data(c, "POST", `/management/projects/deploy/from-upload`, { uploadKey: a.uploadKey }) : await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType));
4330
+ }
4037
4331
  ),
4038
4332
  def(
4039
4333
  "create_deploy_upload",
4040
4334
  "Get a presigned deploy-upload URL",
4041
- 'Get a presigned URL to upload a LARGE deploy archive directly to storage, bypassing the ~100 MB body limit deploy_project\'s inline `data` hits. Flow: (1) call this for `uploadUrl` + `uploadKey`; (2) PUT your .tgz/.zip to `uploadUrl` (e.g. `curl -X PUT --data-binary @archive.tgz "<uploadUrl>"`) \u2014 straight to storage, no size ceiling; (3) call deploy_from_upload with `uploadKey`. Still exclude node_modules/.git/build caches. URL expires in 10 minutes.',
4335
+ 'Get a presigned URL to upload a LARGE deploy archive directly to storage, bypassing the ~100 MB body limit deploy_project\'s inline `data` hits. Flow: (1) call this for `uploadUrl` + `uploadKey`; (2) PUT your .tgz/.zip to `uploadUrl` (e.g. `curl -X PUT --data-binary @archive.tgz "<uploadUrl>"`) \u2014 straight to storage, no size ceiling; (3) call deploy_project with `uploadKey`. Still exclude node_modules/.git/build caches. URL expires in 10 minutes.',
4042
4336
  z.object({}).shape,
4043
4337
  async (c) => ok("Upload URL.", await data(c, "POST", `/management/projects/deploy/upload-url`))
4044
4338
  ),
4045
- def(
4046
- "deploy_from_upload",
4047
- "Deploy from a staged upload",
4048
- "Deploy from an archive already uploaded via create_deploy_upload (step 3). Pass the `uploadKey` you received. Builds SOURCE server-side or serves a prebuilt site \u2014 then poll get_deploy_status until live.",
4049
- z.object({ uploadKey: z.string().min(1).describe("the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl") }).shape,
4050
- async (c, a) => ok("Deploy queued.", await data(c, "POST", `/management/projects/deploy/from-upload`, { uploadKey: a.uploadKey }))
4051
- ),
4052
4339
  // ── Git (import a repository, read it, push commits back) ──
4053
4340
  // Same artifact:write authority as the deploy tools: on the build branch a push IS a
4054
4341
  // deploy. Every call runs server-side as the GitHub App installation, so the agent
@@ -4145,20 +4432,228 @@ ${res.warnings.join("\n")}` : summary, res.data);
4145
4432
  async (c, a) => ok("Pull requests.", await data(c, "GET", `/management/git/pulls${q({ state: a.state, limit: a.limit })}`))
4146
4433
  ),
4147
4434
  // ── Insight: what happened, what's working, what's broken ─────────────────
4435
+ def(
4436
+ "update_field",
4437
+ "Change one field of a model or a page",
4438
+ UPDATE_FIELD_DESCRIPTION,
4439
+ z.object({
4440
+ modelId: z.string().min(1).optional().describe("the content model (from list_content_models); or send pageId"),
4441
+ pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to edit"),
4442
+ path: z.string().min(1).describe("the field's key, or dot-separated keys through its parents, e.g. 'hero.title'"),
4443
+ label: z.string().min(1).optional(),
4444
+ helpText: z.string().nullable().optional().describe("null clears it"),
4445
+ required: z.boolean().optional(),
4446
+ config: z.record(z.string(), z.unknown()).optional().describe("per-type validation/config keys, merged into the stored config; null deletes a key"),
4447
+ showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
4448
+ ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; null clears it"),
4449
+ placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
4450
+ type: fieldType.optional().describe("a new type; needs allowRetype"),
4451
+ key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
4452
+ allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
4453
+ allowFieldRemoval: z.boolean().optional().describe("with a rename: discard the old key's stored values"),
4454
+ ifMatch: z.number().int().min(0).optional().describe("the model's optimisticVersion; the version read when omitted")
4455
+ }).shape,
4456
+ // Read the model (a page's through its backingModel), patch ONE field with the helper the
4457
+ // hosted catalogue uses, and PATCH the whole list at the version read.
4458
+ async (c, a) => {
4459
+ let modelId = a.modelId;
4460
+ if (a.pageId) {
4461
+ const page = await data(c, "GET", `/management/pages/${s(a.pageId)}`);
4462
+ modelId = page?.backingModel?.id;
4463
+ if (!modelId) return fail("This page has no backing content model; address its schema by modelId.");
4464
+ }
4465
+ if (!modelId) return fail("Pass modelId (a content model) or pageId (a page).");
4466
+ const plan = planFieldEdit(await data(c, "GET", `/management/content/models/${s(modelId)}`), a, a.ifMatch);
4467
+ if (!plan.ok) return fail(plan.error);
4468
+ const path = a.pageId ? `/management/pages/${s(a.pageId)}/model` : `/management/content/models/${s(modelId)}`;
4469
+ const updated = (await c.fetchJSON(c.url(path), {
4470
+ method: "PATCH",
4471
+ body: JSON.stringify({ fields: plan.fields, ...a.allowFieldRemoval === true ? { allowFieldRemoval: true } : {} }),
4472
+ ...plan.ifMatch !== void 0 ? { headers: { "If-Match": `W/"${plan.ifMatch}"` } } : {}
4473
+ })).data;
4474
+ return ok(plan.renamed ? `Renamed '${plan.renamed.from}' to '${plan.renamed.to}'.` : `Updated field '${s(a.path)}'.`, updated);
4475
+ }
4476
+ ),
4477
+ // ── Search, batch, localisation, automation, branches and AI images (2026-09-30) ──
4478
+ // Descriptions from ./tool-text.ts, shared word for word with the hosted catalogue.
4479
+ def(
4480
+ "search_content",
4481
+ "Search published content",
4482
+ NEW_TOOL_TEXT.search_content,
4483
+ z.object({
4484
+ q: z.string().min(1).max(200).describe("the words to find, 1-200 chars"),
4485
+ type: z.enum(["page", "entry"]).optional().describe("only pages, or only entries"),
4486
+ limit: z.number().int().min(1).max(50).optional().describe("hits to return; 10 when omitted")
4487
+ }).shape,
4488
+ async (c, a) => ok("Search results.", await c.fetchJSON(c.url(`/management/search${q({ q: a.q, type: a.type, limit: a.limit })}`), { method: "GET" }))
4489
+ ),
4490
+ def(
4491
+ "discard_draft",
4492
+ "Discard unpublished edits",
4493
+ NEW_TOOL_TEXT.discard_draft,
4494
+ z.object({
4495
+ entryId: z.string().min(1).optional().describe("the entry (from list_content_entries); or send pageId"),
4496
+ pageId: z.string().min(1).optional().describe("instead of entryId: the page (from list_pages)"),
4497
+ ifMatch: z.number().int().min(0).optional().describe("optimisticVersion from get_content_entry or get_page")
4498
+ }).shape,
4499
+ async (c, a) => {
4500
+ if (!a.entryId === !a.pageId) return fail("Pass exactly one of entryId (an entry) or pageId (a page).");
4501
+ const path = a.pageId ? `/management/pages/${s(a.pageId)}/discard-draft` : `/management/content/entries/${s(a.entryId)}/discard-draft`;
4502
+ return ok("Draft discarded.", (await c.fetchJSON(c.url(path), {
4503
+ method: "POST",
4504
+ body: JSON.stringify({}),
4505
+ ...a.ifMatch !== void 0 ? { headers: { "If-Match": `W/"${a.ifMatch}"` } } : {}
4506
+ })).data);
4507
+ }
4508
+ ),
4509
+ def(
4510
+ "refresh_search_index",
4511
+ "Refresh IndexNow or site search",
4512
+ NEW_TOOL_TEXT.refresh_search_index,
4513
+ z.object({
4514
+ action: z.enum(["indexnow", "reindex"]).describe("'indexnow' pings search engines; 'reindex' rebuilds site search"),
4515
+ urls: z.array(z.string().min(1)).min(1).max(1e3).optional().describe("indexnow only: this site's URLs; every change since the last submission when omitted")
4516
+ }).shape,
4517
+ async (c, a) => ok(a.action === "reindex" ? "Site search rebuilt." : "IndexNow submission.", a.action === "reindex" ? await data(c, "POST", `/management/search/reindex`, {}) : await data(c, "POST", `/management/seo/indexnow/submit`, { urls: a.urls }))
4518
+ ),
4519
+ def(
4520
+ "give_feedback",
4521
+ "Send feedback to the BetterCMS team",
4522
+ NEW_TOOL_TEXT.give_feedback,
4523
+ z.object({
4524
+ kind: z.enum(["bug", "missing_capability", "unclear_docs", "other"]).describe("what went wrong"),
4525
+ message: z.string().min(1).max(1e4).describe("what you tried, what happened and what you expected (1-10000 chars)"),
4526
+ tool: z.string().max(100).optional().describe("the tool it is about, if any"),
4527
+ context: z.string().max(4e3).optional().describe("the request and response, or other detail (up to 4000 chars)")
4528
+ }).shape,
4529
+ async (c, a) => ok("Feedback sent.", await data(c, "POST", `/management/feedback`, { kind: a.kind, message: a.message, tool: a.tool, context: a.context }))
4530
+ ),
4531
+ def(
4532
+ "publish_entries",
4533
+ "Publish or unpublish many entries and pages",
4534
+ NEW_TOOL_TEXT.publish_entries,
4535
+ z.object({
4536
+ action: z.enum(["publish", "unpublish"]).describe("take the items live, or back to draft"),
4537
+ entryIds: z.array(z.string().min(1)).max(100).optional().describe("entry ids (from list_content_entries)"),
4538
+ pageIds: z.array(z.string().min(1)).max(100).optional().describe("page ids (from list_pages); publish only")
4539
+ }).shape,
4540
+ async (c, a) => ok(a.action === "publish" ? "Publish receipt." : "Unpublish receipt.", await data(c, "POST", `/management/publish`, { action: a.action, entryIds: a.entryIds, pageIds: a.pageIds }))
4541
+ ),
4542
+ def(
4543
+ "create_content_entries",
4544
+ "Create many draft entries",
4545
+ NEW_TOOL_TEXT.create_content_entries,
4546
+ z.object({
4547
+ contentModelId: z.string().min(1).describe("the collection every entry belongs to (from list_content_models)"),
4548
+ entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()) })).min(1).max(200).describe("1-200 entries, each {slug?, data}")
4549
+ }).shape,
4550
+ // The whole body: the per-row receipt is `data`, the counts ride in `meta`.
4551
+ async (c, a) => ok("Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries }) }))
4552
+ ),
4553
+ def(
4554
+ "translate_entry",
4555
+ "Translate an entry into another locale",
4556
+ NEW_TOOL_TEXT.translate_entry,
4557
+ z.object({
4558
+ entryId: z.string().min(1).describe("the SOURCE entry, never another translation"),
4559
+ targetLocale: z.string().min(1).describe("BCP-47 locale from the project's locales, e.g. 'fr'"),
4560
+ slug: z.string().optional().describe("the translation's slug; <source slug>-<locale> when omitted"),
4561
+ fields: z.array(z.string().min(1)).optional().describe("translate only these field keys")
4562
+ }).shape,
4563
+ async (c, a) => ok("Translation created.", await c.fetchJSON(c.url(`/management/content/entries/${s(a.entryId)}/translate`), { method: "POST", body: JSON.stringify({ targetLocale: a.targetLocale, slug: a.slug, fields: a.fields }) }))
4564
+ ),
4565
+ def(
4566
+ "list_webhooks",
4567
+ "List webhooks",
4568
+ NEW_TOOL_TEXT.list_webhooks,
4569
+ z.object({}).shape,
4570
+ async (c) => ok("Webhooks.", await data(c, "GET", `/management/webhooks`))
4571
+ ),
4572
+ def(
4573
+ "create_webhook",
4574
+ "Create a webhook",
4575
+ NEW_TOOL_TEXT.create_webhook,
4576
+ z.object({
4577
+ name: z.string().min(1).max(255).describe("a label, 1-255 chars"),
4578
+ url: z.string().url().describe("the https endpoint that receives the events"),
4579
+ events: z.array(z.enum(WEBHOOK_EVENTS)).min(1).optional().describe("events to send; ['content.publish'] when omitted"),
4580
+ isActive: z.boolean().optional().describe("default true"),
4581
+ test: z.boolean().optional().describe("send a sample delivery now")
4582
+ }).shape,
4583
+ // The whole body: a `test` delivery's outcome rides beside `data`.
4584
+ async (c, a) => ok("Webhook created. Store the `secret` now: it is never shown again.", await c.fetchJSON(c.url(`/management/webhooks`), { method: "POST", body: JSON.stringify({ name: a.name, url: a.url, events: a.events, isActive: a.isActive, test: a.test }) }))
4585
+ ),
4586
+ def(
4587
+ "list_branches",
4588
+ "List content branches",
4589
+ NEW_TOOL_TEXT.list_branches,
4590
+ z.object({}).shape,
4591
+ async (c) => ok("Branches.", await c.fetchJSON(c.url(`/management/branches`), { method: "GET" }))
4592
+ ),
4593
+ def(
4594
+ "create_branch",
4595
+ "Create a content branch",
4596
+ NEW_TOOL_TEXT.create_branch,
4597
+ z.object({
4598
+ name: z.string().min(1).max(80).describe("1-80 chars"),
4599
+ slug: z.string().max(80).optional().describe("derived from the name when omitted")
4600
+ }).shape,
4601
+ async (c, a) => ok("Branch created.", await data(c, "POST", `/management/branches`, { name: a.name, slug: a.slug }))
4602
+ ),
4603
+ def(
4604
+ "apply_branch",
4605
+ "Merge a branch into its parent",
4606
+ NEW_TOOL_TEXT.apply_branch,
4607
+ z.object({
4608
+ branchId: z.string().min(1).describe("the branch to merge (from list_branches)"),
4609
+ dryRun: z.boolean().optional().describe("true = return the diff and write nothing"),
4610
+ include: z.array(z.string().min(1)).max(500).optional().describe("change keys to merge (from the diff); all when omitted"),
4611
+ fields: z.record(z.string(), z.record(z.string(), z.enum(["keep_branch", "keep_parent"]))).optional().describe("change key \u2192 field \u2192 'keep_branch' | 'keep_parent', for each conflict")
4612
+ }).shape,
4613
+ async (c, a) => a.dryRun === true ? ok("Branch diff (nothing was written).", await data(c, "GET", `/management/branches/${s(a.branchId)}/diff`)) : ok("Branch applied.", await c.fetchJSON(c.url(`/management/branches/${s(a.branchId)}/apply`), { method: "POST", body: JSON.stringify({ include: a.include, fields: a.fields }) }))
4614
+ ),
4615
+ def(
4616
+ "search_docs",
4617
+ "Search the BetterCMS docs",
4618
+ NEW_TOOL_TEXT.search_docs,
4619
+ z.object({
4620
+ q: z.string().min(1).describe("the question or words to find"),
4621
+ limit: z.number().int().min(1).max(20).optional().describe("pages to return; 5 when omitted")
4622
+ }).shape,
4623
+ async (c, a) => ok("Docs.", await c.fetchJSON(c.url(`/management/docs/search${q({ q: a.q, limit: a.limit })}`), { method: "GET" }))
4624
+ ),
4625
+ def(
4626
+ "generate_image",
4627
+ "Generate an image with AI",
4628
+ NEW_TOOL_TEXT.generate_image,
4629
+ z.object({
4630
+ prompt: z.string().min(1).max(2e3).describe("what the image shows, 1-2000 chars"),
4631
+ altText: z.string().optional().describe("alt text for the new asset"),
4632
+ width: z.number().int().min(256).max(1440).multipleOf(16).optional(),
4633
+ height: z.number().int().min(256).max(1440).multipleOf(16).optional(),
4634
+ folderId: z.string().optional().describe("Media Library folder id")
4635
+ }).shape,
4636
+ async (c, a) => ok("Generated image.", await data(c, "POST", `/management/ai/image`, { prompt: a.prompt, altText: a.altText, width: a.width, height: a.height, folderId: a.folderId }))
4637
+ ),
4638
+ def(
4639
+ "transform_image",
4640
+ "Edit an image with AI",
4641
+ NEW_TOOL_TEXT.transform_image,
4642
+ z.object({
4643
+ mediaId: z.string().min(1).describe("the image to edit (from list_media)"),
4644
+ prompt: z.string().min(1).max(2e3).describe("the change to make, 1-2000 chars"),
4645
+ altText: z.string().optional().describe("alt text for the new asset"),
4646
+ folderId: z.string().optional().describe("Media Library folder id")
4647
+ }).shape,
4648
+ async (c, a) => ok("Edited image (a new asset).", await data(c, "POST", `/management/ai/image/transform`, { mediaId: a.mediaId, prompt: a.prompt, altText: a.altText, folderId: a.folderId }))
4649
+ ),
4148
4650
  def(
4149
4651
  "list_activity",
4150
4652
  "List project activity",
4151
- "Recent activity in the connected project \u2014 who changed what, and when. Filter by `category` (comma-separated: content, pages, media, forms, deployment, settings, members), `actorId`, or a `since`/`until` ISO window.",
4653
+ "Recent activity in the connected project \u2014 who changed what, and when. Filter by `category` (comma-separated: content, pages, media, forms, deployment, settings, members), `actorId`, or a `since`/`until` ISO window. At the start of a turn, pass `since` (your last look) to notice dashboard or agent edits made since.",
4152
4654
  z.object({ category: z.string().optional(), actorId: z.string().optional(), since: z.string().optional(), until: z.string().optional(), limit: z.number().optional() }).shape,
4153
4655
  async (c, a) => ok("Project activity.", await data(c, "GET", `/management/insights/activity${q({ category: a.category, actorId: a.actorId, since: a.since, until: a.until, limit: a.limit })}`))
4154
4656
  ),
4155
- def(
4156
- "get_changes",
4157
- "Get changes since a timestamp",
4158
- "Read the durable project change feed since an ISO timestamp. Call this at the start of a new turn to notice dashboard or agent edits made since your previous observation.",
4159
- z.object({ since: z.string().datetime(), limit: z.number().int().min(1).max(100).optional() }).shape,
4160
- async (c, a) => ok("Project changes.", await data(c, "GET", `/management/insights/activity${q({ since: a.since, limit: a.limit ?? 100 })}`))
4161
- ),
4162
4657
  def(
4163
4658
  "get_next_steps",
4164
4659
  "Get what to do next",
@@ -4170,14 +4665,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
4170
4665
  def(
4171
4666
  "get_binding_report",
4172
4667
  "Check what on the live site is editable",
4173
- "The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason \u2014 not-declared, ambiguous-text, no-element, declared-only, needs-wrap, refused, layout-owned, page-owned, layout-locator-stale, locale-unvarianted, svg-unavailable, partial and unstamped (a page list the content apply left as built: `kind` `list <bound>/<total>`), mismatch (a text-match stamp whose element shows MORE than the value, or no longer the text it was matched on (`data-bcms-from`): never written), report-outdated. DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not; `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path. `unaddressable` is the copy it cannot see: visible live text no field owns, by route and bucket, biggest first (null = unmeasured: diff visible text against entry values). Over `count / visible > 0.02`, fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook \xA711. `builtRoutes`: routes the live build has HTML for (null = cannot say). `layoutIncomplete`: Layout paths the last content apply could not write (their publish waits); `layoutUnread`: Layout fields no route reads (`layoutUnreadJudged` false = not judged). `canvas.lane`: the editor's live-preview lane (`bridge`, `draft-route`; `none` \u2192 get_next_steps). `coverage.error` `nothing-bound`: no element of this build is bound, so its percentage describes another site.",
4174
- z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves.") }).shape,
4668
+ "The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason \u2014 not-declared, ambiguous-text, no-element, declared-only, needs-wrap, refused, layout-owned, page-owned, layout-locator-stale, locale-unvarianted, svg-unavailable, partial and unstamped (a page list the content apply left as built: `kind` `list <bound>/<total>`), mismatch (a text-match stamp whose element shows MORE than the value, or no longer the text it was matched on (`data-bcms-from`): never written), report-outdated. DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not; `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path. `unaddressable` is the copy it cannot see: visible live text no field owns, by route and bucket, biggest first (null = unmeasured: diff visible text against entry values). Over `count / visible > 0.02`, fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook \xA711. `canvas.lane`: the editor's live-preview lane (`bridge`, `draft-route`; `none` \u2192 get_next_steps). `coverage.error` `nothing-bound`: no element of this build is bound, so its percentage describes another site.",
4669
+ z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves. Also per slot: `builtRoutes`: routes the live build has HTML for (null = cannot say). `layoutIncomplete`: Layout paths the last content apply could not write (their publish waits); `layoutUnread`: Layout fields no route reads (`layoutUnreadJudged` false = not judged).") }).shape,
4175
4670
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
4176
4671
  ),
4177
4672
  def(
4178
4673
  "get_conversion_brief",
4179
4674
  "Get the brief for making this site's bindings durable",
4180
- "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_page_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
4675
+ "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
4181
4676
  z.object({
4182
4677
  complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
4183
4678
  cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
@@ -4201,9 +4696,9 @@ ${res.warnings.join("\n")}` : summary, res.data);
4201
4696
  def(
4202
4697
  "get_componentize_plan",
4203
4698
  "Get the plan for turning this site's sections into components",
4204
- "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite). Chrome (`nav`/`footer`/`modal`: LAYOUT_CHROME, or in the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
4699
+ "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered (its codes: see `summary`). Chrome (`nav`/`footer`/`modal`: LAYOUT_CHROME, or in the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
4205
4700
  z.object({
4206
- summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first."),
4701
+ summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first. `pending` codes: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite)."),
4207
4702
  paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
4208
4703
  cursor: z.string().optional().describe("The `cursor` from the previous page. Implies paged. A 409 `PLAN_CHANGED` means start again with no cursor.")
4209
4704
  }).shape,
@@ -4437,102 +4932,6 @@ ${res.warnings.join("\n")}` : summary, res.data);
4437
4932
  z.object({ jobId: z.string().min(1) }).shape,
4438
4933
  async (c, a) => ok("Job rejected.", await data(c, "POST", `/management/bulk/jobs/${s(a.jobId)}/reject`))
4439
4934
  ),
4440
- def(
4441
- "list_section_validation_requests",
4442
- "List queued Section validation requests",
4443
- "Poll for user-agent Section implementation-validation requests in this exact project. BetterCMS cannot push work into an ordinary MCP client: call this explicitly, claim one request, inspect and run the customer's real repository/app shell, then submit request-bound evidence and complete it. Returns only unclaimed user-agent requests; it never exposes another project.",
4444
- listSectionValidationRequestsInput.shape,
4445
- async (c, a) => ok("Queued user-agent Section validation requests.", await data(
4446
- c,
4447
- "GET",
4448
- `/projects/${s(a.projectId)}/section-evidence/implementation-requests${q({ limit: a.limit })}`
4449
- ))
4450
- ),
4451
- def(
4452
- "claim_section_validation_request",
4453
- "Claim a Section validation request",
4454
- "Atomically claim one queued user-agent request and pin the exact git commit you will inspect. Claim BEFORE submitting evidence. BetterCMS records coordination only; all customer code and responsive checks must run in the user's repository and real app shell.",
4455
- claimSectionValidationRequestInput.shape,
4456
- async (c, a) => ok("Section validation request claimed.", await data(
4457
- c,
4458
- "POST",
4459
- `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/claim`,
4460
- {
4461
- commitSha: a.commitSha,
4462
- providerRunId: a.providerRunId,
4463
- providerRunUrl: a.providerRunUrl
4464
- }
4465
- ))
4466
- ),
4467
- def(
4468
- "complete_section_validation_request",
4469
- "Complete a Section validation request",
4470
- "Complete a claimed request only after submit_section_manifest and submit_section_validation have bound exact, passed evidence to the same requestId and pinned commit. This cannot grant the separate human Visual Approval.",
4471
- completeSectionValidationRequestInput.shape,
4472
- async (c, a) => ok("Section validation request completed.", await data(
4473
- c,
4474
- "POST",
4475
- `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/complete`,
4476
- { manifestId: a.manifestId, validationRunId: a.validationRunId }
4477
- ))
4478
- ),
4479
- def(
4480
- "fail_section_validation_request",
4481
- "Fail a Section validation request",
4482
- "Truthfully close a claimed request when the renderer, validation command, app shell, or required evidence is missing or fails. This path intentionally works without a manifest so 'not implemented' is representable instead of being reported as passed or left queued forever.",
4483
- failSectionValidationRequestInput.shape,
4484
- async (c, a) => ok("Section validation request failed.", await data(
4485
- c,
4486
- "POST",
4487
- `/projects/${s(a.projectId)}/section-evidence/implementation-requests/${s(a.requestId)}/fail`,
4488
- {
4489
- errorCode: a.errorCode,
4490
- errorMessage: a.errorMessage,
4491
- providerRunId: a.providerRunId,
4492
- providerRunUrl: a.providerRunUrl
4493
- }
4494
- ))
4495
- ),
4496
- def(
4497
- "submit_section_manifest",
4498
- "Submit a Section implementation manifest",
4499
- "Record the implementation manifest produced for one exact Section schema version by CI or an agent running INSIDE the user's repository. BetterCMS does not execute customer code: inspect/build the real renderer in the user's app shell first, then submit its exact API ID, schema hash, commit SHA, loader, preview adapter, and native responsive viewports. Requires a project-scoped artifact:write credential. This records evidence only; it does not validate the implementation and cannot create human Visual Approval.",
4500
- submitSectionManifestInput.shape,
4501
- async (c, a) => ok("Section implementation manifest recorded.", await data(
4502
- c,
4503
- "POST",
4504
- `/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/manifests`,
4505
- {
4506
- requestId: a.requestId,
4507
- apiId: a.apiId,
4508
- schemaHash: a.schemaHash,
4509
- commitSha: a.commitSha,
4510
- loader: a.loader,
4511
- previewAdapter: a.previewAdapter,
4512
- nativeViewports: a.nativeViewports
4513
- }
4514
- ))
4515
- ),
4516
- def(
4517
- "submit_section_validation",
4518
- "Submit a Section validation result",
4519
- "Record a real validation result for one exact Section schema version and manifest. Run the component in the user's repository and real app shell across the canonical preview dataset, AI stress fixtures, and required/native viewports BEFORE calling this tool; BetterCMS never runs that customer code. Pass status 'passed' only when those checks actually passed, otherwise 'failed'. The manifest pins schemaHash and commitSha; fixtureHash and evidenceDigest pin the tested data and immutable evidence bundle. Requires project-scoped artifact:write and cannot create the separate human Visual Approval required for publication.",
4520
- submitSectionValidationInput.shape,
4521
- async (c, a) => ok("Section validation result recorded.", await data(
4522
- c,
4523
- "POST",
4524
- `/projects/${s(a.projectId)}/section-evidence/sections/${s(a.sectionId)}/versions/${a.version}/validations`,
4525
- {
4526
- requestId: a.requestId,
4527
- manifestId: a.manifestId,
4528
- status: a.status,
4529
- fixtureHash: a.fixtureHash,
4530
- evidenceDigest: a.evidenceDigest,
4531
- appShell: a.appShell,
4532
- viewportResults: a.viewportResults
4533
- }
4534
- ))
4535
- ),
4536
4935
  def(
4537
4936
  "get_deploy_status",
4538
4937
  "Get deploy/build status",
@@ -4582,7 +4981,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4582
4981
  name: "list_pages",
4583
4982
  config: {
4584
4983
  title: "List pages in the current project",
4585
- description: "List the pages in the project this MCP key is bound to, each with its full field SCHEMA, pageType (singleton|dynamic), and status. Use it to verify WHERE content lands and to read field keys/types before set_page_content / add_page_field.",
4984
+ description: "List the pages in the project this MCP key is bound to, each with its full field SCHEMA, pageType (singleton|dynamic), and status. Use it to verify WHERE content lands and to read field keys/types before set_page_content / add_field.",
4586
4985
  inputSchema: {}
4587
4986
  },
4588
4987
  handler: guard(
@@ -4606,7 +5005,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4606
5005
  name: "get_page",
4607
5006
  config: {
4608
5007
  title: "Get a page (with its field schema)",
4609
- description: "Get one page by id INCLUDING its full field schema (keys, types, nested group/repeater children) and pageType. Read this before set_page_content so you write correctly-keyed values, or before add_page_field so you know the existing keys.",
5008
+ description: "Get one page by id INCLUDING its full field schema (keys, types, nested group/repeater children) and pageType. Read this before set_page_content so you write correctly-keyed values, or before add_field so you know the existing keys.",
4610
5009
  inputSchema: getPageInput.shape
4611
5010
  },
4612
5011
  handler: guard(
@@ -4640,7 +5039,9 @@ ${res.warnings.join("\n")}` : summary, res.data);
4640
5039
  ...args.fields ? { fields: args.fields.map(toField) } : {},
4641
5040
  ...args.data !== void 0 ? { data: args.data } : {},
4642
5041
  ...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
4643
- ...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
5042
+ ...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {},
5043
+ ...args.locale !== void 0 ? { locale: args.locale } : {},
5044
+ ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
4644
5045
  });
4645
5046
  const { warnings, seedErrors, ...created } = page;
4646
5047
  const summary = `Created ${created.pageType ?? "page"} page '${created.title}' (id ${created.id}, slug ${created.slug}) with ${created.fields.length} field(s).`;
@@ -4683,13 +5084,24 @@ ${notes.join("\n")}` : summary, created);
4683
5084
  {
4684
5085
  name: "add_field",
4685
5086
  config: {
4686
- title: "Add a field to a content model",
4687
- 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,
5087
+ title: "Add a field to a content model or a page",
5088
+ description: ADD_FIELD_DESCRIPTION + " " + RICH_TEXT_NOTE,
4688
5089
  inputSchema: addFieldInput.shape
4689
5090
  },
4690
5091
  handler: guard(
4691
5092
  async (args) => withClient(async (client) => {
4692
- const model = await client.getModel(args.modelId);
5093
+ if (!args.modelId === !args.pageId) return fail("Pass exactly one of modelId (a content model) or pageId (a page).");
5094
+ if (args.pageId) {
5095
+ const page = await client.addPageFields(args.pageId, {
5096
+ // Cast: the SDK types `options` as string[], narrower than the API, which also takes
5097
+ // {label, value} (backend selectFieldSchema).
5098
+ addFields: [toField(args)],
5099
+ ...args.after !== void 0 ? { after: args.after } : {}
5100
+ });
5101
+ return ok(`Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`, page);
5102
+ }
5103
+ const modelId = args.modelId;
5104
+ const model = await client.getModel(modelId);
4693
5105
  const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
4694
5106
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
4695
5107
  const existingFields = model.fields;
@@ -4707,9 +5119,8 @@ ${notes.join("\n")}` : summary, created);
4707
5119
  }
4708
5120
  const nextFields = [...existingFields];
4709
5121
  nextFields.splice(at, 0, toField(args));
4710
- const updated = await client.updateModel(args.modelId, {
4711
- fields: nextFields
4712
- });
5122
+ const input = { fields: nextFields };
5123
+ const updated = model.optimisticVersion !== void 0 ? await client.updateModel(modelId, input, { ifMatch: model.optimisticVersion }) : await client.updateModel(modelId, input);
4713
5124
  return ok(
4714
5125
  `Added field '${args.key}' to '${updated.name}'${args.after ? ` after '${args.after}'` : ""}. Model now has ${updated.fields.length} field(s).`,
4715
5126
  updated
@@ -4717,33 +5128,11 @@ ${notes.join("\n")}` : summary, created);
4717
5128
  })
4718
5129
  )
4719
5130
  },
4720
- {
4721
- name: "add_page_field",
4722
- config: {
4723
- title: "Add a field to a page",
4724
- description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. 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. A 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section.",
4725
- inputSchema: addPageFieldInput.shape
4726
- },
4727
- handler: guard(
4728
- async (args) => withClient(async (client) => {
4729
- const page = await client.addPageFields(args.pageId, {
4730
- // Cast: same as create_page. The SDK types `options` as string[], narrower than the
4731
- // API, which also takes {label, value} (backend selectFieldSchema).
4732
- addFields: [toField(args)],
4733
- ...args.after !== void 0 ? { after: args.after } : {}
4734
- });
4735
- return ok(
4736
- `Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`,
4737
- page
4738
- );
4739
- })
4740
- )
4741
- },
4742
5131
  {
4743
5132
  name: "create_content_entry",
4744
5133
  config: {
4745
5134
  title: "Create a content entry",
4746
- description: "Create a content entry under a model. " + PAGES_NOT_COLLECTIONS_NOTE + " Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
5135
+ description: "Create a content entry under a model. " + PAGES_NOT_COLLECTIONS_NOTE + " Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required. One entry per call: for volume, run code against the Management API.",
4747
5136
  inputSchema: createEntryInput.shape
4748
5137
  },
4749
5138
  handler: guard(
@@ -4751,7 +5140,10 @@ ${notes.join("\n")}` : summary, created);
4751
5140
  const created = await client.createEntry({
4752
5141
  contentModelId: args.contentModelId,
4753
5142
  ...args.slug !== void 0 ? { slug: args.slug } : {},
4754
- ...args.data !== void 0 ? { data: args.data } : {}
5143
+ ...args.data !== void 0 ? { data: args.data } : {},
5144
+ // The SDK's input type predates localization; its body is the input as given.
5145
+ ...args.locale !== void 0 ? { locale: args.locale } : {},
5146
+ ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
4755
5147
  });
4756
5148
  const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
4757
5149
  const { warnings, ...entry } = published;
@@ -4767,7 +5159,7 @@ ${notes.join("\n")}` : summary, created);
4767
5159
  name: "set_page_content",
4768
5160
  config: {
4769
5161
  title: "Set a page's field values (content)",
4770
- description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only. Merges by top-level key (a zone value replaces that whole zone); pass replace:true to remove omitted fields.",
5162
+ description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL or { url, altText?, focal?, crop? } \u2014 focal {x,y} and crop {top,right,bottom,left} (0\u20131, cut from each edge) frame THIS placement only. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only. Merges by top-level key (a zone value replaces that whole zone); pass replace:true to remove omitted fields.",
4771
5163
  inputSchema: setPageContentInput.shape
4772
5164
  },
4773
5165
  handler: guard(
@@ -4788,17 +5180,26 @@ ${notes.join("\n")}` : summary, created);
4788
5180
  name: "list_content_entries",
4789
5181
  config: {
4790
5182
  title: "List content entries (incl. drafts)",
4791
- 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.",
5183
+ description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page, by field values (`where`), sorted and paged. 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. Returns `limit` entries per `page` (50 by default); the summary says which page of how many.",
4792
5184
  inputSchema: listEntriesInput.shape
4793
5185
  },
4794
5186
  handler: guard(
4795
5187
  async (args) => withClient(async (client) => {
4796
- const entries = await client.listEntries({
5188
+ const filter = {
4797
5189
  ...args.modelId ? { modelId: args.modelId } : {},
4798
5190
  ...args.pageId ? { pageId: args.pageId } : {},
4799
5191
  ...args.status ? { status: args.status } : {}
4800
- });
4801
- return ok(`${entries.length} entr(y/ies).`, entries);
5192
+ };
5193
+ if ([args.limit, args.where, args.sort, args.page].every((v) => v === void 0)) {
5194
+ const entries = await client.listEntries(filter);
5195
+ return ok(`${entries.length} entr(y/ies).`, entries);
5196
+ }
5197
+ const res = await client.fetchJSON(
5198
+ client.url(`/management/content/entries${entryListSearch(args)}`),
5199
+ { method: "GET" }
5200
+ );
5201
+ const paged = res.meta ? ` Page ${res.meta.page} of ${res.meta.totalPages} (${res.meta.total} in all).` : "";
5202
+ return ok(`${res.data.length} entr(y/ies).${paged}`, res.data);
4802
5203
  })
4803
5204
  )
4804
5205
  },
@@ -4820,17 +5221,24 @@ ${notes.join("\n")}` : summary, created);
4820
5221
  name: "update_content_entry",
4821
5222
  config: {
4822
5223
  title: "Update a content entry's values",
4823
- 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,
5224
+ description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + ENTRY_META_NOTE,
4824
5225
  inputSchema: updateEntryInput.shape
4825
5226
  },
4826
5227
  handler: guard(
4827
5228
  async (args) => withClient(async (client) => {
4828
- const { warnings, ...entry } = await client.updateEntry(args.entryId, {
4829
- ...args.data !== void 0 ? { data: args.data } : {},
5229
+ const write = args.merge === true && args.data !== void 0 ? planEntryMerge(await client.getEntry(args.entryId), args.data, args.ifMatch) : { data: args.data, ifMatch: args.ifMatch };
5230
+ const input = {
5231
+ ...write.data !== void 0 ? { data: write.data } : {},
4830
5232
  ...args.status !== void 0 ? { status: args.status } : {},
4831
5233
  ...args.slug !== void 0 ? { slug: args.slug } : {},
4832
- ...args.meta !== void 0 ? { meta: args.meta } : {}
4833
- });
5234
+ ...args.meta !== void 0 ? { meta: args.meta } : {},
5235
+ // Not on the SDK's input type yet; the route accepts both (null cancels).
5236
+ ...args.scheduledAt !== void 0 ? { scheduledAt: args.scheduledAt } : {},
5237
+ ...args.unpublishAt !== void 0 ? { unpublishAt: args.unpublishAt } : {},
5238
+ ...args.locale !== void 0 ? { locale: args.locale } : {},
5239
+ ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
5240
+ };
5241
+ const { warnings, ...entry } = write.ifMatch === void 0 ? await client.updateEntry(args.entryId, input) : await client.updateEntry(args.entryId, input, { ifMatch: write.ifMatch });
4834
5242
  return ok([`Updated entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...warnings ?? []].join("\n"), entry);
4835
5243
  })
4836
5244
  )
@@ -4839,12 +5247,15 @@ ${notes.join("\n")}` : summary, created);
4839
5247
  name: "upload_asset",
4840
5248
  config: {
4841
5249
  title: "Upload an asset to the Media Library",
4842
- description: "Upload an image/asset from a local file path or a remote URL into the project's Media Library. Returns the asset's stable CDN URL \u2014 put that URL into a content entry's image field. Use this BEFORE creating entries that reference images.",
5250
+ description: "Upload an image/asset from a local file path or a remote URL into the project's Media Library, or have the server fetch a public `sourceUrl` itself. Returns the asset's stable CDN URL \u2014 put that URL into a content entry's image field. Use this BEFORE creating entries that reference images.",
4843
5251
  inputSchema: uploadAssetInput.shape
4844
5252
  },
4845
5253
  handler: guard(
4846
5254
  async (args) => withClient(async (client) => {
4847
- const asset = await client.uploadAsset(args);
5255
+ const asset = args.sourceUrl && !args.localPath && !args.url ? (await client.fetchJSON(client.url(`/management/media/import`), {
5256
+ method: "POST",
5257
+ body: JSON.stringify({ url: args.sourceUrl, altText: args.altText, caption: args.caption, folderId: args.folderId })
5258
+ })).data : await client.uploadAsset(args);
4848
5259
  return ok(
4849
5260
  `Uploaded '${asset.filename}' (id ${asset.id}). Put this URL (or the id ${asset.id}) into an image field to attach it: ${asset.url}`,
4850
5261
  asset
@@ -5070,7 +5481,7 @@ ${notes.join("\n")}` : summary, created);
5070
5481
  name: "get_component",
5071
5482
  config: {
5072
5483
  title: "Get a component (with its blockJson)",
5073
- 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.",
5484
+ 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. " + VARIANT_GROUP_NOTE,
5074
5485
  inputSchema: getComponentInput.shape
5075
5486
  },
5076
5487
  handler: guard(
@@ -5084,7 +5495,7 @@ ${notes.join("\n")}` : summary, created);
5084
5495
  name: "create_component",
5085
5496
  config: {
5086
5497
  title: "Create a reusable component",
5087
- description: "Create a reusable component from a blockJson tree. THIS IS ALSO HOW A PAGE GETS ITS SECTIONS: set `sectionType` (e.g. 'Hero') and the component becomes a placeable section, selectable in the editor's 'Add a section' picker. Components sharing a `sectionType` are its layout VARIANTS \u2014 one Hero with a 'Centered' and a 'Two-column' variant, same prop keys, so a swap keeps the content. CONFIRM the structure with the user first. blockJson is an array of blocks \u2014 the same set create_page accepts (heading, text/richtext, image, button, spacer, video, columns, section, slider, tabs, navbar, footer, form, component, collection), NOT a narrower one; `section` nests child blocks in props.children and `columns` in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`. Always lands as a DRAFT \u2014 call publish_component to put it on the live site; until then it renders as NOTHING there, with no error. Buttons auto-join the project's Button family; section buttons link to its looks." + SECTION_DOCTRINE,
5498
+ description: "Create a reusable component from a blockJson tree. THIS IS ALSO HOW A PAGE GETS ITS SECTIONS: set `sectionType` (e.g. 'Hero') and the component becomes a placeable section, selectable in the editor's 'Add a section' picker. Components sharing a `sectionType` are its layout VARIANTS \u2014 one Hero with a 'Centered' and a 'Two-column' variant, same prop keys, so a swap keeps the content. CONFIRM the structure with the user first. blockJson is an array of blocks \u2014 the same set create_page accepts (heading, text/richtext, image, button, spacer, video, columns, section, slider, tabs, navbar, footer, form, component, collection), NOT a narrower one; `section` nests child blocks in props.children and `columns` in props.columns. `props` declares overridable fields. Returns the new id \u2014 render with `<BcmsBlocks>`. Always lands as a DRAFT \u2014 call publish_component to put it on the live site; until then it renders as NOTHING there, with no error. Buttons auto-join the project's Button family; section buttons link to its looks." + SECTION_DOCTRINE + " " + UNPROPPED_COPY_NOTE,
5088
5499
  inputSchema: createComponentInput.shape
5089
5500
  },
5090
5501
  handler: guard(
@@ -5108,32 +5519,6 @@ ${notes.join("\n")}` : summary, created);
5108
5519
  * no receipt mentions, and the agent's only safe move is to re-run it and duplicate them.
5109
5520
  * Every item reports its own outcome instead, and the summary line says how many failed.
5110
5521
  */
5111
- {
5112
- name: "create_components",
5113
- config: {
5114
- title: "Create many components in one call",
5115
- description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component for more than three: a whole-site componentize run (playbook \xA712) is dozens. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a declared prop TARGETS are click-to-edit; copy no prop points at is unreachable from canvas and dock alike. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
5116
- inputSchema: createComponentsInput.shape
5117
- },
5118
- handler: guard(
5119
- async (args) => withClient(async (client) => {
5120
- const results = [];
5121
- for (const input of args.components) {
5122
- try {
5123
- const cmp = await client.createComponent(input);
5124
- results.push({ ok: true, slug: cmp.slug, id: cmp.id, name: cmp.name });
5125
- } catch (err) {
5126
- results.push({ ok: false, slug: input.slug, error: errorText(err) });
5127
- }
5128
- }
5129
- const failed = results.filter((r) => !r.ok).length;
5130
- return ok(
5131
- `Created ${results.length - failed} of ${args.components.length} component(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. All are DRAFTS: publish_components once the owner has approved them, or they render as empty strings.`,
5132
- results
5133
- );
5134
- })
5135
- )
5136
- },
5137
5522
  {
5138
5523
  name: "publish_components",
5139
5524
  config: {
@@ -5262,7 +5647,7 @@ ${lines.join("\n")}`, found);
5262
5647
  name: "update_component",
5263
5648
  config: {
5264
5649
  title: "Update a reusable component",
5265
- description: "Update a component by id \u2014 blockJson, props, name, or category. Read with get_component, then pass `ifMatch` = the version from that read. Passing `blockJson`/`props` REPLACES them. This writes the DRAFT: call publish_component to push the change live.",
5650
+ description: "Update a component by id \u2014 blockJson, props, name, or category. Read with get_component, then pass `ifMatch` = the version from that read. Passing `blockJson`/`props` REPLACES them. This writes the DRAFT: call publish_component to push the change live. " + UNPROPPED_COPY_NOTE,
5266
5651
  inputSchema: updateComponentInput.shape
5267
5652
  },
5268
5653
  handler: guard(
@@ -5395,24 +5780,6 @@ ${lines.join("\n")}`, found);
5395
5780
  })
5396
5781
  )
5397
5782
  },
5398
- {
5399
- name: "duplicate_component",
5400
- config: {
5401
- title: "Add a look to a component family",
5402
- description: "Add a new LOOK to a component's variant family (\"+ New variant\"), e.g. a Ghost button the site does not have: copy the component into the same family and folder under `name`, with a new unique slug. `preset` restyles its button block: primary / secondary fill it with the brand kit's primary / accent colour; ghost drops the fill and border, keeps the padding and colours the text with the old fill; link is a ghost with no padding. Omit `preset` for a plain copy. Starts a family if none. outline drops the fill, keeps the padding and draws a border all round in the old fill's colour, which becomes the text colour. A look the style vocabulary cannot express is refused with 422 and the reason \u2014 relay it, never approximate it with update_component. 409 means that name is already a look in the family. When the family's contract is published and this connection may publish, the new look is published at once and is choosable in every section's style select; otherwise it is a draft: call publish_component.",
5403
- inputSchema: duplicateComponentInput.shape
5404
- },
5405
- handler: guard(
5406
- async (args) => withClient(async (client) => {
5407
- const { componentId, ...body } = args;
5408
- const res = await client.fetchJSON(
5409
- client.url(`/management/components/${encodeURIComponent(componentId)}/duplicate`),
5410
- { method: "POST", body: JSON.stringify(body) }
5411
- );
5412
- return ok(`Added look '${res.data.name}' (id ${res.data.id}, ${res.data.status}).`, res.data);
5413
- })
5414
- )
5415
- },
5416
5783
  // ── AI content + SEO actions (Option B) ────────────────────────────────────
5417
5784
  {
5418
5785
  name: "write_content",
@@ -5467,30 +5834,6 @@ ${lines.join("\n")}`, found);
5467
5834
  })
5468
5835
  )
5469
5836
  },
5470
- {
5471
- name: "native_bridge_heartbeat",
5472
- config: {
5473
- title: "Register as a native bridge and report liveness",
5474
- 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.",
5475
- inputSchema: nativeBridgeHeartbeatInput.shape
5476
- },
5477
- handler: guard(
5478
- async (args) => withClient(async (client) => {
5479
- const result = await client.fetchJSON(client.url("/management/orchestration/bridge/heartbeat"), {
5480
- method: "POST",
5481
- body: JSON.stringify({
5482
- protocolVersion: args.protocolVersion,
5483
- capabilities: args.capabilities,
5484
- ...args.session ? { session: args.session } : {}
5485
- })
5486
- });
5487
- return ok(
5488
- `Bridge ${result.liveness}. Call again within ${Math.round(result.nextHeartbeatMs / 1e3)}s to stay live.`,
5489
- result
5490
- );
5491
- })
5492
- )
5493
- },
5494
5837
  // ── Lifecycle tools (parity with the remote /mcp surface) ──────────────────
5495
5838
  // Media management, form submissions (leads), redirects, SEO, AEO site-files,
5496
5839
  // promote, and entry version history. These call the management endpoints straight
@@ -5502,12 +5845,13 @@ ${lines.join("\n")}`, found);
5502
5845
  ...defs.filter((d) => LISTING_TAIL.has(d.name))
5503
5846
  ].map(withProjectId);
5504
5847
  }
5505
- function registerTools(server, deps) {
5848
+ function registerTools(server, deps, tools = process.env.BETTERCMS_MCP_TOOLS) {
5506
5849
  const withElicit = {
5507
5850
  ...deps,
5508
5851
  elicit: deps.elicit ?? ((params) => server.server.elicitInput(params))
5509
5852
  };
5510
- for (const def of buildToolDefs(withElicit)) {
5853
+ const sets = toolsetsOf(tools);
5854
+ for (const def of buildToolDefs(withElicit).filter((d) => listsTool(d.name, sets))) {
5511
5855
  server.registerTool(def.name, def.config, def.handler);
5512
5856
  }
5513
5857
  }
@@ -5541,7 +5885,7 @@ DERIVED at import, where the componentize lane in \xA710 reuses every field.
5541
5885
  A stack of mixed, reorderable content? -> kind:'block' models + a \`modular\` field
5542
5886
  A row in a list (post, author, tier)? -> a collection (kind:'model'); rows one template
5543
5887
  route renders get urlPattern, e.g. '/blog/:slug'
5544
- Copy for ONE page (home, about, legal)?-> page fields in sections (add_page_field /
5888
+ Copy for ONE page (home, about, legal)?-> page fields in sections (add_field {pageId} /
5545
5889
  set_page_content) \u2014 never a one-entry collection
5546
5890
  The one body of an article? -> type:'document' (exactly one, top level)
5547
5891
  A fixed cluster of fields? -> group (never a 1-item repeater)
@@ -5726,7 +6070,7 @@ written to the wrong project can only be demoted (\`sectionType: null\`) and lef
5726
6070
 
5727
6071
  ## 10. You imported a site, or you are about to deploy one
5728
6072
 
5729
- \`deploy_project\`, \`deploy_from_upload\` and \`promote_project\` all answer **409
6073
+ \`deploy_project\` and \`promote_project\` both answer **409
5730
6074
  AUTHORING_DECISION_REQUIRED** until a human has chosen this project's architecture. This is
5731
6075
  asked ONCE per project, ever. It is not an error to retry or route around: read the message
5732
6076
  out, let the user pick, call \`set_authoring_preference\`, then deploy again.
@@ -5861,7 +6205,7 @@ renders. Three consequences, each load-bearing:
5861
6205
  route, in this order (get_next_steps reports the state until it is done):
5862
6206
 
5863
6207
  a. create_page one per route, slug matching the route
5864
- b. add_page_field the fields its content needs (or create_component +
6208
+ b. add_field {pageId} the fields its content needs (or create_component +
5865
6209
  publish_component + component blocks, per your \xA710 answer)
5866
6210
  c. set_page_content values EXACTLY equal to the text the site renders \u2014
5867
6211
  binding matches by value, so a paraphrase binds nothing
@@ -6030,8 +6374,8 @@ assembled from registered components?": per page its blocks and every component
6030
6374
  that component's status, plus site totals \u2014 unpublished, awaiting evidence, awaiting approval,
6031
6375
  missing from the picker, placed nowhere \u2014 and \`verdict.everyPageComposed\` with the reasons it
6032
6376
  is false. A component reaches a visitor only once PUBLISHED, and publishing needs the project
6033
- owner's approval in the DASHBOARD: evidence goes \`submit_section_manifest\` \u2192
6034
- \`submit_section_validation\`, the owner approves there, then \`publish_components\`. There is no
6377
+ owner's approval in the DASHBOARD: evidence comes from \`request_component_validation\` and a
6378
+ validation run in the repository, the owner approves there, then \`publish_components\`. There is no
6035
6379
  bulk approve, and no tool here can grant that approval. Finish with \`get_next_steps\`.
6036
6380
 
6037
6381
  **Order of operations.**
@@ -6041,10 +6385,10 @@ bulk approve, and no tool here can grant that approval. Finish with \`get_next_s
6041
6385
  3. componentize_sections dryRun first, then for real
6042
6386
  3b. npx @bettercms-ai/convert --componentize --receipt componentize.json, push,
6043
6387
  then submit_componentize_receipt { receipt } every section's Output source, ONE call
6044
- 4. create_components everything the plan did not cover, ONE call, each with a group
6388
+ 4. create_component each section the plan did not cover, each with a group
6045
6389
  5. compose_pages every page's blockJson, ONE call
6046
6390
  6. update_layout { commands } nav/footer chrome + add-component, ONE call
6047
- 7. evidence submit_section_manifest -> submit_section_validation
6391
+ 7. evidence request_component_validation -> the repo's validation run
6048
6392
  8. owner approves in the dashboard, then publish_components ONE call, per-item receipts
6049
6393
  9. update_page status:'published', publish_layout
6050
6394
  10. get_site_composition, then get_next_steps
@@ -6093,7 +6437,7 @@ either way.
6093
6437
  **If you know Sanity, this is the same shape under different names:**
6094
6438
 
6095
6439
  defineType schema in code -> content models / page fields (create_content_model,
6096
- add_page_field, or bcms-content.json "schema")
6440
+ add_field, or bcms-content.json "schema")
6097
6441
  TypeGen -> @bettercms-ai/codegen
6098
6442
  GROQ query in the page -> @bettercms-ai/sdk read client, or the bcms-content.json
6099
6443
  build snapshot
@@ -6127,8 +6471,9 @@ hand. THE ORDER, and every step of it matters:
6127
6471
  fallback, and declares each binding.
6128
6472
  2b. IF THE BRIEF CARRIES \`forms\`, wire them too:
6129
6473
  \`npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json\`
6130
- \u2014 ITS OWN receipt file, submitted with submit_conversion_receipt as a second call: the
6131
- server stores its \`forms\` block beside the binding receipt (coverage is untouched), and
6474
+ \u2014 ITS OWN receipt file, submitted with submit_conversion_receipt as a second call under the
6475
+ SAME \`briefDigest\` (its \`paths\` are all zero): the server stores its \`forms\` block
6476
+ beside the binding receipt (never counted in coverage), and
6132
6477
  the next release reads \`wiredForms\` to say which component renders each form.
6133
6478
  A site imported by phase 1 has a DRAFT form row per \`<form>\` in its build, so the Forms tab
6134
6479
  is full while the repository's markup still posts wherever it always did \u2014 to nothing, or to
@@ -6149,6 +6494,11 @@ hand. THE ORDER, and every step of it matters:
6149
6494
  in the data file or collection entry it names) or \`manual\` (do what its \`why\` says). A row
6150
6495
  with no \`fix\` is still yours. Then RERUN step 2 and repeat until \`paths.pending\` is empty \u2014
6151
6496
  the codemod is idempotent, so a second run over converted source rewrites nothing.
6497
+ 3b. THE SITE'S DESIGN IS NOT YOURS TO CHANGE. The \`--verify-styles\` pass of the same codemod
6498
+ (\`--root .\`, with \`--base\` set to the last commit before you started converting) lists every
6499
+ \`class\`, \`className\` and \`style\` the conversion altered as \`styleEdits\`; they travel
6500
+ inside the receipt, and a non-empty list is refused with 409 \`style-edits\` naming every
6501
+ token. Restore them in the source, rerun, resubmit.
6152
6502
  4. \`submit_conversion_receipt { briefDigest, receipt }\` with the receipt file, verbatim. It
6153
6503
  answers \`complete\`, \`pendingTotal\` and the first 40 pending rows with their \`fix\`. \`complete: true\` \u2014 nothing
6154
6504
  pending \u2014 is the ONLY receipt that ends a conversion; on anything less go back to step 3. It
@@ -6180,7 +6530,7 @@ conversion is yours to write.
6180
6530
  reconstruct content from the deployed HTML \u2014 that is how a site ends up bound to a copy of
6181
6531
  its own stale build.
6182
6532
  2. Decide the architecture WITH the human (\xA710) and record it: \`set_authoring_preference\`.
6183
- 3. Register the schema. Per route: \`create_page\` + \`add_page_field\` (text / longtext /
6533
+ 3. Register the schema. Per route: \`create_page\` + \`add_field\` { pageId } (text / longtext /
6184
6534
  richtext / image / array groups). Per repeated content type: \`create_content_model\`, with a
6185
6535
  \`document\` body for articles. Site chrome goes through \`update_layout\`; images through
6186
6536
  \`create_media_upload\` / \`upload_asset\`, then reference the CMS URL. The repo-owned
@@ -6237,7 +6587,7 @@ conversion is yours to write.
6237
6587
  \`BCMS_API_KEY_PROD\`, is the validation workflows' key; they fall back to the shared
6238
6588
  \`BCMS_API_KEY\` only for a repo whose last connect predates it.) Read that file, with in-code
6239
6589
  fallbacks so a local or CI build without it still renders.
6240
- - ARCHIVE (\`deploy_project\` / \`deploy_from_upload\`): the sandbox build runs with no env
6590
+ - ARCHIVE (\`deploy_project\`, inline or by \`uploadKey\`): the sandbox build runs with no env
6241
6591
  and no network content step, by design. So the archive MUST SHIP its own
6242
6592
  \`bcms-content.json\` \u2014 generate it locally with a delivery key and commit it. Without
6243
6593
  one the site renders its fallbacks and the report says \`no-element\` for every path.
@@ -6278,7 +6628,7 @@ conversion is yours to write.
6278
6628
 
6279
6629
  You can create the schema. You can also say how it PRESENTS to the person filling it in. These
6280
6630
  three props ride on any field, on any field-creating tool (\`create_content_model\`,
6281
- \`create_page\`, \`add_field\`, \`add_page_field\`), and none of them can move, reshape or hide a
6631
+ \`create_page\`, \`add_field\`), and none of them can move, reshape or hide a
6282
6632
  delivered value \u2014 they change editor chrome only.
6283
6633
 
6284
6634
  \`\`\`jsonc
@@ -6417,7 +6767,7 @@ Importance orders a screen: required fields first, then array order.
6417
6767
  Name props from the code (\`plan.cents\` \u2192 Cents), help text as \`e.g. "49"\`; repeated cards are one \`table\` prop.
6418
6768
  They write the DRAFT props; no publish is needed for the editor to show them.
6419
6769
  - Always send \`ifMatch\`, the \`optimisticVersion\` you read.
6420
- - A NEW field carries its chrome in the same \`add_field\` / \`add_page_field\` call.
6770
+ - A NEW field carries its chrome in the same \`add_field\` call.
6421
6771
 
6422
6772
  **The dock's refusals, each named in the message.**
6423
6773
  - **428** \`precondition-required\` \u2014 a chrome key without \`ifMatch\`. Read, then send it.
@@ -6429,6 +6779,9 @@ Importance orders a screen: required fields first, then array order.
6429
6779
  fieldset its fields still name.
6430
6780
  - \`row_member_type\`, \`row_member_count\`, \`row_member_control\` \u2014 the row rule.
6431
6781
  - \`pair_split\` \u2014 a button's label and href in different fieldsets.
6782
+ - \`cta_pair\` \u2014 a NEW text field beside a link keyed like it plus \`-href\` (\`hero-span-go\` + \`hero-a-go-href\`): one
6783
+ call to action. Add ONE \`type: "button"\` field (value \`{ label, href }\`); on a component, point both props
6784
+ at one button block.
6432
6785
  - \`ui.icon\` or \`ui.itemLabel\` on a leaf; an icon name not in \`bettercms://icons/dock\`.
6433
6786
  - \`layout_grid\` \u2014 a grid with no image or file child. \`layout_table\` \u2014 more than 3 columns, or a
6434
6787
  column that is not a scalar.
@@ -6444,8 +6797,8 @@ stored, whatever the write returned \u2014 \xA711 RECEIPTS rule 3, applied to th
6444
6797
  and \`dock-showif-order\` must all be gone. Declare, then read back, then say it works.
6445
6798
 
6446
6799
  **No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
6447
- somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\` /
6448
- \`add_page_field\`; omit it and the field appends, exactly as before. \`after\` is an argument
6800
+ somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\`;
6801
+ omit it and the field appends, exactly as before. \`after\` is an argument
6449
6802
  applied once at the write \u2014 a stored ordering prop would be a second source of truth that
6450
6803
  disagrees with the array the moment anyone drags a row in the builder.
6451
6804
 
@@ -6511,7 +6864,7 @@ Do not stop at the plan or at a partial receipt \u2014 finish the whole site, ne
6511
6864
  with no \`fix\` is still yours. Then rerun step 4 until \`paths.pending\` is EMPTY.
6512
6865
  6. \`npx @bettercms-ai/convert --verify-styles --base <commit before step 4>\`, restore what it lists, then
6513
6866
  \`submit_conversion_receipt { briefDigest, receipt }\` with its \`styleEdits\`: only \`complete: true\` ends it (else step 5).
6514
- 7. Deploy the working tree (\`create_deploy_upload\` + \`deploy_from_upload\`); poll \`get_deploy_status\` until live. No git yet.
6867
+ 7. Deploy the working tree (\`create_deploy_upload\`, then \`deploy_project\` with its \`uploadKey\`); poll \`get_deploy_status\` until live. No git yet.
6515
6868
  8. \`get_binding_report\`: \`unmatched\` 0, \`unaddressable\` under 2% per route; fix the SOURCE, deploy, re-read (playbook \xA711).
6516
6869
  9. \`set_binding_mode { declaredBindings: true }\`, deploy AGAIN and poll, then confirm \`get_binding_report\`
6517
6870
  reads mode "declared", unmatched 0, bound above 0, \`coverage.pending\` empty. Send me that report.
@@ -6539,7 +6892,7 @@ shape, then ask me, once and batched). Icon names are in bettercms://icons/dock.
6539
6892
  fieldHelpText by field path, plus \`fieldsets\`. A singleton page: send \`pageId\` instead of
6540
6893
  modelId, with its backingModel's version;
6541
6894
  - component props: \`update_component\` propUi / propGroup / propLabel / propHelpText;
6542
- - a NEW field: ui / helpText / showIf in the same \`add_field\` / \`add_page_field\` call, with \`after\`.
6895
+ - a NEW field: ui / helpText / showIf in the same \`add_field\` call, with \`after\`.
6543
6896
  A 409 \`ui_human_owned\` names chrome a person set: leave it, or ask me and send \`override: true\`
6544
6897
  only on my word.
6545
6898
  4. **RECEIPT, non-negotiable.** Re-read every target and confirm the stored chrome matches what you
@@ -6581,15 +6934,15 @@ Author a page (the page-first schema). Ask, in order, via AskUserQuestion:
6581
6934
  4. **Review** the assembled tree, then call \`create_page\` with
6582
6935
  { title, slug, pageType, fields, metaTitle?, metaDescription? }. Field object:
6583
6936
  { key, label, type, required?, options?, config?, fields? }.`;
6584
- var FIELD_FLOW = `### Add a field \u2192 \`add_field\` (models) / \`add_page_field\` (pages)
6937
+ var FIELD_FLOW = `### Add a field \u2192 \`add_field\` (a model or a page)
6585
6938
  Append a field to an existing schema. First decide the target: a **content model**
6586
6939
  or a **page** (Home, About, a blog template). Ask: which target (id \u2014 use
6587
6940
  \`list_pages\` to find a page id), then the field (key, label, type \u2014 any of the 13
6588
6941
  above, including nested group/repeater), required?. Confirm, then call:
6589
6942
  - a model \u2192 \`add_field\` { modelId, key, label, type, ... }
6590
- - a page \u2192 \`add_page_field\` { pageId, key, label, type, ... }
6591
- Both are additive: they reject a key that already exists and never retype/overwrite
6592
- an existing field (edit those in the dashboard).`;
6943
+ - a page \u2192 \`add_field\` { pageId, key, label, type, ... }
6944
+ It is additive: it rejects a key that already exists and never retypes/overwrites
6945
+ an existing field \u2014 change one of those with \`update_field\` { path, \u2026 }.`;
6593
6946
  var ENTRY_FLOW = `### Create an entry \u2192 \`create_content_entry\` tool
6594
6947
  Create a content entry under a model. Ask: which model (id), the field values (data),
6595
6948
  status (draft/published). Then call \`create_content_entry\` { contentModelId, data?, status?, slug? }.`;
@@ -6662,11 +7015,11 @@ Read \`bettercms://playbook/schema\` \xA712 first \u2014 this is the short form
6662
7015
  restyle. Dry-run first.
6663
7016
  6. **Validation** = \`get_site_composition\`: every page's placements, each component's status,
6664
7017
  and \`verdict.everyPageComposed\` with its reasons. Publishing needs the OWNER's approval in
6665
- the dashboard (evidence via \`submit_section_manifest\` \u2192 \`submit_section_validation\`), then
7018
+ the dashboard (evidence via \`request_component_validation\` and a run in the repository), then
6666
7019
  \`publish_components\`. No tool here can grant that approval, and there is no bulk approve.
6667
7020
 
6668
7021
  **Order:** get_site_composition \u2192 (get_componentize_plan \u2192 componentize_sections) \u2192
6669
- create_components \u2192 compose_pages \u2192 update_layout { commands } \u2192 evidence + owner approval \u2192
7022
+ create_component per uncovered section \u2192 compose_pages \u2192 update_layout { commands } \u2192 evidence + owner approval \u2192
6670
7023
  publish_components \u2192 publish the pages / publish_layout \u2192 get_site_composition \u2192 get_next_steps.
6671
7024
  Prefer the batch tools whenever there is more than three of anything.`;
6672
7025
  var LAYOUT_FLOW = `### Project/page Layout authoring \u2192 \`get_layout\` / \`update_layout\` / \`publish_layout\`
@@ -7070,7 +7423,7 @@ Read bettercms://playbook/schema section 11 first, then work this order:
7070
7423
 
7071
7424
  1. AUTHORING GATE \u2014 if any deploy answered 409 AUTHORING_DECISION_REQUIRED, ask ME
7072
7425
  components-or-fields (do not choose), then set_authoring_preference.
7073
- 2. CONTENT INTO THE CMS, per route: create_page (slug = route) -> add_page_field (or
7426
+ 2. CONTENT INTO THE CMS, per route: create_page (slug = route) -> add_field {pageId} (or
7074
7427
  create_component + publish_component + component blocks) -> set_page_content with values
7075
7428
  EXACTLY equal to the rendered text (binding matches by value; a paraphrase binds nothing)
7076
7429
  -> update_page status 'published' (the canvas binds the PUBLISHED copy).
@@ -7100,6 +7453,67 @@ On 401/403, the MCP key needs (re)authorizing.`
7100
7453
  );
7101
7454
  }
7102
7455
 
7456
+ // src/skill-docs.ts
7457
+ import { existsSync, readdirSync, readFileSync } from "fs";
7458
+ import { join } from "path";
7459
+ import { fileURLToPath } from "url";
7460
+ var SKILL_URI_PREFIX = "bettercms://skill/";
7461
+ var CORE_SKILL = "bettercms";
7462
+ var CANDIDATES = ["./skills/bettercms/", "../../skills/bettercms/"];
7463
+ function defaultSkillDir() {
7464
+ for (const rel of CANDIDATES) {
7465
+ const dir = fileURLToPath(new URL(rel, import.meta.url));
7466
+ if (existsSync(join(dir, "SKILL.md"))) return dir;
7467
+ }
7468
+ return null;
7469
+ }
7470
+ function frontmatterOf(text2) {
7471
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(text2);
7472
+ if (!m) return {};
7473
+ const out = {};
7474
+ const lines = m[1].split(/\r?\n/);
7475
+ for (let i = 0; i < lines.length; i++) {
7476
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
7477
+ if (!kv) continue;
7478
+ let value = kv[2].trim();
7479
+ if (value === ">" || value === "|" || value === ">-" || value === "|-") {
7480
+ const block = [];
7481
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) block.push(lines[++i].trim());
7482
+ value = block.join(value.startsWith(">") ? " " : "\n");
7483
+ } else if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
7484
+ value = value.slice(1, -1).replace(/\\"/g, '"');
7485
+ }
7486
+ if (value) out[kv[1]] = value;
7487
+ }
7488
+ return out;
7489
+ }
7490
+ function skillDocOf(stem, text2) {
7491
+ const fm = frontmatterOf(text2);
7492
+ const heading = /^#\s+(.+?)\s*$/m.exec(text2)?.[1];
7493
+ const title = fm.title ?? heading ?? stem;
7494
+ const description = fm.description ?? heading;
7495
+ return {
7496
+ uri: `${SKILL_URI_PREFIX}${stem}`,
7497
+ name: `skill-${stem}`,
7498
+ title,
7499
+ ...description ? { description } : {},
7500
+ mimeType: "text/markdown",
7501
+ text: text2
7502
+ };
7503
+ }
7504
+ function loadSkillDocs(dir = defaultSkillDir()) {
7505
+ if (!dir) return [];
7506
+ const docs = [skillDocOf(CORE_SKILL, readFileSync(join(dir, "SKILL.md"), "utf8"))];
7507
+ const refs = join(dir, "references");
7508
+ if (!existsSync(refs)) return docs;
7509
+ for (const file of readdirSync(refs).filter((f) => f.endsWith(".md")).sort()) {
7510
+ const stem = file.slice(0, -3);
7511
+ if (stem === CORE_SKILL) continue;
7512
+ docs.push(skillDocOf(stem, readFileSync(join(refs, file), "utf8")));
7513
+ }
7514
+ return docs;
7515
+ }
7516
+
7103
7517
  // src/server.ts
7104
7518
  var SERVER_NAME = "bettercms";
7105
7519
  var SERVER_VERSION = "1.5.0";
@@ -7111,6 +7525,12 @@ var SERVER_DISPLAY = {
7111
7525
  { src: "https://api.bettercms.ai/brand/mark.svg", mimeType: "image/svg+xml", sizes: ["any"] }
7112
7526
  ]
7113
7527
  };
7528
+ function withBranch(client, branch) {
7529
+ if (!branch) return client;
7530
+ const base = client.headers.bind(client);
7531
+ client.headers = () => ({ ...base(), "X-Branch-Id": branch });
7532
+ return client;
7533
+ }
7114
7534
  function buildServer(deps) {
7115
7535
  const server = new McpServer(
7116
7536
  { name: SERVER_NAME, version: SERVER_VERSION, ...SERVER_DISPLAY },
@@ -7119,7 +7539,7 @@ function buildServer(deps) {
7119
7539
  // 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
7120
7540
  // (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
7121
7541
  // one definition of done; without this an agent converts the page it landed on and stops.
7122
- instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 create_components, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. BetterCMS never executes a customer's Section renderer or app code. An ordinary MCP connection is not a push runner: explicitly poll list_section_validation_requests, claim one request at an exact git commit, run implementation and responsive checks inside the user's own repository and real app shell, then submit manifest + validation with that requestId and complete it\u2014or truthfully fail it when implementation/evidence is missing. Never invent a manifest, a passing validation, or visual evidence; these tools cannot grant the separate human Visual Approval required for publication. " + // The default after authoring (the structure standard): organise what you made. Same
7542
+ instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 componentize_sections, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. " + // The default after authoring (the structure standard): organise what you made. Same
7123
7543
  // sentence as the hosted connector's MCP_INSTRUCTIONS.
7124
7544
  STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION
7125
7545
  }
@@ -7152,12 +7572,15 @@ function buildServer(deps) {
7152
7572
  server.registerResource(dockIconsName, dockIconsUri, dockIconsMeta, () => ({
7153
7573
  contents: [{ uri: dockIconsUri, mimeType: dockIconsMeta.mimeType, text: DOCK_ICONS_TEXT }]
7154
7574
  }));
7575
+ for (const { uri, name, text: text2, ...meta } of loadSkillDocs()) {
7576
+ server.registerResource(name, uri, meta, () => ({ contents: [{ uri, mimeType: meta.mimeType, text: text2 }] }));
7577
+ }
7155
7578
  registerTools(server, {
7156
7579
  auth: deps.auth,
7157
7580
  // `project` is the per-call target of a workspace-wide grant; the SDK sends it as
7158
7581
  // X-BCMS-Project. Omitted entirely when absent so a project-scoped key's request is
7159
7582
  // byte-identical to before.
7160
- createClient: (apiKey, project) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} })
7583
+ createClient: (apiKey, project, branch) => withBranch(BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} }), branch)
7161
7584
  });
7162
7585
  registerPrompts(server);
7163
7586
  return server;
@@ -7179,7 +7602,7 @@ function isMainModule() {
7179
7602
  const argv1 = process.argv[1];
7180
7603
  if (!argv1) return false;
7181
7604
  try {
7182
- return realpathSync(argv1) === fileURLToPath(import.meta.url);
7605
+ return realpathSync(argv1) === fileURLToPath2(import.meta.url);
7183
7606
  } catch {
7184
7607
  return false;
7185
7608
  }