CaveCMS
API reference

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 one

3 · 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

FieldTypeNotes
dataJSONA widget’s CONTENT: heading text, paragraph HTML, image ref, form fields. Validated by the block type’s schema and sanitized. Containers carry data = {}.
metaJSONPRESENTATION: 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.

POST /api/cms/pages/{id}/batch· admin · editor

Request

FieldTypeNotes
pageVersionnumber?Optional whole-batch optimistic lock. Omit for last-write-wins.
opsOp[]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.

FieldTypeNotes
kind"section"|"column"|"widget"Required.
blockTypestringRequired for widgets (e.g. "lx_heading").
parentnumber|null|{ref}Block id, null (top-level section), or a temp-id ref.
dataJSONRequired for widgets (the content schema).
metaJSONSection/column settings. Section meta defaults are seeded.
tempIdstring?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

GET/api/cms/pagesadmin · editor
List pages (≤ 50). ?trashed=1 for the trash. → { items }.
GET/api/cms/pages/{id}admin · editor · viewer
One page + its full block tree (each block has data, meta, version). → { page, blocks }.
POST/api/cms/pagesadmin · editor
Create. Body: title, slug, template (admins may clone).
PATCH/api/cms/pages/{id}admin · editor
Update title / SEO / hero / og (+ admin slug, published, isHome). Needs version.
POST/api/cms/pages/{id}/batchadmin · editor
The fast lane. See Batch API.
DELETE/api/cms/pages/{id}admin
Soft-delete.

Blocks (single)

POST/api/cms/blocksadmin · editor
Create one block. Body: pageId, kind, blockType+data or meta, optional parentId / afterBlockId / withColumns.
PATCH/api/cms/blocks/{id}admin · editor
Update one block’s data or meta. Needs pageId, blockVersion, pageVersion.
POST/api/cms/blocks/reorderadmin · editor
Reorder / move (cross-parent). Needs each affected parent’s complete child set.
POST/api/cms/blocks/{id}/duplicateadmin · editor
Recursively duplicate (≤ 256 blocks).
POST/api/cms/blocks/{id}/restoreadmin · editor
Restore a soft-deleted block; cascade: true for its subtree.
DELETE/api/cms/blocks/{id}admin · editor
Soft-delete (cascades to descendants).

Posts & projects

GET/api/cms/posts/{id}admin · editor · viewer
One post with every field (incl. body_md). Read before editing. List at GET /api/cms/posts.
POST/api/cms/postsadmin · editor
Create draft (slug, title); PATCH /api/cms/posts/{id} fills body_md (admin-only published).
GET/api/cms/projects/{id}admin · editor · viewer
One project with every field (tagline, location, SEO, hero). The detail page renders from its own block tree. Find it in GET /api/cms/pages and edit the blocks.
PATCH/api/cms/projects/{id}admin · editor
Update project fields. Needs version. (Create / delete / restore mirror posts.)

Media

GET/api/cms/mediaadmin · editor
List, cursor-paginated (?cursor=&limit=, ≤ 50).
POST/api/cms/mediaadmin · editor
multipart/form-data: file (image ≤ 10 MB / PDF ≤ 25 MB) + alt. Auto-generates variants. → { id, uuid }; reference id from a block’s image field.
DELETE/api/cms/media/{id}admin
Soft-delete; 409 still_referenced if a block still uses it.

Settings (content & branding)

GET/api/admin/settingsadmin
Read settings. A token sees only its writable keys; secrets are always redacted.
PATCH/api/admin/settingsadmin
Update one key: key, 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)

GET/api/cms/sync/exportadmin · editor
Export this install’s whole content graph + media manifest as one bundle.
GET/api/cms/sync/hashadmin · editor
Canonical content hash + object counts: the drift baseline a push checks against.
POST/api/cms/sync/stageadmin
Upload + validate a bundle (gzip body). ?validateOnly=1 dry-runs, no writes.
POST/api/cms/sync/cutoveradmin
Atomic content swap. Refuses on drift unless force; 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.

StatusWhen
400Malformed body, missing field, unknown block type, both/neither of an exactly-one pair.
401Missing or invalid token.
403Editor hit an admin-only field, or a token hit a non-writable settings key.
404Resource missing or trashed (also a forged id pointing at another page, with no info-leak).
409Optimistic-lock mismatch, slug in use, html_id_collision, column_count_exceeded, reorder drift.
413Upload exceeds the size limit.
422Validation guard failed (e.g. invalid slug).
429Rate 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.

Recipes

Common tasks.

The patterns an agent reaches for most, each a single round-trip.

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.

FieldTypeNotes
Mutations300 / min / userEach write is one tick. A batch counts each op, so batching never evades the limit.
Reads120 / min / userGET list + single-resource endpoints.
Uploads10 / min / userPOST /api/cms/media.
Batch size≤ 50 opsPer request. Build a large page in a few batches.
Reorder≤ 500 idsPer reorderChildren op.
Columns≤ 4 per sectionEnforced server-side.
Uploadimage ≤ 10 MB · PDF ≤ 25 MBLarger → 413.
Next

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.