@bettercms-ai/mcp 0.83.0 → 0.83.2

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 (40) hide show
  1. package/README.md +86 -86
  2. package/SKILL.md +160 -160
  3. package/dist/index.js +38 -9
  4. package/dist/index.js.map +1 -1
  5. package/dist/kit-catalog.json +1509 -1509
  6. package/dist/skills/bettercms/SKILL.md +206 -206
  7. package/dist/skills/bettercms/references/accessibility.md +171 -171
  8. package/dist/skills/bettercms/references/animation.md +296 -296
  9. package/dist/skills/bettercms/references/astro.md +148 -148
  10. package/dist/skills/bettercms/references/bindings.md +366 -366
  11. package/dist/skills/bettercms/references/componentize.md +185 -185
  12. package/dist/skills/bettercms/references/components.md +415 -415
  13. package/dist/skills/bettercms/references/content-experiments.md +62 -62
  14. package/dist/skills/bettercms/references/content-modeling.md +382 -382
  15. package/dist/skills/bettercms/references/detection.md +465 -465
  16. package/dist/skills/bettercms/references/dock-input.md +121 -121
  17. package/dist/skills/bettercms/references/editor-chrome.md +92 -92
  18. package/dist/skills/bettercms/references/external-content.md +78 -78
  19. package/dist/skills/bettercms/references/finish-site.md +377 -377
  20. package/dist/skills/bettercms/references/forms-and-interactions.md +245 -245
  21. package/dist/skills/bettercms/references/frameworks.md +159 -159
  22. package/dist/skills/bettercms/references/getting-started.md +156 -156
  23. package/dist/skills/bettercms/references/integrations.md +364 -364
  24. package/dist/skills/bettercms/references/kit-catalog.md +3259 -3259
  25. package/dist/skills/bettercms/references/kit-scenarios.md +712 -712
  26. package/dist/skills/bettercms/references/localization.md +85 -85
  27. package/dist/skills/bettercms/references/media-and-brand.md +181 -181
  28. package/dist/skills/bettercms/references/next.md +189 -189
  29. package/dist/skills/bettercms/references/other-frameworks.md +85 -85
  30. package/dist/skills/bettercms/references/portable-text.md +213 -213
  31. package/dist/skills/bettercms/references/programmatic-seo.md +98 -98
  32. package/dist/skills/bettercms/references/publishing.md +289 -289
  33. package/dist/skills/bettercms/references/seo.md +357 -357
  34. package/dist/skills/bettercms/references/shopify-headless.md +52 -52
  35. package/dist/skills/bettercms/references/site-blueprints.md +895 -895
  36. package/dist/skills/bettercms/references/site-review.md +74 -74
  37. package/dist/skills/bettercms/references/worked-examples-advanced.md +509 -509
  38. package/dist/skills/bettercms/references/worked-examples.md +885 -885
  39. package/dist/skills/bettercms/references/write-limits.md +217 -217
  40. package/package.json +1 -1
@@ -1,289 +1,289 @@
1
- ---
2
- title: "Publishing and sync"
3
- description: "Publish order, delivery lanes, execute and verify, rollback, reconciling a re-import or continuous sync, and the automation surfaces that exist."
4
- ---
5
-
6
- # Publishing, release, rollback and sync reconciliation
7
-
8
- **Outcome.** The intended revision reaches the right public site with its dependencies, routes,
9
- forms and cache state checked, and a new source revision lands without overwriting editorial
10
- work. A dashboard success is not evidence that a public route works.
11
-
12
- Order of operations and why a draft renders nothing: §7 of `bettercms://playbook/schema`.
13
- Component refusal codes: [components.md](components.md) §4. Hosting modes, the SSR switch, env
14
- names and revalidation: [frameworks.md](frameworks.md).
15
-
16
- ## 1. Plan the dependency graph
17
-
18
- Confirm the project and its hosting mode from `get_project`, never from the repo name (`hybrid`
19
- is handled exactly like `managed`). Record the source commit, content revision, live baseline,
20
- release target and rollback point.
21
-
22
- Publish in dependency order: component definitions (and nested components), Form documents, the
23
- global navigation and footer, the Global Layout, pages and entries, SEO, then the frontend code
24
- that reads them.
25
-
26
- - **Chrome**: components first, then `publish_layout`; the order and the logo refusals are in
27
- [components.md](components.md) §4.
28
- - **Forms**: test the destination and consent behaviour, then `publish_form`; a draft form answers
29
- 403 to every public submission.
30
- - **Pages and entries are two rows.** Publishing a singleton page publishes its entry too, as the
31
- dashboard's publish button does, unless the entry's own gate refuses it (its workflow stage, or
32
- the publish permission). `ENTRY_STILL_DRAFT` means the page went live over content that did
33
- not, and names the entry and why; `ENTRY_SEED_FAILED` on `create_page` means none of its seed
34
- data was saved.
35
- - Publishing copies the stored draft to live, so what you checked in the draft is what ships.
36
- - **Many at once**: `publish_entries` { action: 'publish' | 'unpublish', entryIds, pageIds } takes
37
- up to 100 of each in one call (pages publish only). Each item still passes its own gates, and
38
- nothing stops the run: read `failed` for every refusal and its reason, then `outcome` for what
39
- the served pages show (the same shape `update_page` returns on a publish, below).
40
- - **Throw away a draft**: `discard_draft` with `entryId` or `pageId` returns it to exactly what is
41
- live; a page also drops its unpublished Layout overrides (`no_published_version` if it never
42
- went live: delete it instead).
43
- - **Edit a page safely**: read `get_page` and send its `optimisticVersion` as `ifMatch` on
44
- `update_page`; a 409 means someone changed it since, so read again. `blockJson` replaces the
45
- whole composition, so an unguarded write can erase a concurrent edit.
46
-
47
- ## 2. Choose the delivery lane
48
-
49
- | Hosting mode | Release path | Public proof |
50
- |---|---|---|
51
- | `managed` or `hybrid` | `deploy_project` or `create_deploy_upload`, then `get_deploy_status` | Cache-busted BetterCMS URL answers 200 with the intended revision |
52
- | `headless` | Publish content in BetterCMS; the frontend deploys through its own host (`npx @bettercms-ai/cli@latest deploy --to <host>` the first time, which registers the rebuild hook) and rebuild or revalidation | The external host's URL answers 200 with the intended content and metadata |
53
-
54
- A managed site renders static unless the owner switches Hosting to **Run as a server app**, which
55
- must happen before an SSR build ships ([frameworks.md](frameworks.md)).
56
-
57
- ### What a publish builds
58
-
59
- On a site BetterCMS hosts, a publish applies in place with no build: values, Layout values, SEO of a
60
- page with HTML on disk, and a reorder or removal of sections the build draws from the CMS (every band
61
- stamped `data-bcms-block`, measured in CMS order). Only what needs new markup builds, by itself: a
62
- new or removed page, a new section or one whose component changed, a Layout structure change, SEO
63
- of a page with no HTML on disk, and a list whose published row count the page cannot take (it
64
- renders rows no address names, or was built empty). That publish waits for its build ("Rebuilding so
65
- the list shows N rows") and fails with the reason if the build still shows the page's own rows: the
66
- site's code renders the list itself. `get_next_steps` names what is waiting.
67
-
68
- A static page whose own code fixes its sections takes all of it in place too, when the project's
69
- in-place structure switch is on: the publish writes the page's block list into the built HTML.
70
- The code's bands stand in the published order, a removed one is cut, a section added in
71
- BetterCMS is drawn between them, and every root takes its block's style. No repository change
72
- and no build; each rebuild is put right the same way. The gate's `sections` row says which
73
- routes take it (`accepts: "in-place"`).
74
-
75
- A collection's item pages take it per TEMPLATE: the collection page's sections are the
76
- template's, held once, so a section added to that page (`update_page` on the collection page)
77
- stands on every entry's route, and an entry has no sections of its own. Its publish reads one
78
- row for all of them (`route` `blog/*`). The entry's body is a section too: add sections around
79
- it or move it, and space it through its field's style, since its element is the field's.
80
-
81
- The SEO a page or entry stores is what its `<head>` says, whenever it was set: the title, the
82
- description, the canonical, the card fields, a `noindex` that is on. A field left empty keeps what
83
- the build rendered, and a site default only fills a tag the build has none of. Only a real choice
84
- replaces what the build renders: a meta title that merely repeats the page's name (whatever its
85
- case), a description that repeats the title or is the entry's auto-filled first paragraph, and
86
- anything on a row no person or agent has ever written (`origin` `system:*`) are read as not set. To
87
- change such a head, set the title or description yourself. On a route the site's app renders, no write
88
- reaches the head: a publish whose SEO the page does not show fails with "Published, but this page's
89
- code doesn't read this field yet: SEO title", and the fix is to render the head from the published
90
- values.
91
-
92
- Builds run `build:bcms` instead of `build` when `package.json` declares it. Keep CI-only checks out
93
- of it and every step the build needs in: `"build": "astro check && astro build"` beside
94
- `"build:bcms": "astro build"`. The convert codemod adds one to a repo whose build chains a check.
95
-
96
- ### Add a section, or set its spacing
97
-
98
- One call each, on any page (a page BetterCMS draws, a code-built one, a collection template):
99
-
100
- - **Add a section here**: `update_page { pageId, blockJson: { insertSection: { componentId, after: "<block id>" } } }`.
101
- `componentId` is a component from `list_components`; a `builtin:*` id is copied into the project
102
- once and the copy is placed. Place it with `at` (an index into the page's sections), `before`
103
- or `after` (a section's block id from `get_page`); none of the three adds it last. `style` and
104
- `overrides` set its block style and its copy in the same call.
105
- - **Set spacing on this section**: `update_page { pageId, blockJson: { sectionStyle: { blockId, style } } }`.
106
- Each key of `style` replaces the stored one and `null` unsets it: `paddingTop`, `paddingBottom`,
107
- `paddingSides` (px, 0 to 240), `spaceTop`, `spaceBottom`, `spaceSides` (the spacing tokens),
108
- `bg`, `bgCustom`, `contentWidth`, `contentAlign`, `theme`, `corner`, `shadow`, `border`,
109
- `opacity`, `hideOn`. A key the block style does not have is refused (`INVALID_STYLE`).
110
-
111
- The server edits the page as stored, so nothing else on it is resent or overwritten; send
112
- `ifMatch` to refuse a page that changed since you read it. Both save a DRAFT: add
113
- `status: "published"` to the same call, or publish after. Then read the answer, top down:
114
-
115
- 1. `held`: the draft is saved and the live page will NOT show it. It names the reason and the one
116
- action (below). Do the action; do not report the change as made.
117
- 2. `outcome` (on a publish): `status` `live` is every value found on the served page. `partial`
118
- lists what is not in `notLive`, each with its `reason` and `action` (the table under Reading
119
- `get_deploy_status`). `next: "get_deploy_status"` means it is not judged whole: nothing has
120
- looked at it yet, or a section change is still being written (`NOT_CHECKED`). It resolves by
121
- itself to live or held: poll `publishes` there before you say anything is live.
122
- 3. `section`: the block's `id` and `index`, to place the next one beside it.
123
- 4. `warnings`: `COMPONENT_DRAFT` is a section that renders nothing until `publish_component`.
124
-
125
- Refusals: `SECTION_NOT_FOUND` (the answer lists the page's section ids), `POSITION_OUT_OF_RANGE`,
126
- `COMPONENT_NOT_FOUND`, `BUILTIN_NOT_FOUND`. A whole `blockJson` array still replaces the page's
127
- sections; use it to move or remove one, with `ifMatch`.
128
-
129
- ### A section change that is held at the write
130
-
131
- `update_page` and `compose_pages` save the draft and answer `held` first (with `holds`) when a
132
- write adds, moves or removes a section the live page cannot show yet. Each hold has a `reason`,
133
- a `message`, the `sections` it is about and the one `action`. Never report such a write as done:
134
-
135
- - `CODE_FIXED_SECTIONS`: the site's code fixes this page's sections, so no publish ships the
136
- change. Read the route's `sections` row in `get_next_steps` (`receipt.gate`): its `fixCode`
137
- names the lane ([finish-site.md](finish-site.md)). `enable-in-place-structure` is the user's
138
- switch and needs no code. `run-sections-conversion` is the wrap: `componentize_sections` with
139
- `codemod: true` and the `digest` of `get_componentize_plan`, or, when that answers `skipped`
140
- (no repository the platform may commit to), `npx @bettercms-ai/convert@latest --sections --root .`
141
- in a checkout ([componentize.md](componentize.md) §5). After its build, publish.
142
- - `SECTION_NEEDS_BUILD`: the build draws the page's sections from BetterCMS. Publish: the build
143
- starts by itself, and a move or a removal needs none.
144
- - `STRUCTURE_UNMEASURED`: nothing has measured the page. Publish, then read the publish's outcome
145
- in `get_deploy_status`.
146
-
147
- ### Reading `get_deploy_status`
148
-
149
- - `release` is the newest release: `id`, `sha`, `status`, `phaseMarks`, `error`, `createdAt`.
150
- While an uploaded build is ingested or released the state is `building`, `release.sha` is the
151
- new sha and `lastLiveSha` the old one. Sending the live sha again does nothing. A failed deploy
152
- has `release.status` `failed` and its `error`.
153
- - `held` means the release waits until the hold is cleared; `held.by` is `staff`, `archived` or
154
- `no-subdomain`.
155
- - `slot` is where the bytes are: `current` is production, `staging` the staging handle.
156
- `productionSha` is what production serves. `awaitingPromote: true` is a build that is verified
157
- and ready on staging while production keeps the older sha, until `promote_project` runs.
158
- - The poll is finished only when `slot` is `current`, or when `awaitingPromote` is false and
159
- `productionSha` equals the sha you deployed.
160
- - `publishes` is the newest publishes, value by value. `status` `live` means every published value
161
- was found in the file the site serves that page from. `partial` means some were not: `summary`
162
- counts them ("3 of 4 live · 1 held") and `notLive` lists each with its `route`, `address`,
163
- `state`, `reason` and the one `action` that fixes it. Never tell the user a publish is live
164
- while it reads `partial`: relay the reason and do the action, then poll again.
165
-
166
- | `reason` | `state` | The one action |
167
- |---|---|---|
168
- | `NOT_STAMPED` | held | Rebuild: the built page has no element for the field and does not show it |
169
- | `VALUE_REFUSED` | held | Bind the field to an element that can hold its value |
170
- | `APP_DOES_NOT_READ` | held | Convert in code: make the page's code read the published field |
171
- | `ROWS_NEED_BUILD`, `SECTION_NEEDS_BUILD`, `NEEDS_BUILD` | held | Wait for the build the publish started |
172
- | `STRUCTURE_UNBOUND` | refused | The fix the route's `sections` gate row names: the in-place switch, or the sections conversion |
173
- | `SECTION_MISSING` | held | The same: the served page does not render the section |
174
- | `SECTION_NOT_ON_PAGE` | held | In place: a section of the site's own code has no band on this page. The sections conversion, or remove the placement |
175
- | `SECTION_NOT_RENDERABLE` | held | In place: the added section's component is not published, or it needs a form. `publish_component`, then publish the page again |
176
- | `STYLE_NOT_PAINTABLE` | held | Rebuild once the section root carries the style marker (`sectionRootAttrs`, or the codemod's `--upgrade-sections`: [components.md](components.md)) |
177
- | `WINDOW_NOT_OPEN` | held | Nothing: it goes live when its window opens |
178
- | `ROUTE_NOT_OWNED` | held | Change one of the two page paths that share the URL |
179
- | `APP_UNREACHABLE`, `NOT_CHECKED`, `EDGE_PENDING`, `IMAGE_UNAVAILABLE`, `STRUCTURE_UNMEASURED` | unverified | Poll again; publish again if it stays |
180
-
181
- Each row's `action.code` is one of these, and `get_next_steps` lists every held or refused value
182
- as `publish-held` until it is gone:
183
-
184
- | `action.code` | What you do |
185
- |---|---|
186
- | `rebuild` | No tool rebuilds: change the site's code if the reason names it, then ask the user to Rebuild from Publishing (or `deploy_project` from a checkout) |
187
- | `wait_for_build` | Poll `get_deploy_status` until the build the publish started is live |
188
- | `convert_in_code` | Make the page's code read the field ([bindings.md](bindings.md)), then release |
189
- | `control_section_order` | The route's `sections` gate row names it: the in-place switch (the user's), or `componentize_sections` with `codemod: true` (else `convert --sections` in a checkout), then release |
190
- | `editor_setup` | `editor_setup` |
191
- | `rebind` | Bind the field to an element that can hold it, in the site's code |
192
- | `resolve_route` | Change one of the two page paths |
193
- | `wait`, `retry` | Poll again; publish again if it stays |
194
-
195
- ### Deploy to Vercel or Netlify
196
-
197
- To serve a `managed` or `hybrid` site from the owner's own Vercel or Netlify account, call
198
- `list_hosting_connections` first; with no working connection for that host, hand the user
199
- `connectUrl` to connect it in the browser and wait. Then `deploy_to_host` with the `provider`:
200
- BetterCMS builds, the host serves, and calling it again redeploys the same site. Relay any
201
- refusal's `fixUrl` to the user. Poll `get_host_deploy_status` until the newest deployment is live
202
- and verify its `providerUrl`. Forms post to the BetterCMS API, so they need no rewrite, and no host
203
- CLI or pasted host token is ever needed while a connection exists.
204
-
205
- ## 3. Execute and verify
206
-
207
- 1. After an authorized release, record the operation ids and revisions from each call.
208
- 2. On a refusal, stop: resolve the cause its code names, then retry only with changed input.
209
- 3. Fetch each touched route directly, cache-busted: status, the copy you published, component
210
- overrides, media URLs, metadata and forms. Include one collection detail route, the global
211
- navigation and footer, and mobile interaction when affected.
212
- 4. Run the verify loop in SKILL.md, noting each report's freshness.
213
- 5. On a cached site, confirm the publish webhook or rebuild actually refreshed the public page.
214
-
215
- ## 4. Roll back
216
-
217
- Use the known prior content revision and source commit. For an entry, `list_entry_versions` then
218
- `restore_entry_version`: it writes that version back as the current draft and adds a new version,
219
- so publish it afterwards. Unpublished edits you do not want are `discard_draft`, not a restore.
220
- Restore only the affected lane, recheck the live URLs, and never overwrite newer unrelated edits.
221
-
222
- ## 4b. Stage a change on a branch
223
-
224
- A content branch is a copy of the project's content to edit without touching the live site.
225
- `create_branch` { name } returns it; pass its id as `branchId` on every call made inside it
226
- (`list_branches` names the one you are on). To merge, `apply_branch` { branchId, dryRun: true }
227
- returns the diff and its conflicts: show it to the user, then apply, with `include` to take only
228
- some changes and `fields` to settle each conflicting field (`keep_branch` / `keep_parent`).
229
-
230
- ## 5. Reconcile a re-import, a push or a continuous sync
231
-
232
- Identify the previous source snapshot or commit, the new one, the import manifest, the
233
- source-to-target id map and the current BetterCMS state. `list_activity` with `since` reads the
234
- project's durable change feed from an ISO timestamp (up to 100 events per call): call it with the
235
- time of the previous sync to see every dashboard or agent edit made since. Compare by stable identity, never
236
- by display name or row order, and confirm it is the same project and source branch. Entries
237
- written with `externalKey` (the source id) are their own id map: `list_content_entries` with
238
- `externalKey` finds one, and re-sending the row answers `created`, `updated` or `unchanged`
239
- ([write-limits.md](write-limits.md)). Media, and references to items not yet created, still
240
- need the map. Without a manifest, keys or an id map this is no longer an automatic sync; treat
241
- it as a reviewed migration (the `bettercms-migrate` skill).
242
-
243
- Classify each difference and keep a three-way view where you can (the value last imported, the
244
- current source value, the current CMS value). Never flatten a person's CMS edit back to imported
245
- code because the file was touched.
246
-
247
- | Difference | Resolution |
248
- |---|---|
249
- | New in source | Import with the normal model and component rules; file into the existing Content sidebar folders |
250
- | Changed only in source | Update fields that are still code-owned or explicitly synced; CMS-owned values stay |
251
- | Changed only in CMS | Keep |
252
- | Changed on both sides | Report both values with provenance; apply a winner only after the user chooses |
253
- | Missing in source | An orphan, never an automatic delete. Tell removed from moved or renamed; offer archive / keep / relink with the inbound references, routes and redirects each affects |
254
- | Route or slug change | A URL and redirect plan (`create_redirect`); check collisions, canonicals and internal links first |
255
- | Assets | Match by source id or path plus checksum; a changed binary is a new revision; a missing source asset never breaks a published reference until the user approves its replacement or removal |
256
-
257
- Dry-run the proposed operations, then run the same snapshot again to prove it is idempotent (no
258
- new changes the second time). Apply only approved, supported changes, then compare counts,
259
- references, component usage, field bindings, preview and the affected public routes. The rollback
260
- for a content value the sync got wrong is §4.
261
-
262
- ## 6. Automation surfaces
263
-
264
- "Run X when content changes" maps onto what exists; there is no user-deployed function runtime.
265
-
266
- | Need | Surface | Owner |
267
- |---|---|---|
268
- | Rebuild or revalidate a site on publish | The publish webhook, signed `X-BetterCMS-Signature` (HMAC-SHA256 of the raw body) with the project's webhook secret ([frameworks.md](frameworks.md) Revalidation) | Secret set in the dashboard; your route verifies it |
269
- | Notify another system of an event | Event webhooks, `list_webhooks` / `create_webhook`: `content.publish`, `content.unpublish`, `content.stage_changed` (workflow stage), `content.created`, `content.updated`, `content.deleted` and `deployment.triggered`; HMAC-SHA256 signed in `X-BetterCMS-Signature`; payloads carry ids, never content: an entry's `content.publish`/`content.unpublish` carries `data.entryId`, a page's carries `data.pageId` and `kind: "page"` (so `entryId` is not always present). The signing `secret` is returned ONCE, on create. A receiver that writes back on `content.updated` loops unless it skips its own writes. Per-subscription payload filters: dashboard | You |
270
- | Send form submissions somewhere | Per-form integrations: Google Sheets, Slack, a webhook, email | Dashboard only |
271
- | Go live or retire at a time | `scheduledAt` (publish a draft at a time; needs publish permission) and `unpublishAt` (return a published row to draft) on `update_content_entry` and `update_page`; `null` cancels either. Campaigns: `publish_campaign` takes both | You. An edit that omits `scheduledAt` CANCELS the pending publish, so resend it with every edit before it fires; omitting `unpublishAt` never cancels it |
272
- | Show one section only for a period | `window` `{ from?, until? }` on a page block (`update_page` `blockJson`) or on a Layout Section (`update_layout` `add-section`, `update-section`). Shown from `from` until `until`; each is an ISO 8601 datetime with an offset (`2026-11-27T00:00:00+01:00`), stored as UTC, and either may be left out. `null` clears it. Refusals: `invalid_window` (no offset, or not a date), `window_inverted` (`from` not before `until`), `window_in_component` (inside a component's `blockJson`: put it on the placement), `window_in_entry` (on a block inside an entry's sections value: put it on the page block or Layout Section that shows the entry). A Section a page adds or customizes takes a window like a Global one | You. Outside its window the block is in no published output: the page, its Markdown, the delivery API and search. A draft preview draws it, marked. A page BetterCMS renders per request changes at the boundary; a static site catches up within about a minute, in place when a section goes and with a build when one appears. Site code that renders its own header ignores a Section's window |
273
- | Load many entries | `create_content_entries`: up to 200 drafts of one collection per call, each row `created` or `failed` (with `externalKey` also `updated` or `unchanged`, so a re-run is safe); then `publish_entries` | You |
274
- | React to edits made elsewhere | Poll `list_activity` with `since` set to the time of your last read (1–100 events per call) | You |
275
- | Generate or rewrite in bulk with a human gate | AI jobs: `list_ai_jobs`, `get_ai_job`, then `approve_ai_job` or `reject_ai_job` on the user's word | User approves |
276
-
277
- Not available, so say so instead of building around it: code that runs inside BetterCMS on a
278
- content event, and a change subscription over the MCP (poll `list_activity` `since`). Anything
279
- beyond the table runs in the user's own infrastructure, triggered by a webhook. Why: a timer on the
280
- delivery API looks like a trigger, but it serves published content only and never sees an edit
281
- before it ships.
282
-
283
- ## Report
284
-
285
- State what is live, what remains draft, whether a rollback ran, and any owner action still
286
- required (Visual Approval, a render-mode change, a secret). For a sync, list exact created,
287
- updated, unchanged, orphaned and conflicted ids, the choices still open, and the rollback point;
288
- an open conflict is never a successful sync. Do not say "launched" until the public response is
289
- verified.
1
+ ---
2
+ title: "Publishing and sync"
3
+ description: "Publish order, delivery lanes, execute and verify, rollback, reconciling a re-import or continuous sync, and the automation surfaces that exist."
4
+ ---
5
+
6
+ # Publishing, release, rollback and sync reconciliation
7
+
8
+ **Outcome.** The intended revision reaches the right public site with its dependencies, routes,
9
+ forms and cache state checked, and a new source revision lands without overwriting editorial
10
+ work. A dashboard success is not evidence that a public route works.
11
+
12
+ Order of operations and why a draft renders nothing: §7 of `bettercms://playbook/schema`.
13
+ Component refusal codes: [components.md](components.md) §4. Hosting modes, the SSR switch, env
14
+ names and revalidation: [frameworks.md](frameworks.md).
15
+
16
+ ## 1. Plan the dependency graph
17
+
18
+ Confirm the project and its hosting mode from `get_project`, never from the repo name (`hybrid`
19
+ is handled exactly like `managed`). Record the source commit, content revision, live baseline,
20
+ release target and rollback point.
21
+
22
+ Publish in dependency order: component definitions (and nested components), Form documents, the
23
+ global navigation and footer, the Global Layout, pages and entries, SEO, then the frontend code
24
+ that reads them.
25
+
26
+ - **Chrome**: components first, then `publish_layout`; the order and the logo refusals are in
27
+ [components.md](components.md) §4.
28
+ - **Forms**: test the destination and consent behaviour, then `publish_form`; a draft form answers
29
+ 403 to every public submission.
30
+ - **Pages and entries are two rows.** Publishing a singleton page publishes its entry too, as the
31
+ dashboard's publish button does, unless the entry's own gate refuses it (its workflow stage, or
32
+ the publish permission). `ENTRY_STILL_DRAFT` means the page went live over content that did
33
+ not, and names the entry and why; `ENTRY_SEED_FAILED` on `create_page` means none of its seed
34
+ data was saved.
35
+ - Publishing copies the stored draft to live, so what you checked in the draft is what ships.
36
+ - **Many at once**: `publish_entries` { action: 'publish' | 'unpublish', entryIds, pageIds } takes
37
+ up to 100 of each in one call (pages publish only). Each item still passes its own gates, and
38
+ nothing stops the run: read `failed` for every refusal and its reason, then `outcome` for what
39
+ the served pages show (the same shape `update_page` returns on a publish, below).
40
+ - **Throw away a draft**: `discard_draft` with `entryId` or `pageId` returns it to exactly what is
41
+ live; a page also drops its unpublished Layout overrides (`no_published_version` if it never
42
+ went live: delete it instead).
43
+ - **Edit a page safely**: read `get_page` and send its `optimisticVersion` as `ifMatch` on
44
+ `update_page`; a 409 means someone changed it since, so read again. `blockJson` replaces the
45
+ whole composition, so an unguarded write can erase a concurrent edit.
46
+
47
+ ## 2. Choose the delivery lane
48
+
49
+ | Hosting mode | Release path | Public proof |
50
+ |---|---|---|
51
+ | `managed` or `hybrid` | `deploy_project` or `create_deploy_upload`, then `get_deploy_status` | Cache-busted BetterCMS URL answers 200 with the intended revision |
52
+ | `headless` | Publish content in BetterCMS; the frontend deploys through its own host (`npx @bettercms-ai/cli@latest deploy --to <host>` the first time, which registers the rebuild hook) and rebuild or revalidation | The external host's URL answers 200 with the intended content and metadata |
53
+
54
+ A managed site renders static unless the owner switches Hosting to **Run as a server app**, which
55
+ must happen before an SSR build ships ([frameworks.md](frameworks.md)).
56
+
57
+ ### What a publish builds
58
+
59
+ On a site BetterCMS hosts, a publish applies in place with no build: values, Layout values, SEO of a
60
+ page with HTML on disk, and a reorder or removal of sections the build draws from the CMS (every band
61
+ stamped `data-bcms-block`, measured in CMS order). Only what needs new markup builds, by itself: a
62
+ new or removed page, a new section or one whose component changed, a Layout structure change, SEO
63
+ of a page with no HTML on disk, and a list whose published row count the page cannot take (it
64
+ renders rows no address names, or was built empty). That publish waits for its build ("Rebuilding so
65
+ the list shows N rows") and fails with the reason if the build still shows the page's own rows: the
66
+ site's code renders the list itself. `get_next_steps` names what is waiting.
67
+
68
+ A static page whose own code fixes its sections takes all of it in place too, when the project's
69
+ in-place structure switch is on: the publish writes the page's block list into the built HTML.
70
+ The code's bands stand in the published order, a removed one is cut, a section added in
71
+ BetterCMS is drawn between them, and every root takes its block's style. No repository change
72
+ and no build; each rebuild is put right the same way. The gate's `sections` row says which
73
+ routes take it (`accepts: "in-place"`).
74
+
75
+ A collection's item pages take it per TEMPLATE: the collection page's sections are the
76
+ template's, held once, so a section added to that page (`update_page` on the collection page)
77
+ stands on every entry's route, and an entry has no sections of its own. Its publish reads one
78
+ row for all of them (`route` `blog/*`). The entry's body is a section too: add sections around
79
+ it or move it, and space it through its field's style, since its element is the field's.
80
+
81
+ The SEO a page or entry stores is what its `<head>` says, whenever it was set: the title, the
82
+ description, the canonical, the card fields, a `noindex` that is on. A field left empty keeps what
83
+ the build rendered, and a site default only fills a tag the build has none of. Only a real choice
84
+ replaces what the build renders: a meta title that merely repeats the page's name (whatever its
85
+ case), a description that repeats the title or is the entry's auto-filled first paragraph, and
86
+ anything on a row no person or agent has ever written (`origin` `system:*`) are read as not set. To
87
+ change such a head, set the title or description yourself. On a route the site's app renders, no write
88
+ reaches the head: a publish whose SEO the page does not show fails with "Published, but this page's
89
+ code doesn't read this field yet: SEO title", and the fix is to render the head from the published
90
+ values.
91
+
92
+ Builds run `build:bcms` instead of `build` when `package.json` declares it. Keep CI-only checks out
93
+ of it and every step the build needs in: `"build": "astro check && astro build"` beside
94
+ `"build:bcms": "astro build"`. The convert codemod adds one to a repo whose build chains a check.
95
+
96
+ ### Add a section, or set its spacing
97
+
98
+ One call each, on any page (a page BetterCMS draws, a code-built one, a collection template):
99
+
100
+ - **Add a section here**: `update_page { pageId, blockJson: { insertSection: { componentId, after: "<block id>" } } }`.
101
+ `componentId` is a component from `list_components`; a `builtin:*` id is copied into the project
102
+ once and the copy is placed. Place it with `at` (an index into the page's sections), `before`
103
+ or `after` (a section's block id from `get_page`); none of the three adds it last. `style` and
104
+ `overrides` set its block style and its copy in the same call.
105
+ - **Set spacing on this section**: `update_page { pageId, blockJson: { sectionStyle: { blockId, style } } }`.
106
+ Each key of `style` replaces the stored one and `null` unsets it: `paddingTop`, `paddingBottom`,
107
+ `paddingSides` (px, 0 to 240), `spaceTop`, `spaceBottom`, `spaceSides` (the spacing tokens),
108
+ `bg`, `bgCustom`, `contentWidth`, `contentAlign`, `theme`, `corner`, `shadow`, `border`,
109
+ `opacity`, `hideOn`. A key the block style does not have is refused (`INVALID_STYLE`).
110
+
111
+ The server edits the page as stored, so nothing else on it is resent or overwritten; send
112
+ `ifMatch` to refuse a page that changed since you read it. Both save a DRAFT: add
113
+ `status: "published"` to the same call, or publish after. Then read the answer, top down:
114
+
115
+ 1. `held`: the draft is saved and the live page will NOT show it. It names the reason and the one
116
+ action (below). Do the action; do not report the change as made.
117
+ 2. `outcome` (on a publish): `status` `live` is every value found on the served page. `partial`
118
+ lists what is not in `notLive`, each with its `reason` and `action` (the table under Reading
119
+ `get_deploy_status`). `next: "get_deploy_status"` means it is not judged whole: nothing has
120
+ looked at it yet, or a section change is still being written (`NOT_CHECKED`). It resolves by
121
+ itself to live or held: poll `publishes` there before you say anything is live.
122
+ 3. `section`: the block's `id` and `index`, to place the next one beside it.
123
+ 4. `warnings`: `COMPONENT_DRAFT` is a section that renders nothing until `publish_component`.
124
+
125
+ Refusals: `SECTION_NOT_FOUND` (the answer lists the page's section ids), `POSITION_OUT_OF_RANGE`,
126
+ `COMPONENT_NOT_FOUND`, `BUILTIN_NOT_FOUND`. A whole `blockJson` array still replaces the page's
127
+ sections; use it to move or remove one, with `ifMatch`.
128
+
129
+ ### A section change that is held at the write
130
+
131
+ `update_page` and `compose_pages` save the draft and answer `held` first (with `holds`) when a
132
+ write adds, moves or removes a section the live page cannot show yet. Each hold has a `reason`,
133
+ a `message`, the `sections` it is about and the one `action`. Never report such a write as done:
134
+
135
+ - `CODE_FIXED_SECTIONS`: the site's code fixes this page's sections, so no publish ships the
136
+ change. Read the route's `sections` row in `get_next_steps` (`receipt.gate`): its `fixCode`
137
+ names the lane ([finish-site.md](finish-site.md)). `enable-in-place-structure` is the user's
138
+ switch and needs no code. `run-sections-conversion` is the wrap: `componentize_sections` with
139
+ `codemod: true` and the `digest` of `get_componentize_plan`, or, when that answers `skipped`
140
+ (no repository the platform may commit to), `npx @bettercms-ai/convert@latest --sections --root .`
141
+ in a checkout ([componentize.md](componentize.md) §5). After its build, publish.
142
+ - `SECTION_NEEDS_BUILD`: the build draws the page's sections from BetterCMS. Publish: the build
143
+ starts by itself, and a move or a removal needs none.
144
+ - `STRUCTURE_UNMEASURED`: nothing has measured the page. Publish, then read the publish's outcome
145
+ in `get_deploy_status`.
146
+
147
+ ### Reading `get_deploy_status`
148
+
149
+ - `release` is the newest release: `id`, `sha`, `status`, `phaseMarks`, `error`, `createdAt`.
150
+ While an uploaded build is ingested or released the state is `building`, `release.sha` is the
151
+ new sha and `lastLiveSha` the old one. Sending the live sha again does nothing. A failed deploy
152
+ has `release.status` `failed` and its `error`.
153
+ - `held` means the release waits until the hold is cleared; `held.by` is `staff`, `archived` or
154
+ `no-subdomain`.
155
+ - `slot` is where the bytes are: `current` is production, `staging` the staging handle.
156
+ `productionSha` is what production serves. `awaitingPromote: true` is a build that is verified
157
+ and ready on staging while production keeps the older sha, until `promote_project` runs.
158
+ - The poll is finished only when `slot` is `current`, or when `awaitingPromote` is false and
159
+ `productionSha` equals the sha you deployed.
160
+ - `publishes` is the newest publishes, value by value. `status` `live` means every published value
161
+ was found in the file the site serves that page from. `partial` means some were not: `summary`
162
+ counts them ("3 of 4 live · 1 held") and `notLive` lists each with its `route`, `address`,
163
+ `state`, `reason` and the one `action` that fixes it. Never tell the user a publish is live
164
+ while it reads `partial`: relay the reason and do the action, then poll again.
165
+
166
+ | `reason` | `state` | The one action |
167
+ |---|---|---|
168
+ | `NOT_STAMPED` | held | Rebuild: the built page has no element for the field and does not show it |
169
+ | `VALUE_REFUSED` | held | Bind the field to an element that can hold its value |
170
+ | `APP_DOES_NOT_READ` | held | Convert in code: make the page's code read the published field |
171
+ | `ROWS_NEED_BUILD`, `SECTION_NEEDS_BUILD`, `NEEDS_BUILD` | held | Wait for the build the publish started |
172
+ | `STRUCTURE_UNBOUND` | refused | The fix the route's `sections` gate row names: the in-place switch, or the sections conversion |
173
+ | `SECTION_MISSING` | held | The same: the served page does not render the section |
174
+ | `SECTION_NOT_ON_PAGE` | held | In place: a section of the site's own code has no band on this page. The sections conversion, or remove the placement |
175
+ | `SECTION_NOT_RENDERABLE` | held | In place: the added section's component is not published, or it needs a form. `publish_component`, then publish the page again |
176
+ | `STYLE_NOT_PAINTABLE` | held | Rebuild once the section root carries the style marker (`sectionRootAttrs`, or the codemod's `--upgrade-sections`: [components.md](components.md)) |
177
+ | `WINDOW_NOT_OPEN` | held | Nothing: it goes live when its window opens |
178
+ | `ROUTE_NOT_OWNED` | held | Change one of the two page paths that share the URL |
179
+ | `APP_UNREACHABLE`, `NOT_CHECKED`, `EDGE_PENDING`, `IMAGE_UNAVAILABLE`, `STRUCTURE_UNMEASURED` | unverified | Poll again; publish again if it stays |
180
+
181
+ Each row's `action.code` is one of these, and `get_next_steps` lists every held or refused value
182
+ as `publish-held` until it is gone:
183
+
184
+ | `action.code` | What you do |
185
+ |---|---|
186
+ | `rebuild` | No tool rebuilds: change the site's code if the reason names it, then ask the user to Rebuild from Publishing (or `deploy_project` from a checkout) |
187
+ | `wait_for_build` | Poll `get_deploy_status` until the build the publish started is live |
188
+ | `convert_in_code` | Make the page's code read the field ([bindings.md](bindings.md)), then release |
189
+ | `control_section_order` | The route's `sections` gate row names it: the in-place switch (the user's), or `componentize_sections` with `codemod: true` (else `convert --sections` in a checkout), then release |
190
+ | `editor_setup` | `editor_setup` |
191
+ | `rebind` | Bind the field to an element that can hold it, in the site's code |
192
+ | `resolve_route` | Change one of the two page paths |
193
+ | `wait`, `retry` | Poll again; publish again if it stays |
194
+
195
+ ### Deploy to Vercel or Netlify
196
+
197
+ To serve a `managed` or `hybrid` site from the owner's own Vercel or Netlify account, call
198
+ `list_hosting_connections` first; with no working connection for that host, hand the user
199
+ `connectUrl` to connect it in the browser and wait. Then `deploy_to_host` with the `provider`:
200
+ BetterCMS builds, the host serves, and calling it again redeploys the same site. Relay any
201
+ refusal's `fixUrl` to the user. Poll `get_host_deploy_status` until the newest deployment is live
202
+ and verify its `providerUrl`. Forms post to the BetterCMS API, so they need no rewrite, and no host
203
+ CLI or pasted host token is ever needed while a connection exists.
204
+
205
+ ## 3. Execute and verify
206
+
207
+ 1. After an authorized release, record the operation ids and revisions from each call.
208
+ 2. On a refusal, stop: resolve the cause its code names, then retry only with changed input.
209
+ 3. Fetch each touched route directly, cache-busted: status, the copy you published, component
210
+ overrides, media URLs, metadata and forms. Include one collection detail route, the global
211
+ navigation and footer, and mobile interaction when affected.
212
+ 4. Run the verify loop in SKILL.md, noting each report's freshness.
213
+ 5. On a cached site, confirm the publish webhook or rebuild actually refreshed the public page.
214
+
215
+ ## 4. Roll back
216
+
217
+ Use the known prior content revision and source commit. For an entry, `list_entry_versions` then
218
+ `restore_entry_version`: it writes that version back as the current draft and adds a new version,
219
+ so publish it afterwards. Unpublished edits you do not want are `discard_draft`, not a restore.
220
+ Restore only the affected lane, recheck the live URLs, and never overwrite newer unrelated edits.
221
+
222
+ ## 4b. Stage a change on a branch
223
+
224
+ A content branch is a copy of the project's content to edit without touching the live site.
225
+ `create_branch` { name } returns it; pass its id as `branchId` on every call made inside it
226
+ (`list_branches` names the one you are on). To merge, `apply_branch` { branchId, dryRun: true }
227
+ returns the diff and its conflicts: show it to the user, then apply, with `include` to take only
228
+ some changes and `fields` to settle each conflicting field (`keep_branch` / `keep_parent`).
229
+
230
+ ## 5. Reconcile a re-import, a push or a continuous sync
231
+
232
+ Identify the previous source snapshot or commit, the new one, the import manifest, the
233
+ source-to-target id map and the current BetterCMS state. `list_activity` with `since` reads the
234
+ project's durable change feed from an ISO timestamp (up to 100 events per call): call it with the
235
+ time of the previous sync to see every dashboard or agent edit made since. Compare by stable identity, never
236
+ by display name or row order, and confirm it is the same project and source branch. Entries
237
+ written with `externalKey` (the source id) are their own id map: `list_content_entries` with
238
+ `externalKey` finds one, and re-sending the row answers `created`, `updated` or `unchanged`
239
+ ([write-limits.md](write-limits.md)). Media, and references to items not yet created, still
240
+ need the map. Without a manifest, keys or an id map this is no longer an automatic sync; treat
241
+ it as a reviewed migration (the `bettercms-migrate` skill).
242
+
243
+ Classify each difference and keep a three-way view where you can (the value last imported, the
244
+ current source value, the current CMS value). Never flatten a person's CMS edit back to imported
245
+ code because the file was touched.
246
+
247
+ | Difference | Resolution |
248
+ |---|---|
249
+ | New in source | Import with the normal model and component rules; file into the existing Content sidebar folders |
250
+ | Changed only in source | Update fields that are still code-owned or explicitly synced; CMS-owned values stay |
251
+ | Changed only in CMS | Keep |
252
+ | Changed on both sides | Report both values with provenance; apply a winner only after the user chooses |
253
+ | Missing in source | An orphan, never an automatic delete. Tell removed from moved or renamed; offer archive / keep / relink with the inbound references, routes and redirects each affects |
254
+ | Route or slug change | A URL and redirect plan (`create_redirect`); check collisions, canonicals and internal links first |
255
+ | Assets | Match by source id or path plus checksum; a changed binary is a new revision; a missing source asset never breaks a published reference until the user approves its replacement or removal |
256
+
257
+ Dry-run the proposed operations, then run the same snapshot again to prove it is idempotent (no
258
+ new changes the second time). Apply only approved, supported changes, then compare counts,
259
+ references, component usage, field bindings, preview and the affected public routes. The rollback
260
+ for a content value the sync got wrong is §4.
261
+
262
+ ## 6. Automation surfaces
263
+
264
+ "Run X when content changes" maps onto what exists; there is no user-deployed function runtime.
265
+
266
+ | Need | Surface | Owner |
267
+ |---|---|---|
268
+ | Rebuild or revalidate a site on publish | The publish webhook, signed `X-BetterCMS-Signature` (HMAC-SHA256 of the raw body) with the project's webhook secret ([frameworks.md](frameworks.md) Revalidation) | Secret set in the dashboard; your route verifies it |
269
+ | Notify another system of an event | Event webhooks, `list_webhooks` / `create_webhook`: `content.publish`, `content.unpublish`, `content.stage_changed` (workflow stage), `content.created`, `content.updated`, `content.deleted` and `deployment.triggered`; HMAC-SHA256 signed in `X-BetterCMS-Signature`; payloads carry ids, never content: an entry's `content.publish`/`content.unpublish` carries `data.entryId`, a page's carries `data.pageId` and `kind: "page"` (so `entryId` is not always present). The signing `secret` is returned ONCE, on create. A receiver that writes back on `content.updated` loops unless it skips its own writes. Per-subscription payload filters: dashboard | You |
270
+ | Send form submissions somewhere | Per-form integrations: Google Sheets, Slack, a webhook, email | Dashboard only |
271
+ | Go live or retire at a time | `scheduledAt` (publish a draft at a time; needs publish permission) and `unpublishAt` (return a published row to draft) on `update_content_entry` and `update_page`; `null` cancels either. Campaigns: `publish_campaign` takes both | You. An edit that omits `scheduledAt` CANCELS the pending publish, so resend it with every edit before it fires; omitting `unpublishAt` never cancels it |
272
+ | Show one section only for a period | `window` `{ from?, until? }` on a page block (`update_page` `blockJson`) or on a Layout Section (`update_layout` `add-section`, `update-section`). Shown from `from` until `until`; each is an ISO 8601 datetime with an offset (`2026-11-27T00:00:00+01:00`), stored as UTC, and either may be left out. `null` clears it. Refusals: `invalid_window` (no offset, or not a date), `window_inverted` (`from` not before `until`), `window_in_component` (inside a component's `blockJson`: put it on the placement), `window_in_entry` (on a block inside an entry's sections value: put it on the page block or Layout Section that shows the entry). A Section a page adds or customizes takes a window like a Global one | You. Outside its window the block is in no published output: the page, its Markdown, the delivery API and search. A draft preview draws it, marked. A page BetterCMS renders per request changes at the boundary; a static site catches up within about a minute, in place when a section goes and with a build when one appears. Site code that renders its own header ignores a Section's window |
273
+ | Load many entries | `create_content_entries`: up to 200 drafts of one collection per call, each row `created` or `failed` (with `externalKey` also `updated` or `unchanged`, so a re-run is safe); then `publish_entries` | You |
274
+ | React to edits made elsewhere | Poll `list_activity` with `since` set to the time of your last read (1–100 events per call) | You |
275
+ | Generate or rewrite in bulk with a human gate | AI jobs: `list_ai_jobs`, `get_ai_job`, then `approve_ai_job` or `reject_ai_job` on the user's word | User approves |
276
+
277
+ Not available, so say so instead of building around it: code that runs inside BetterCMS on a
278
+ content event, and a change subscription over the MCP (poll `list_activity` `since`). Anything
279
+ beyond the table runs in the user's own infrastructure, triggered by a webhook. Why: a timer on the
280
+ delivery API looks like a trigger, but it serves published content only and never sees an edit
281
+ before it ships.
282
+
283
+ ## Report
284
+
285
+ State what is live, what remains draft, whether a rollback ran, and any owner action still
286
+ required (Visual Approval, a render-mode change, a secret). For a sync, list exact created,
287
+ updated, unchanged, orphaned and conflicted ids, the choices still open, and the rollback point;
288
+ an open conflict is never a successful sync. Do not say "launched" until the public response is
289
+ verified.