@bettercms-ai/mcp 0.54.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +234 -37
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2280,7 +2280,9 @@ var fieldShape = {
|
|
|
2280
2280
|
),
|
|
2281
2281
|
label: z.string().min(1).describe("human label shown in the editor"),
|
|
2282
2282
|
type: fieldType,
|
|
2283
|
-
required: z.boolean().optional()
|
|
2283
|
+
required: z.boolean().optional().describe(
|
|
2284
|
+
"an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
|
|
2285
|
+
),
|
|
2284
2286
|
richText: z.boolean().optional().describe(
|
|
2285
2287
|
"prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
|
|
2286
2288
|
),
|
|
@@ -2288,7 +2290,7 @@ var fieldShape = {
|
|
|
2288
2290
|
"choices when type is 'select'. A plain string, or {label, value} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'})."
|
|
2289
2291
|
),
|
|
2290
2292
|
config: z.record(z.string(), z.unknown()).optional().describe(
|
|
2291
|
-
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model"
|
|
2293
|
+
"per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
|
|
2292
2294
|
),
|
|
2293
2295
|
fields: z.array(z.lazy(() => fieldObject)).optional().describe(
|
|
2294
2296
|
"NESTED child fields \u2014 REQUIRED for type 'group' (one nested object, a Non-Repeatable Zone like blog_hero \u2192 heading, description, hero_image) and type 'repeater' (a repeatable array of such objects, a Repeatable Zone / section-list like testimonials \u2192 quote, author). A section with repeating items is a 'repeater'; a fixed grouped block is a 'group'. Recurse to any depth \u2014 do NOT flatten zones into separate top-level fields."
|
|
@@ -2314,7 +2316,7 @@ var fieldShape = {
|
|
|
2314
2316
|
"conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
|
|
2315
2317
|
),
|
|
2316
2318
|
ui: uiObject.optional().describe(
|
|
2317
|
-
"AUTHORING CHROME \u2014 how this field presents in the editor
|
|
2319
|
+
"AUTHORING CHROME \u2014 how this field presents in the editor; never a delivery effect. `preview` slots name a CHILD FIELD KEY and must name real children, or the write is refused with the valid keys listed. `preview`/`layout` need a nesting field, `reorderable` needs a list, and an unknown key inside `ui` is a 400, not a silent strip. No `order`: field order is the array index \u2014 use `after`. Section 14 of bettercms://playbook/schema has each key and where it is legal."
|
|
2318
2320
|
),
|
|
2319
2321
|
// ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
|
|
2320
2322
|
fieldsetId: z.string().min(1).optional().describe(
|
|
@@ -2428,7 +2430,8 @@ var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit: colors, typography,
|
|
|
2428
2430
|
var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model. When sent, the update applies only if the model is still at that version; a 409 means someone changed it since \u2014 read it again and re-apply, never retry blindly. Omitted, the last write wins.";
|
|
2429
2431
|
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.";
|
|
2430
2432
|
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).";
|
|
2431
|
-
var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state
|
|
2433
|
+
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 icon 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 do work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, and Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, the same as editing the schema; reading needs only content access. A 403 SCHEMA_ACCESS_REQUIRED means this connection is neither: stop and tell the user, do not retry. Pins sit above the folders, in their own order, and may be 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 model bound to it. That model is NOT a collection: it is 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. ';
|
|
2434
|
+
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.";
|
|
2432
2435
|
function structureResult(d) {
|
|
2433
2436
|
const message = d?.message;
|
|
2434
2437
|
return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
|
|
@@ -2522,7 +2525,7 @@ function authPrompt(err) {
|
|
|
2522
2525
|
}
|
|
2523
2526
|
var currentProject = new AsyncLocalStorage();
|
|
2524
2527
|
var projectIdArg = z.string().min(1).optional().describe(
|
|
2525
|
-
"
|
|
2528
|
+
"target project id (from list_projects); only for a workspace-wide grant."
|
|
2526
2529
|
);
|
|
2527
2530
|
var withProjectId = (def) => ({
|
|
2528
2531
|
...def,
|
|
@@ -2532,6 +2535,60 @@ var withProjectId = (def) => ({
|
|
|
2532
2535
|
return currentProject.run(project, () => def.handler(args));
|
|
2533
2536
|
}
|
|
2534
2537
|
});
|
|
2538
|
+
var LISTING_TAIL = /* @__PURE__ */ new Set([
|
|
2539
|
+
// The agent-job lane: a dashboard surface an agent rarely drives.
|
|
2540
|
+
"list_ai_jobs",
|
|
2541
|
+
"get_ai_job",
|
|
2542
|
+
"approve_ai_job",
|
|
2543
|
+
"reject_ai_job",
|
|
2544
|
+
"list_ai_reports",
|
|
2545
|
+
"connect_native_ai",
|
|
2546
|
+
"native_bridge_heartbeat",
|
|
2547
|
+
// Section evidence and component validation: a CI lane, not an authoring one.
|
|
2548
|
+
"claim_section_validation_request",
|
|
2549
|
+
"complete_section_validation_request",
|
|
2550
|
+
"fail_section_validation_request",
|
|
2551
|
+
"list_section_validation_requests",
|
|
2552
|
+
"submit_section_validation",
|
|
2553
|
+
"submit_section_manifest",
|
|
2554
|
+
"request_component_validation",
|
|
2555
|
+
"declare_component_route",
|
|
2556
|
+
"clear_component_source",
|
|
2557
|
+
"submit_componentize_receipt",
|
|
2558
|
+
// Reporting, history and workflow chrome.
|
|
2559
|
+
"get_analytics_overview",
|
|
2560
|
+
"get_analytics_top_pages",
|
|
2561
|
+
"list_activity",
|
|
2562
|
+
"get_workflow_board",
|
|
2563
|
+
"move_entry_stage",
|
|
2564
|
+
"move_page_stage",
|
|
2565
|
+
"list_entry_versions",
|
|
2566
|
+
"restore_entry_version",
|
|
2567
|
+
// One-off project operations and the extraction lane.
|
|
2568
|
+
"clone_project",
|
|
2569
|
+
"promote_project",
|
|
2570
|
+
"generate_pages_from_dataset",
|
|
2571
|
+
"extract_component",
|
|
2572
|
+
"list_extraction_candidates",
|
|
2573
|
+
"create_deploy_upload",
|
|
2574
|
+
"deploy_from_upload",
|
|
2575
|
+
"set_authoring_preference",
|
|
2576
|
+
"set_media_delivery",
|
|
2577
|
+
"delete_form_submission",
|
|
2578
|
+
// The ABM campaign lane: a marketer's dashboard surface, plus the worker tools a task runner
|
|
2579
|
+
// claims with. Ten more tools than the tail was written for, and a connector that truncates
|
|
2580
|
+
// would otherwise lose `get_deploy_status` and the GitHub lane to make room for them.
|
|
2581
|
+
"research_account",
|
|
2582
|
+
"claim_abm_task",
|
|
2583
|
+
"complete_abm_task",
|
|
2584
|
+
"fail_abm_task",
|
|
2585
|
+
"get_campaign_review",
|
|
2586
|
+
"regenerate_entry",
|
|
2587
|
+
"approve_campaign_entries",
|
|
2588
|
+
"publish_campaign",
|
|
2589
|
+
"get_campaign_share_link",
|
|
2590
|
+
"revoke_campaign_share_link"
|
|
2591
|
+
]);
|
|
2535
2592
|
function buildToolDefs(deps) {
|
|
2536
2593
|
async function withClient(fn) {
|
|
2537
2594
|
const token = await deps.auth.getAccessToken();
|
|
@@ -2586,7 +2643,7 @@ function buildToolDefs(deps) {
|
|
|
2586
2643
|
]).describe("block type; section/slider/tabs/columns nest child blocks"),
|
|
2587
2644
|
id: z.string().min(1).describe("stable unique block id"),
|
|
2588
2645
|
props: z.record(z.string(), z.unknown()).describe(
|
|
2589
|
-
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}); image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
|
|
2646
|
+
"per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
|
|
2590
2647
|
),
|
|
2591
2648
|
style: z.record(z.string(), z.unknown()).optional().describe(
|
|
2592
2649
|
"design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom. A real marketing band is a `section` block carrying bg + padding + contentWidth."
|
|
@@ -2748,8 +2805,11 @@ function buildToolDefs(deps) {
|
|
|
2748
2805
|
]),
|
|
2749
2806
|
placeholder: z.string().optional(),
|
|
2750
2807
|
helpText: z.string().optional().describe("hint shown under the control, muted"),
|
|
2751
|
-
required: z.boolean().optional()
|
|
2808
|
+
required: z.boolean().optional().describe(
|
|
2809
|
+
"an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
|
|
2810
|
+
),
|
|
2752
2811
|
options: z.array(z.string()).optional().describe("choices when type is 'select', 'radio' or 'checkboxes'"),
|
|
2812
|
+
optionValues: z.array(z.string()).optional().describe("what each choice SUBMITS, positionally paired with `options` \u2014 only where it differs from the label"),
|
|
2753
2813
|
hidden: z.boolean().optional().describe("not rendered; pairs with defaultValue to capture context"),
|
|
2754
2814
|
defaultValue: z.string().optional(),
|
|
2755
2815
|
showIf: z.object({ field: z.string(), equals: z.string() }).optional().describe("show this field only when another field equals a value"),
|
|
@@ -3154,11 +3214,11 @@ function buildToolDefs(deps) {
|
|
|
3154
3214
|
z.object({ formId: z.string().min(1), submissionId: z.string().min(1) }).shape,
|
|
3155
3215
|
async (c, a) => ok("Deleted submission.", await data(c, "DELETE", `/management/forms/${s(a.formId)}/submissions/${s(a.submissionId)}`))
|
|
3156
3216
|
),
|
|
3157
|
-
// ── Content structure: the Content sidebar's folders and icons
|
|
3217
|
+
// ── Content structure: the Content sidebar's folders and icons ──
|
|
3158
3218
|
def(
|
|
3159
3219
|
"get_content_structure",
|
|
3160
3220
|
"Get the Content sidebar structure",
|
|
3161
|
-
`
|
|
3221
|
+
`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}`,
|
|
3162
3222
|
z.object({}).shape,
|
|
3163
3223
|
async (c) => {
|
|
3164
3224
|
const d = await data(c, "GET", `/management/content-structure`);
|
|
@@ -3182,7 +3242,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3182
3242
|
def(
|
|
3183
3243
|
"set_content_structure",
|
|
3184
3244
|
"Replace the Content sidebar structure",
|
|
3185
|
-
`Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc).
|
|
3245
|
+
`Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc). Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId a folder in the doc, no cycles, depth \u22644, names 1-80 chars, lucide icons. A 412 VERSION_CONFLICT means someone changed it since you read it: read again and re-apply. Returns the new version and a diff. ${STRUCTURE_FENCE}`,
|
|
3186
3246
|
z.object({
|
|
3187
3247
|
doc: z.object({
|
|
3188
3248
|
schema: z.literal(1),
|
|
@@ -3208,7 +3268,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3208
3268
|
def(
|
|
3209
3269
|
"create_folder",
|
|
3210
3270
|
"Create a Content sidebar folder",
|
|
3211
|
-
`Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${
|
|
3271
|
+
`Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_FENCE}`,
|
|
3212
3272
|
z.object({
|
|
3213
3273
|
name: z.string().min(1).describe("the folder's label, 1-80 chars"),
|
|
3214
3274
|
parentId: z.string().min(1).optional().describe("optional parent folder id (from get_content_structure); omit it, or send 'root', for the top level"),
|
|
@@ -3219,7 +3279,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3219
3279
|
def(
|
|
3220
3280
|
"rename_folder",
|
|
3221
3281
|
"Rename a Content sidebar folder",
|
|
3222
|
-
`Rename a Content sidebar folder. ${
|
|
3282
|
+
`Rename a Content sidebar folder. ${STRUCTURE_FENCE}`,
|
|
3223
3283
|
z.object({
|
|
3224
3284
|
folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
|
|
3225
3285
|
name: z.string().min(1).describe("the new label, 1-80 chars")
|
|
@@ -3229,9 +3289,11 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3229
3289
|
def(
|
|
3230
3290
|
"move_to_folder",
|
|
3231
3291
|
"Move into a Content sidebar folder",
|
|
3232
|
-
`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. ${
|
|
3292
|
+
`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}`,
|
|
3233
3293
|
z.object({
|
|
3234
|
-
kind: z.enum(["page", "collection", "folder"])
|
|
3294
|
+
kind: z.enum(["page", "collection", "folder"]).describe(
|
|
3295
|
+
"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."
|
|
3296
|
+
),
|
|
3235
3297
|
id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
|
|
3236
3298
|
folderId: z.string().min(1).nullable().describe("target folder id; 'root' pins a page or collection at the top level (for a folder, 'root' is the top level); null moves it back to its home (Other pages, the Shared list, or the top level for a folder)")
|
|
3237
3299
|
}).shape,
|
|
@@ -3270,9 +3332,11 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3270
3332
|
def(
|
|
3271
3333
|
"set_icon",
|
|
3272
3334
|
"Set a Content sidebar icon",
|
|
3273
|
-
`Set or clear the sidebar icon of a page, a collection or a folder. ${
|
|
3335
|
+
`Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_FENCE}`,
|
|
3274
3336
|
z.object({
|
|
3275
|
-
kind: z.enum(["page", "collection", "folder"])
|
|
3337
|
+
kind: z.enum(["page", "collection", "folder"]).describe(
|
|
3338
|
+
"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."
|
|
3339
|
+
),
|
|
3276
3340
|
id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
|
|
3277
3341
|
icon: z.string().min(1).nullable().describe("lucide icon name, e.g. 'file-text'; null clears it")
|
|
3278
3342
|
}).shape,
|
|
@@ -3281,7 +3345,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3281
3345
|
def(
|
|
3282
3346
|
"delete_folder",
|
|
3283
3347
|
"Delete a folder (its contents move up; nothing is deleted)",
|
|
3284
|
-
`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. ${
|
|
3348
|
+
`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}`,
|
|
3285
3349
|
z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
|
|
3286
3350
|
async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
|
|
3287
3351
|
),
|
|
@@ -3483,7 +3547,7 @@ ${d.outline}` : "Proposed Content structure.", d);
|
|
|
3483
3547
|
def(
|
|
3484
3548
|
"submit_conversion_receipt",
|
|
3485
3549
|
"Record what the conversion codemod could and could not do",
|
|
3486
|
-
"Hand BetterCMS the codemod's own account of a
|
|
3550
|
+
"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` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: The response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`. `action` is `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data` or `manual`; each row's `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. `complete: true` is the only receipt that ends a conversion; do not report a site converted on less. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next 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 `npx @bettercms-ai/convert --forms` run writes a receipt whose `paths` are all zero and whose account is in 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 read `forms.pending` and publish every form it names. Requires artifact:write, the same authority as set_binding_mode.",
|
|
3487
3551
|
z.object({
|
|
3488
3552
|
briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
|
|
3489
3553
|
receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
|
|
@@ -3719,8 +3783,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3719
3783
|
def(
|
|
3720
3784
|
"get_binding_report",
|
|
3721
3785
|
"Check what on the live site is editable",
|
|
3722
|
-
"The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them),
|
|
3723
|
-
z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read
|
|
3786
|
+
"The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason` says which: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one and redeploying will not change it (verify that site by fetching it); `outdated` \u2014 the next release rewrites it. It certifies one thing: every non-empty field has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for, or null when it cannot say. `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge`, `draft-route` or `none`, in which case get_next_steps carries the recipe (playbook section 11). `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page and the result names the routes, their countable characters and the buckets the unowned text sits in, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not.",
|
|
3787
|
+
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.") }).shape,
|
|
3724
3788
|
async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
|
|
3725
3789
|
),
|
|
3726
3790
|
def(
|
|
@@ -3757,13 +3821,13 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3757
3821
|
def(
|
|
3758
3822
|
"componentize_sections",
|
|
3759
3823
|
"Turn this site's derived sections into components",
|
|
3760
|
-
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT
|
|
3824
|
+
"Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` moves the words onto each placement and marks the page's fields `origin: \"componentized\"` \u2014 kept, never deleted (see `copy`). Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
|
|
3761
3825
|
z.object({
|
|
3762
3826
|
digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
|
|
3763
3827
|
pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
|
|
3764
3828
|
pages: z.array(z.string().min(1)).optional().describe("The same page filter under the name the dashboard uses. Unioned with pageIds; an EMPTY list is refused rather than treated as every page."),
|
|
3765
3829
|
sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
|
|
3766
|
-
copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy
|
|
3830
|
+
copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words."),
|
|
3767
3831
|
dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
|
|
3768
3832
|
group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME; created when missing). Omit to file each under its section family.")
|
|
3769
3833
|
}).shape,
|
|
@@ -3801,7 +3865,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3801
3865
|
def(
|
|
3802
3866
|
"generate_pages_from_dataset",
|
|
3803
3867
|
"Generate many pages from a dataset",
|
|
3804
|
-
"Turn a dataset into many pages at once (programmatic SEO from a keyword list, or ABM pages from an account list). Give a template `pageId`, a `mapping` (contentModelId, a slugTemplate like 'for-{{company}}', and per-field values that are either a column name or {ai:{prompt}}), and `rows`. ALWAYS call with dryRun:true first and show the sample \u2014 a real run parks for approval and must be released with approve_ai_job.",
|
|
3868
|
+
"Turn a dataset into many pages at once (programmatic SEO from a keyword list, or ABM pages from an account list). Give a template `pageId`, a `mapping` (contentModelId, a slugTemplate like 'for-{{company}}', and per-field values that are either a column name or {ai:{prompt}}), and `rows`. For an ABM campaign set `mapping.campaign.id` and the run fans out across every page that campaign owns, returning one job id per page in `jobIds`. ALWAYS call with dryRun:true first and show the sample \u2014 a real run parks for approval and must be released with approve_ai_job.",
|
|
3805
3869
|
z.object({
|
|
3806
3870
|
pageId: z.string().min(1),
|
|
3807
3871
|
mapping: z.record(z.string(), z.unknown()),
|
|
@@ -3810,6 +3874,121 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3810
3874
|
}).shape,
|
|
3811
3875
|
async (c, a) => ok("Generation queued.", await data(c, "POST", `/management/bulk/generate`, { pageId: a.pageId, mapping: a.mapping, rows: a.rows, dryRun: a.dryRun }))
|
|
3812
3876
|
),
|
|
3877
|
+
def(
|
|
3878
|
+
"research_account",
|
|
3879
|
+
"Research one account",
|
|
3880
|
+
"Read one company's public site into a cited brief the page generator can use: what they do, who they sell to, what they say is hard, with a source URL on every fact. `lite` is one fetch and costs nothing extra; `deep` searches the web, costs 3 credits and is capped per day. A brief less than 30 days old is returned from cache for free. Company-scoped by design: it never stores a person, an email or a job title.",
|
|
3881
|
+
z.object({
|
|
3882
|
+
campaignId: z.string().min(1),
|
|
3883
|
+
domain: z.string().min(1),
|
|
3884
|
+
name: z.string().optional(),
|
|
3885
|
+
mode: z.enum(["lite", "deep"]).optional(),
|
|
3886
|
+
force: z.boolean().optional()
|
|
3887
|
+
}).shape,
|
|
3888
|
+
async (c, a) => ok("Account brief.", await data(c, "POST", `/management/campaigns/research`, { campaignId: a.campaignId, domain: a.domain, name: a.name, mode: a.mode, force: a.force }))
|
|
3889
|
+
),
|
|
3890
|
+
def(
|
|
3891
|
+
"claim_abm_task",
|
|
3892
|
+
"Claim an account to research",
|
|
3893
|
+
"Claim the next account of a campaign that is waiting for YOU to research it. Returns the account, a `taskId` and a `leaseToken` good for ten minutes; `null` means there is nothing left and your loop is done. Research the company however you like, then call complete_abm_task with the brief \u2014 or fail_abm_task if you cannot. Never store a person, an email or a job title: the brief is refused if you do.",
|
|
3894
|
+
z.object({ campaignId: z.string().min(1), agentId: z.string().optional() }).shape,
|
|
3895
|
+
async (c, a) => ok("Claimed task.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/tasks/claim`, { agentId: a.agentId }))
|
|
3896
|
+
),
|
|
3897
|
+
def(
|
|
3898
|
+
"complete_abm_task",
|
|
3899
|
+
"Hand back an account brief",
|
|
3900
|
+
"Hand back the brief for a task you claimed. `brief` is {name, domain, industry?, size?, painPoint?, summary?, facts:[{text, sourceUrl}], logo?, brandColors?} and NOTHING else \u2014 an unknown key is refused with brief_invalid and the path. Every fact needs the URL it came from. When the campaign's last task is done the page generation starts on its own.",
|
|
3901
|
+
z.object({
|
|
3902
|
+
campaignId: z.string().min(1),
|
|
3903
|
+
taskId: z.string().min(1),
|
|
3904
|
+
leaseToken: z.string().min(1),
|
|
3905
|
+
brief: z.record(z.string(), z.unknown())
|
|
3906
|
+
}).shape,
|
|
3907
|
+
async (c, a) => ok("Brief accepted.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/tasks/${s(a.taskId)}/complete`, { leaseToken: a.leaseToken, brief: a.brief }))
|
|
3908
|
+
),
|
|
3909
|
+
def(
|
|
3910
|
+
"fail_abm_task",
|
|
3911
|
+
"Give an account task back",
|
|
3912
|
+
"Give a claimed task back when you cannot research the account \u2014 the site is down, there is nothing public to read, the domain is wrong. The page is still written, from the dataset alone, and says so.",
|
|
3913
|
+
z.object({
|
|
3914
|
+
campaignId: z.string().min(1),
|
|
3915
|
+
taskId: z.string().min(1),
|
|
3916
|
+
leaseToken: z.string().min(1),
|
|
3917
|
+
reason: z.string().optional()
|
|
3918
|
+
}).shape,
|
|
3919
|
+
async (c, a) => ok("Task returned.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/tasks/${s(a.taskId)}/fail`, { leaseToken: a.leaseToken, reason: a.reason }))
|
|
3920
|
+
),
|
|
3921
|
+
def(
|
|
3922
|
+
"get_campaign_review",
|
|
3923
|
+
"Review a campaign's pages",
|
|
3924
|
+
"The campaign's generated pages, SORTED BY DOUBT \u2014 flagged first, then least confident. Each row carries its flags (`unsupported_claim:headline` and the like), the claims the model said it made, which fields it wrote, and a preview link for drafts. `filter:'flagged'` is the review queue; `sample:5` is the first look at a big run; `filter:'failed'` lists the rows that produced no page at all and why.",
|
|
3925
|
+
z.object({
|
|
3926
|
+
campaignId: z.string().min(1),
|
|
3927
|
+
filter: z.enum(["all", "flagged", "pending", "approved", "rejected", "low_confidence", "failed"]).optional(),
|
|
3928
|
+
sample: z.number().optional(),
|
|
3929
|
+
pageId: z.string().optional(),
|
|
3930
|
+
runId: z.string().optional(),
|
|
3931
|
+
cursor: z.string().optional(),
|
|
3932
|
+
limit: z.number().optional()
|
|
3933
|
+
}).shape,
|
|
3934
|
+
async (c, a) => ok("Campaign review.", await data(c, "GET", `/management/campaigns/${s(a.campaignId)}/review${q({ filter: a.filter, sample: a.sample, pageId: a.pageId, runId: a.runId, cursor: a.cursor, limit: a.limit })}`))
|
|
3935
|
+
),
|
|
3936
|
+
def(
|
|
3937
|
+
"regenerate_entry",
|
|
3938
|
+
"Rewrite one account's page",
|
|
3939
|
+
"Rewrite ONE account's page, optionally with an instruction ('lead with the integration story') and optionally only certain fields. Costs one credit and writes a DRAFT \u2014 a published page shows unpublished changes until someone publishes it again. Re-runs from the original spreadsheet row, not from the last answer, so repeated regenerates do not drift.",
|
|
3940
|
+
z.object({
|
|
3941
|
+
campaignId: z.string().min(1),
|
|
3942
|
+
entryId: z.string().min(1),
|
|
3943
|
+
instruction: z.string().optional(),
|
|
3944
|
+
fields: z.array(z.string()).optional()
|
|
3945
|
+
}).shape,
|
|
3946
|
+
async (c, a) => ok("Regeneration queued.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/entries/${s(a.entryId)}/regenerate`, { instruction: a.instruction, fields: a.fields }))
|
|
3947
|
+
),
|
|
3948
|
+
def(
|
|
3949
|
+
"approve_campaign_entries",
|
|
3950
|
+
"Mark campaign pages reviewed",
|
|
3951
|
+
"Mark campaign pages reviewed. `entryIds:'all_unflagged'` clears every page with nothing wrong with it, which is most of them; a FLAGGED page must be named explicitly, because approving one is a person saying they checked the claim. Approving does not publish \u2014 publish_campaign does, and it refuses pages that are still flagged.",
|
|
3952
|
+
z.object({
|
|
3953
|
+
campaignId: z.string().min(1),
|
|
3954
|
+
entryIds: z.union([z.array(z.string()), z.literal("all_unflagged")]),
|
|
3955
|
+
status: z.enum(["approved", "rejected"]),
|
|
3956
|
+
note: z.string().optional()
|
|
3957
|
+
}).shape,
|
|
3958
|
+
async (c, a) => ok("Entries reviewed.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/entries/review`, { entryIds: a.entryIds, status: a.status, note: a.note }))
|
|
3959
|
+
),
|
|
3960
|
+
def(
|
|
3961
|
+
"publish_campaign",
|
|
3962
|
+
"Publish a campaign",
|
|
3963
|
+
"Put a campaign's pages on the internet, or schedule them. `confirm: true` is REQUIRED \u2014 this is the one action in the ABM lane that a human, not you, decides. It REFUSES with `entry_flagged` if any page still carries an unchecked claim (call get_campaign_review and approve them first) and with `blueprint_missing_disclaimer` if a campaign page has lost its 'not affiliated with' band. Pages somebody rejected are skipped and counted.",
|
|
3964
|
+
z.object({
|
|
3965
|
+
campaignId: z.string().min(1),
|
|
3966
|
+
scheduledAt: z.string().optional(),
|
|
3967
|
+
unpublishAt: z.string().optional(),
|
|
3968
|
+
confirm: z.boolean()
|
|
3969
|
+
}).shape,
|
|
3970
|
+
async (c, a) => ok("Campaign publish queued.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/publish`, { scheduledAt: a.scheduledAt, unpublishAt: a.unpublishAt, confirm: a.confirm }))
|
|
3971
|
+
),
|
|
3972
|
+
def(
|
|
3973
|
+
"get_campaign_share_link",
|
|
3974
|
+
"Make a share link",
|
|
3975
|
+
"Make a link a rep can send before the pages are published. `scope:'campaign'` opens the whole account index; `scope:'account'` with an `externalKey` (the account's domain) opens exactly that account's pages and 404s on anyone else's. At most 14 days, revocable, rate-limited and noindex. The link's `uses` is how a rep sees Not sent / Sent / Visited.",
|
|
3976
|
+
z.object({
|
|
3977
|
+
campaignId: z.string().min(1),
|
|
3978
|
+
scope: z.enum(["campaign", "account"]).optional(),
|
|
3979
|
+
externalKey: z.string().optional(),
|
|
3980
|
+
expiresIn: z.string().optional(),
|
|
3981
|
+
label: z.string().optional()
|
|
3982
|
+
}).shape,
|
|
3983
|
+
async (c, a) => ok("Share link.", await data(c, "POST", `/management/campaigns/${s(a.campaignId)}/share-links`, { scope: a.scope, externalKey: a.externalKey, expiresIn: a.expiresIn, label: a.label }))
|
|
3984
|
+
),
|
|
3985
|
+
def(
|
|
3986
|
+
"revoke_campaign_share_link",
|
|
3987
|
+
"Withdraw a share link",
|
|
3988
|
+
"Withdraw ONE share link by its `jti`, leaving every other link to the same campaign working. The link then answers 404. Safe to call twice.",
|
|
3989
|
+
z.object({ campaignId: z.string().min(1), jti: z.string().min(1) }).shape,
|
|
3990
|
+
async (c, a) => ok("Share link revoked.", await data(c, "DELETE", `/management/campaigns/${s(a.campaignId)}/share-links/${s(a.jti)}`))
|
|
3991
|
+
),
|
|
3813
3992
|
def(
|
|
3814
3993
|
"list_ai_jobs",
|
|
3815
3994
|
"List bulk jobs",
|
|
@@ -3820,7 +3999,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
|
|
|
3820
3999
|
def(
|
|
3821
4000
|
"get_ai_job",
|
|
3822
4001
|
"Get a bulk job",
|
|
3823
|
-
"One bulk job \u2014 status, rows processed, rows created, conflicts, and the approval plan if it is still parked.",
|
|
4002
|
+
"One bulk job \u2014 its `type` (generate, seo, rename, migrate, publish, unpublish, research), status, rows processed, rows created, conflicts, and the approval plan if it is still parked. `rowResults` is the per-row ledger: every row that FAILED or was skipped, with a reason code, which is the only place \u201C20 rows in, 18 pages out\u201D is explained. `summary` splits a rerun into added / updated / unchanged, and `campaignId` / `collectionSlug` say which campaign and collection the pages landed in.",
|
|
3824
4003
|
z.object({ jobId: z.string().min(1) }).shape,
|
|
3825
4004
|
async (c, a) => ok("Bulk job.", await data(c, "GET", `/management/bulk/jobs/${s(a.jobId)}`))
|
|
3826
4005
|
),
|
|
@@ -4477,7 +4656,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4477
4656
|
name: "create_components",
|
|
4478
4657
|
config: {
|
|
4479
4658
|
title: "Create many components in one call",
|
|
4480
|
-
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component whenever you are making more than three: a whole-site componentize run (playbook \xA712) is dozens, and one call each spends the turn on plumbing. 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.
|
|
4659
|
+
description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component whenever you are making more than three: a whole-site componentize run (playbook \xA712) is dozens, and one call each spends the turn on plumbing. 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: inside a component only the leaves a declared prop TARGETS are click-to-edit, and copy no prop points at is reachable neither from the canvas nor from the dock. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
4481
4660
|
inputSchema: createComponentsInput.shape
|
|
4482
4661
|
},
|
|
4483
4662
|
handler: guard(
|
|
@@ -4538,7 +4717,7 @@ ${notes.join("\n")}` : summary, created);
|
|
|
4538
4717
|
name: "compose_pages",
|
|
4539
4718
|
config: {
|
|
4540
4719
|
title: "Set many pages' block composition in one call",
|
|
4541
|
-
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 that page would render the missing sections as empty strings with no error anywhere, which is the single hardest failure on this platform to trace back. Writes DRAFTS: publish each page with update_page status:'published' afterwards.
|
|
4720
|
+
description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 that page would render the missing sections as empty strings with no error anywhere, which is the single hardest failure on this platform to trace back. Writes DRAFTS: publish each page with update_page status:'published' afterwards. A page is composed of SECTIONS: a recurring band is a component with a `sectionType`, a one-off band is a `section` block whose `props.children` hold its blocks \u2014 never loose top-level heading/text/image blocks, which no editor can move, name or swap as a unit. The full doctrine is in the bettercms://playbook/schema resource, \xA712.",
|
|
4542
4721
|
inputSchema: composePagesInput.shape
|
|
4543
4722
|
},
|
|
4544
4723
|
handler: guard(
|
|
@@ -4844,7 +5023,10 @@ ${lines.join("\n")}`, found);
|
|
|
4844
5023
|
// through the client's request plumbing — no bespoke SDK method per endpoint.
|
|
4845
5024
|
...lifecycleTools()
|
|
4846
5025
|
];
|
|
4847
|
-
return
|
|
5026
|
+
return [
|
|
5027
|
+
...defs.filter((d) => !LISTING_TAIL.has(d.name)),
|
|
5028
|
+
...defs.filter((d) => LISTING_TAIL.has(d.name))
|
|
5029
|
+
].map(withProjectId);
|
|
4848
5030
|
}
|
|
4849
5031
|
function registerTools(server, deps) {
|
|
4850
5032
|
const withElicit = {
|
|
@@ -5874,16 +6056,31 @@ End-to-end authoring from a repo or a brief. Confirm-first at every stage.
|
|
|
5874
6056
|
apply via \`set_page_content\` / \`create_content_entry\` / \`update_content_entry\`.
|
|
5875
6057
|
3. **SEO** \u2014 run the SEO flow to fill metaTitle/metaDescription for each page.
|
|
5876
6058
|
Never invent brand facts \u2014 ask the user for anything the repo/brief doesn't state.`;
|
|
5877
|
-
var LANDING_PAGES_FLOW = `### Generate landing pages (programmatic SEO / ABM) \u2192 \`
|
|
6059
|
+
var LANDING_PAGES_FLOW = `### Generate landing pages (programmatic SEO / ABM) \u2192 \`generate_pages_from_dataset\`
|
|
5878
6060
|
Spin up many pages sharing one template, each personalized per row (company, keyword, persona).
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
the
|
|
6061
|
+
|
|
6062
|
+
For an ABM campaign \u2014 a page per NAMED COMPANY \u2014 the whole flow is eight steps, in this order:
|
|
6063
|
+
1. **Research** \u2014 \`research_account\` per account, or set \`mapping.campaign.research\` and let the
|
|
6064
|
+
run do it ('lite' reads the company's own site, 'deep' searches the web, 'agent' hands the work
|
|
6065
|
+
to YOU through \`claim_abm_task\` / \`complete_abm_task\`).
|
|
6066
|
+
2. **Generate** \u2014 \`generate_pages_from_dataset\` with \`mapping.campaign = { id, research,
|
|
6067
|
+
externalKeyColumn: 'domain' }\`. It fans out across every page the campaign owns and parks for
|
|
6068
|
+
approval. Re-running the same export UPDATES each account's page rather than duplicating it.
|
|
6069
|
+
3. **Approve the job** \u2014 \`approve_ai_job\`, only once the user has said yes.
|
|
6070
|
+
4. **Review** \u2014 \`get_campaign_review\` with \`sample: 5\`, then \`filter: 'flagged'\`. A flag reads
|
|
6071
|
+
\`unsupported_claim:headline\`: a figure that is in none of the account's own facts, so the field
|
|
6072
|
+
fell back to the blueprint's copy. \`filter: 'failed'\` lists rows that produced no page at all.
|
|
6073
|
+
5. **Regenerate** \u2014 \`regenerate_entry\` with an instruction, one row at a time.
|
|
6074
|
+
6. **Approve entries** \u2014 \`approve_campaign_entries\`; \`'all_unflagged'\` clears the rest in one call.
|
|
6075
|
+
A FLAGGED page must be named explicitly: approving one says a person checked the claim.
|
|
6076
|
+
7. **Publish** \u2014 \`publish_campaign\` with \`confirm: true\`. It refuses \`entry_flagged\` while any
|
|
6077
|
+
page carries an unresolved flag, and \`blueprint_missing_disclaimer\` if a page has lost its
|
|
6078
|
+
"not affiliated with" band. The user decides this step, never you.
|
|
6079
|
+
8. **Share** \u2014 \`get_campaign_share_link\` for a link a rep can send before publishing.
|
|
6080
|
+
|
|
6081
|
+
For a plain programmatic-SEO run (a page per keyword, no named companies) the same tool works
|
|
6082
|
+
with no \`campaign\`, and \`write_content\` + \`create_content_entry\` per row is still fine for a
|
|
6083
|
+
handful. ALWAYS dry-run first and show the sample.`;
|
|
5887
6084
|
var SEO_FLOW = `### Optimize SEO \u2192 \`generate_seo_meta\` + the page/entry update tools
|
|
5888
6085
|
Fill or refresh SEO metadata across the site.
|
|
5889
6086
|
1. **Target** \u2014 pick the pages/entries (\`list_pages\` / \`list_content_entries\`); confirm scope with the user.
|
|
@@ -6167,7 +6364,7 @@ Confirm each stage with the user before writing. On 401/403, the MCP key needs (
|
|
|
6167
6364
|
"generate_landing_pages",
|
|
6168
6365
|
{
|
|
6169
6366
|
title: "Generate landing pages (programmatic SEO / ABM)",
|
|
6170
|
-
description: "Spin up many personalized landing pages from one template + a dataset
|
|
6367
|
+
description: "Spin up many personalized landing pages from one template + a dataset. For ABM (a page per named company) this is the research -> generate -> review -> approve -> publish -> share order.",
|
|
6171
6368
|
argsSchema: {
|
|
6172
6369
|
request: z2.string().optional().describe("the campaign, e.g. 'a page per target company for our ABM push'")
|
|
6173
6370
|
}
|