The HTTP API.
Edit at machine speed.
Read your content, and edit a whole page in a single request. Built so an AI agent (Cursor, Claude Code, Windsurf, Codex) or any HTTP client drives your site, with the visual builder still right there when you want it.
New to agent editing? Start with connecting your AI assistant.
Start here
Generate a token
Settings → API Tokens → Create. Pick a role, copy it once, send it as a Bearer header. That’s the whole handshake.
- Base
- https://your-site.com
- Format
- JSON in, JSON out
- Roles
- editor · admin
Overview
One API, the whole site.
Every install ships a real HTTP API, the same engine the visual builder uses, so anything you can do by clicking, you can do over HTTP.
Content lives under /api/cms/*; branding, theme, header, footer and SEO under /api/admin/settings. Every request is JSON, authenticated with a bearer token, and scoped so a token can touch content and branding and nothing dangerous.
The fastest path
The batch endpoint applies many changes in one transaction: “recolor twelve sections and rewrite five headlines” is a single round-trip, as fast as touching the database directly, but fully validated and audited.
Already installed?
Point your AI tool at the AGENTS.md file at the root of your install (Cursor, Claude Code, Windsurf, and Codex read it automatically), hand it a token, and describe the change in plain English. See the AI agents guide. On an MCP-aware editor, skip the raw HTTP and connect over the MCP server for typed tools instead.
Quickstart
Three steps to
your first edit.
Get a token, read something, write something. No cookie, no CSRF, no login page.
1 · Generate a token
Settings → API Tokens → Create. Pick editor or admin, set an optional expiry, re-enter your password, copy the token. It’s shown once and stored only as a hash. Lose it, revoke and mint a new one.
2 · Read your content
Confirm auth + list pages
curl https://your-site.com/api/cms/pages \
-H "Authorization: Bearer cave_YOUR_TOKEN"
# → 200 with a valid token, 401 without one3 · Make a change
Recolor a section + retitle a heading + add one, in one call
curl -X POST https://your-site.com/api/cms/pages/42/batch \
-H "Authorization: Bearer cave_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ops": [
{ "op": "patchMeta", "blockId": 101, "metaPatch": { "background": "obsidian" } },
{ "op": "patchData", "blockId": 205, "dataPatch": { "text": "New headline" } },
{ "op": "create", "kind": "widget", "blockType": "lx_heading", "parent": 108,
"data": { "text": "Hello, world", "alignment": "center" } }
]
}'Authentication
The token is the credential.
Send your token as a bearer header on every request. Tokens are prefixed cave_ and carry a 256-bit secret.
Bearer requests skip CSRF entirely: the token isn’t auto-sent by a browser, so it isn’t a CSRF vector. (Cookie sessions keep full CSRF protection and add an x-csrf-token header on writes.)
Every request
Authorization: Bearer cave_<your-token>What a token can’t do
A token reaches only /api/cms/* and the content/branding keys of /api/admin/settings. Even an admin-role token can never manage users, change security/auth settings, rotate secrets, alter session policy, toggle analytics injection, or touch the updater. The cap is enforced in three independent places, and a token’s role is clamped to the creator’s current role: demote or deactivate them and the token follows instantly. Revocation takes effect on the next request.
Token-writable settings keys: theme_palette site_header footer contact_info social_links default_seo organization_json_ld mobile_cta.
The content model
Pages, sections, blocks.
A page is a row plus a tree of blocks, exactly three levels deep: a section holds columns (1 to 4), and a column holds widgets, the content.
GET /api/cms/pages/{id} returns the page plus a flat, position-ordered list of blocks; each carries parent_id and kind so you can rebuild the tree.
data vs meta
| Field | Type | Notes |
|---|---|---|
| data | JSON | A widget’s CONTENT: heading text, paragraph HTML, image ref, form fields. Validated by the block type’s schema and sanitized. Containers carry data = {}. |
| meta | JSON | PRESENTATION: a section’s background / padding / columns, a column’s width / card-link, a widget’s spacing + htmlId. A section’s background lives here, so reading meta is how you see its current colour before changing it. |
Versions & optimistic locking
Each block has a version; the page has its own. Pass them on a write to assert “nobody changed this since I read it”. A mismatch returns 409 stale_block_version / 409 stale_page_version. In the batch endpoint they’re optional (omit = last-write-wins); the per-block PATCH requires them.
Batch API, the fast lane
Edit a whole page
in one request.
Block-by-block editing is serial: every write bumps the page’s shared version, so you fire writes one at a time, each waiting for the last.
The batch endpoint applies many ops in one request → one transaction → one revalidate. Every op still runs the full write boundary (validation, sanitization, media integrity, htmlId uniqueness, the column cap) and writes an audit row. Any error rolls the whole batch back.
Request
| Field | Type | Notes |
|---|---|---|
| pageVersion | number? | Optional whole-batch optimistic lock. Omit for last-write-wins. |
| ops | Op[] | 1 to 50 ops, applied in array order so a create can be referenced by a later op. |
The five ops
Each op has an op discriminator. Errors come back as op[<index>]:<code> so you know which one failed.
create
Add a section, column, or widget. Set tempId to reference it from a later op as { "ref": "tempId" } to build a whole tree in one batch.
| Field | Type | Notes |
|---|---|---|
| kind | "section"|"column"|"widget" | Required. |
| blockType | string | Required for widgets (e.g. "lx_heading"). |
| parent | number|null|{ref} | Block id, null (top-level section), or a temp-id ref. |
| data | JSON | Required for widgets (the content schema). |
| meta | JSON | Section/column settings. Section meta defaults are seeded. |
| tempId | string? | Handle for later parent refs. |
patchData
Update a widget’s content. Send exactly one of data (full replace) or dataPatch (shallow merge that changes one field, keeps the rest). Optional expectedVersion locks it.
patchMeta
Update a section / column / widget’s presentation. This is how you recolor a section. Send meta (full) or metaPatch (merge). A metaPatch { "background": "obsidian" } changes only the background and keeps columns + padding.
delete
Soft-delete a block (30-day trash, restorable). Deleting a section/column cascades to descendants. Fixed template slots can’t be deleted.
reorderChildren
Reorder a parent’s children. Pass parent and orderedBlockIds, the complete set of that parent’s living children in the new order. (New blocks land in creation order, so create them as you want them.)
Response
200 with the new page version, a tempIds map (your handles → real ids), and a per-op results array.
Response shape
{
"pageVersion": 43,
"tempIds": { "sec1": 1001, "col1": 1002 },
"results": [
{ "op": "create", "tempId": "sec1", "id": 1001, "version": 0, "parentId": null, "position": 1000 },
{ "op": "patchMeta", "id": 101, "version": 6 },
{ "op": "delete", "id": 503, "deletedIds": [503, 504] }
]
}Build a whole section in one call
POST /api/cms/pages/{id}/batch request body (copy & adapt)
{
"ops": [
{ "op": "create", "tempId": "hero", "kind": "section",
"meta": { "columns": 2, "background": "ivory", "padding": "xl" } },
{ "op": "create", "tempId": "left", "kind": "column", "parent": { "ref": "hero" } },
{ "op": "create", "tempId": "right", "kind": "column", "parent": { "ref": "hero" } },
{ "op": "create", "kind": "widget", "blockType": "lx_heading",
"parent": { "ref": "left" },
"data": { "text": "Welcome", "level": "h1", "size": "display-xl" } },
{ "op": "create", "kind": "widget", "blockType": "lx_action",
"parent": { "ref": "left" },
"data": { "label": "Get started", "href": "/contact", "variant": "primary-gold" } }
]
}Endpoint reference
Every route.
All JSON. Writes need a token with the editor or admin role; a few fields (publish, slug, set-as-home, deletes) are admin-only and return 403 forbidden to an editor.
Pages
/api/cms/pagesadmin · editor?trashed=1 for the trash. → { items }./api/cms/pages/{id}admin · editor · viewerdata, meta, version). → { page, blocks }./api/cms/pagesadmin · editortitle, slug, template (admins may clone)./api/cms/pages/{id}admin · editorslug, published, isHome). Needs version./api/cms/pages/{id}/batchadmin · editor/api/cms/pages/{id}adminBlocks (single)
/api/cms/blocksadmin · editorpageId, kind, blockType+data or meta, optional parentId / afterBlockId / withColumns./api/cms/blocks/{id}admin · editordata or meta. Needs pageId, blockVersion, pageVersion./api/cms/blocks/reorderadmin · editor/api/cms/blocks/{id}/duplicateadmin · editor/api/cms/blocks/{id}/restoreadmin · editorcascade: true for its subtree./api/cms/blocks/{id}admin · editorPosts & projects
/api/cms/posts/{id}admin · editor · viewerbody_md). Read before editing. List at GET /api/cms/posts./api/cms/postsadmin · editorslug, title); PATCH /api/cms/posts/{id} fills body_md (admin-only published)./api/cms/projects/{id}admin · editor · viewerGET /api/cms/pages and edit the blocks./api/cms/projects/{id}admin · editorversion. (Create / delete / restore mirror posts.)Media
/api/cms/mediaadmin · editor?cursor=&limit=, ≤ 50)./api/cms/mediaadmin · editormultipart/form-data: file (image ≤ 10 MB / PDF ≤ 25 MB) + alt. Auto-generates variants. → { id, uuid }; reference id from a block’s image field./api/cms/media/{id}admin409 still_referenced if a block still uses it.Settings (content & branding)
/api/admin/settingsadmin/api/admin/settingsadminkey, value, version. Writable: theme_palette site_header footer contact_info social_links default_seo organization_json_ld mobile_cta. Anything else → 403.Chrome colour overrides (all optional #hex; unset = the theme defaults): site_header takes ctaFillColor ctaTextColor ctaHoverFillColor ctaHoverTextColor ctaRadius (0–64 px) for the CTA pill plus navColor / navActiveColor for the nav links; footer takes newsletterEnabled (boolean — false removes the newsletter column), accentColor, and the same cta* colour set for its Subscribe button.
Overlay header. site_header takes headerMode ("solid" default | "overlay" — transparent over the hero, solid on scroll), overlayLogo (a { media_id, alt } for a white logo shown only while transparent; the main logo takes over once solid), and overlayTone ("light" | "dark" transparent-state text). headerMode is site-wide but resolved per page: on an overlay site a page whose first section is light renders the solid bar from the top automatically. Force one page with PATCH /api/cms/pages/{id} { headerMode: "solid" | "overlay" | null, version } (null = inherit).
Footer columns. footer.columns is [{ label, links: [{ text, href }] }] — up to 6 columns, rendered as real horizontal columns beside the brand block. Three or four give a full estate-style footer.
Sync (publish local → production)
/api/cms/sync/exportadmin · editor/api/cms/sync/hashadmin · editor/api/cms/sync/stageadmin?validateOnly=1 dry-runs, no writes./api/cms/sync/cutoveradminforce; snapshots production first.You rarely call these by hand; the cavecms CLI wraps them. See shipping to production for the push workflow.
Block types
The widget catalog.
A widget’s data shape is defined by its block type. The core ones are below; the full catalog and how to discover any shape follow.
lx_heading
lx_heading.data
{
"text": "Welcome", // required, 1–220
"level": "h2", // h1–h6
"size": "display-lg", // display-2xl | xl | lg | md | sm
"alignment": "left", // left | center | right
"tone": "obsidian", // colour token or #hex
"highlightText": "Today", // optional — two-tone heading: first match
"highlightColor": "#E8590C", // recoloured (token/#hex, default champagne)
"animation": "none" // none | fade-in | slide-up | line-reveal
}lx_text
lx_text.data
{
"body_richtext": "<p>Sanitized rich HTML</p>", // <= 16 KB
"size": "body-md", // body-lg | md | sm
"alignment": "left", // left | center
"maxWidth": "medium" // narrow | medium | wide | full
}lx_action (button)
lx_action.data
{
"label": "Learn more", // required, 1–50
"href": "/contact", // required
"variant": "primary-gold",// primary-gold | secondary-outline | ghost | link-arrow
"size": "md", // sm | md | lg
"elevation": "none", // optional — none | shadow | pulse (flat brand button)
"fillColor": "#E8590C", // optional solid fill (token/#hex)…
"hoverFillColor": "#D9480F" // …and hover fill/text still win on hover
}lx_figure (image)
lx_figure.data
{
"image": { "media_id": 101, "alt": "Studio entrance" }, // id from POST /api/cms/media
"ratio": "16:9", // 21:9 | 16:9 | 4:5 | 1:1
"fit": "cover", // cover | contain
"caption": "Optional"
}Section & column meta
Container styling lives in meta. A full meta replace on a section needscolumns/background/padding; a metaPatch needs only the keys you change.
Section meta
{
"columns": 2, // 1 | 2 | 3 | 4
"background": "obsidian", // cream | near-black | copper-tint | obsidian |
// ivory | champagne | bone | charcoal
"padding": "lg", // none | sm | md | lg | xl | 2xl
"backgroundImage": { "media_id": 101, "alt": "" }, // optional cover photo
"htmlId": "hero" // optional in-page anchor (unique per page)
}Column meta
{
"width": 6, // 1–12 grid units (even split by default)
"verticalAlign": "center",// start | center | end
"cardLink": { "href": "/projects/x" }, // make the whole column a link
"cardLinkLabel": "View project" // required with cardLink
}Full catalog (≈47 types)
Text & headings: lx_eyebrow lx_heading lx_text lx_quote lx_animated_headline
Actions & nav: lx_action lx_cta_banner lx_menu_anchor lx_toc lx_share lx_social_icons
Media: lx_figure lx_gallery lx_image_pair lx_cover_image lx_video lx_carousel lx_before_after lx_hotspot lx_marquee
Layout: lx_divider lx_space
Data & features: lx_stat lx_icon_list lx_icon_box lx_pricing_table lx_pricing_list lx_comparison_table lx_progress lx_progress_tracker lx_timeline lx_countdown lx_flip_box lx_tabs lx_accordion
Social proof: lx_testimonial lx_testimonial_carousel lx_reviews lx_star_rating
Contact & forms: contact_form lx_inquiry_form lx_brochure_form lx_channel_card
Dynamic & embed: lx_posts lx_featured_projects lx_embed lx_code lx_map
Don’t know a shape?
Copy a live one: GET /api/cms/pages/{id} on a page that already uses the block and read its data. Or, with the codebase on disk, read lib/cms/block-registry.ts, the schema for every block type.
Errors
Clear, actionable codes.
Errors return { error, requestId } with a fitting status. In a batch the error is prefixed op[<index>]: so you know which op failed, and the whole batch rolled back.
| Status | When |
|---|---|
| 400 | Malformed body, missing field, unknown block type, both/neither of an exactly-one pair. |
| 401 | Missing or invalid token. |
| 403 | Editor hit an admin-only field, or a token hit a non-writable settings key. |
| 404 | Resource missing or trashed (also a forged id pointing at another page, with no info-leak). |
| 409 | Optimistic-lock mismatch, slug in use, html_id_collision, column_count_exceeded, reorder drift. |
| 413 | Upload exceeds the size limit. |
| 422 | Validation guard failed (e.g. invalid slug). |
| 429 | Rate limit exceeded. Back off and retry. |
Common batch op codes: stale_page_version stale_block_version not_found parent_not_found unknown_block_type missing_data column_parent_must_be_section widget_parent_must_be_column column_count_exceeded html_id_collision invalid_data invalid_meta wrong_field_for_kind cannot_delete_fixed_block incomplete_reorder not_a_child unknown_parent_ref.
Recolor a section
POST /api/cms/pages/42/batch (one merge op, no prior read needed)
{ "ops": [
{ "op": "patchMeta", "blockId": 101, "metaPatch": { "background": "champagne" } }
] }Rewrite copy across a page
POST /api/cms/pages/42/batch (several patchData ops at once)
{ "ops": [
{ "op": "patchData", "blockId": 205, "dataPatch": { "text": "Our new headline" } },
{ "op": "patchData", "blockId": 206, "dataPatch": { "body_richtext": "<p>Refreshed.</p>" } }
] }Add a blog post
Create then fill
POST /api/cms/posts { "slug": "launch-day", "title": "Launch day" }
# → { id: 12, slug: "launch-day" }
PATCH /api/cms/posts/12 { "version": 0, "body_md": "# We're live\n\nThe big news…" }Upload an image and use it
Multipart upload → reference the id
POST /api/cms/media (multipart: [email protected], alt="Hero")
# → { id: 101, uuid: "…" }
POST /api/cms/pages/42/batch
{ "ops": [ { "op": "create", "kind": "widget", "blockType": "lx_figure", "parent": 108,
"data": { "image": { "media_id": 101, "alt": "Hero" }, "ratio": "16:9" } } ] }Limits & safety
Fast, but never reckless.
Over a limit you get 429, so back off and retry. Every write is validated, sanitized, and audited, so nothing over the API can corrupt your site or slip past the checks the dashboard enforces. Soft-deletes go to a 30-day, restorable trash.
| Field | Type | Notes |
|---|---|---|
| Mutations | 300 / min / user | Each write is one tick. A batch counts each op, so batching never evades the limit. |
| Reads | 120 / min / user | GET list + single-resource endpoints. |
| Uploads | 10 / min / user | POST /api/cms/media. |
| Batch size | ≤ 50 ops | Per request. Build a large page in a few batches. |
| Reorder | ≤ 500 ids | Per reorderChildren op. |
| Columns | ≤ 4 per section | Enforced server-side. |
| Upload | image ≤ 10 MB · PDF ≤ 25 MB | Larger → 413. |
Hand it a token. Watch it build.
Generate a token under Settings → API Tokens, point your AI tool at the AGENTS.md file in your install, and describe the change in plain English.
Keep going
Related
MCP server
Connect Claude Code, Cursor, or Windsurf to your live site with the MCP server built into every install. One command, scoped tokens, typed tools.
AI agents
Point Cursor, Claude Code, Windsurf, or Codex at your site: what AGENTS.md is, how to mint a token, and how to make your first change by prompt.
Features
SEO, security, redirects, backups, an AI writing partner, and a real HTTP API your agent can drive. All built in, not bolted on as plugins.