How AI sessions and external services create and publish CMS content programmatically — endpoint, JSON shape, access tokens, and what happens after a successful POST.
When to use this
This guide is for AI sessions, scheduled jobs, and external services that need to push content into a CMS site without going through the admin UI. Typical cases:
- A research-agent session writes a markdown article and publishes it to your blog
- A daily cron drops a digest post compiled from external sources
- A webhook from a third-party tool (Zapier, n8n) creates documents on demand
If you are a human editing through webhouse.app/admin, you don't need this — just use the editor.
The contract in one paragraph
POST your document JSON to /api/cms/{collection}?site={siteId} with an Authorization: Bearer wh_... header carrying a token scoped content:write for that site. The body needs only four fields (slug, status, data, optionally locale); the server fills in id, _fieldMeta, updatedAt, and audit metadata. Use status: "published" — drafts do not trigger the live-site revalidation webhook.
Step 1 — Create an access token
In webhouse.app/admin:
- Click your avatar → Account Preferences → Access tokens → New token
- Set scope to
content:writeand resource to the specific site (site:trail, notsite:*) - Optionally restrict to a single collection (
postsonly) for least-privilege - Copy the generated
wh_...string — it is shown once only
For cross-session use (e.g. handing the token to a different cc session), the user owning the token must paste it into the recipient's environment. There is no secure cross-session sharing primitive yet.
Step 2 — POST the document
curl -X POST 'https://webhouse.app/api/cms/posts?site=trail' \
-H 'Authorization: Bearer wh_xxx' \
-H 'Content-Type: application/json' \
-d '{
"slug": "three-architectures-of-agent-memory",
"status": "published",
"data": {
"title": "Three architectures of agent memory — and why Trail picked Compile",
"excerpt": "RAG, fine-tuning, and compile-time integration each solve a different memory problem. Here is what each gives up — and why Trail spent a year building the third.",
"content": "## Section heading\n\nMarkdown body…\n\n{{svg:scan-wall-curve}}\n\nMore prose…",
"date": "2026-05-03",
"author": "Trail Team",
"category": "research",
"tags": ["rag", "llm-wiki", "agent-memory"],
"readTime": "9 min read"
}
}'Response on success (HTTP 201):
{
"id": "three-architectures-of-agent-memory",
"slug": "three-architectures-of-agent-memory",
"status": "published",
"locale": "en",
"data": { /* echoed back */ },
"updatedAt": "2026-05-03T15:42:11.000Z",
"_fieldMeta": {}
}What the body fields mean
| Field | Required | Notes |
|---|---|---|
slug | yes | URL-safe (a-z0-9-), unique within the collection. Becomes the URL fragment after urlPrefix. |
status | yes | "published" or "draft". Use "published" unless you specifically want to stage drafts — draft documents do not fire the revalidation webhook, so the live site stays stale. |
data | yes | Object matching the collection's field schema. Field names must match cms.config.ts exactly. Unknown fields are stripped silently. |
locale | no | Defaults to config.defaultLocale. Set explicitly if the site is multilingual and you are creating a non-default-locale variant. |
translationGroup | no | UUID linking translations of the same document. Required for multilingual sites — use the SAME UUID for all language variants. Generate with crypto.randomUUID(). |
What happens after the POST
- The document JSON is written to
_data/sites/{siteId}/content/{collection}/{slug}.jsonon the cms-admin server (or to the GitHub repo for github-adapter sites) - cms-admin saves a revision under
_revisions/ - If the site has
revalidateUrlconfigured, the revalidation webhook fires — Next.js sites callrevalidatePath()for the affected route within ~2 seconds - If the site uses GitHub Pages and
deployOnSave: true, the rocket pipeline runs: build.ts produces the static output, the diffed files upload to thegh-pagesbranch, GitHub Pages publishes, and thepage_buildwebhook fires back to cms-admin - The admin UI shows a toast and (if Web Push is enabled) a native OS notification
For a clean GH-Pages site like trail-landing, end-to-end POST → live URL is typically 30–90 seconds.
Multilingual posts
For a site with da and en locales, create one document per language with a SHARED translationGroup:
UUID=$(node -e 'console.log(crypto.randomUUID())')
# English variant
curl -X POST '.../api/cms/posts?site=trail' -d "{
\"slug\": \"my-post\",
\"locale\": \"en\",
\"translationGroup\": \"$UUID\",
\"data\": { /* English content */ }
}"
# Danish variant — SAME UUID
curl -X POST '.../api/cms/posts?site=trail' -d "{
\"slug\": \"mit-indlaeg\",
\"locale\": \"da\",
\"translationGroup\": \"$UUID\",
\"data\": { /* Danish content */ }
}"Without shared translationGroup, the language switcher, hreflang tags, and side-by-side translation editor all break.
Updating an existing document
Use PATCH for partial update or PUT for full replace:
curl -X PATCH 'https://webhouse.app/api/cms/posts/{slug}?site=trail' \
-H 'Authorization: Bearer wh_xxx' \
-H 'Content-Type: application/json' \
-d '{"data": {"title": "Updated title"}}'PATCH merges the supplied data keys with the existing document. Other top-level fields (status, locale) update independently.
SVG and other build-time assets
The Content API does NOT handle build-time assets like {{svg:...}} placeholders that a custom build.ts resolves at compile time. Those live in the site's source repository, not the CMS:
- For a GitHub-backed site: open a PR adding
apps/landing/public/uploads/svg/<slug>.svgplus any build.ts changes that wire up new slugs - For a CMS-uploaded image (used in a richtext editor or
imagefield): usePOST /api/mediawith multipart form-data
The distinction: if the asset is referenced in your build.ts at compile time, it belongs in the site source. If it is rendered into editor content, it belongs in CMS media.
Common mistakes
- POST without
?site=...— token-based callers MUST always include?site=<siteId>on the URL. The server falls back to the registry default site when omitted, which silently writes to the WRONG site. The token's site-scope claim is checked against?site=, not against fallbacks. - Status
"draft"— silently does not appear on the live site because revalidation does not fire. Use"published". - Field names with typos — unknown fields are silently dropped. The document is created but missing data. Run
GET /api/cms/{collection}/{slug}to verify the round-trip. - Multilingual without
translationGroup— orphans the document from its translation group. Side-by-side editor and language switcher break. - Reusing an existing slug — POST returns 409. Use PATCH instead, or pick a unique slug.
Verifying success
# Did it land?
curl 'https://webhouse.app/api/cms/posts/{slug}?site=trail' \
-H 'Authorization: Bearer wh_xxx'
# Is it live on the deployed site?
curl -I 'https://www.your-site.com/posts/{slug}/'The second should return HTTP/2 200. If it returns 404, the build/deploy pipeline did not fire — check _data/sites/{siteId}/deploy-log.json for the most recent entry.
See also
- Headless Site API & Chat Embedding — the full REST reference and chat-embed flow.
- Content API & ContentService — the in-process programmatic API used by build.ts.
- Storage adapters — where the JSON physically lives (filesystem, GitHub, SQLite).