Content operations
content.svc reads its content plane, which is its content types, content apps and content stores, from the
product’s content/ folder in meta. It reads its storage locations
from the tenant’s own configuration.
The content folder
Section titled “The content folder”content/ types/ blog.post.yaml # a content type: an entities: document blog.author.yaml apps/ blog/ app.yaml # a content app stores/ stores.yaml # the content stores extend/ # extensions to the block's own entities, never content types| Path | Holds |
|---|---|
content/types/<type>.yaml | Content types, each an entities: document with nothing around it. See The content model |
content/apps/<app>/app.yaml | One content app, in a folder named for it |
content/stores/stores.yaml | The contentstores: list |
content/extend/ | Extensions a product makes to the block’s own entities (asset, folder, tag), such as an added field |
Every file is read as it is. None of them names a datastore: the Data block
declares datastores once, in data/datastores/, and content types land in the one declared for
this block.
Only .yaml files are read. Problems in the folder are reported when the content plane loads,
rather than surfacing later as a 404:
- an app that names a type
content/types/does not declare - an app folder without an
app.yaml - a
.ymlfile - content in the folders of the earlier layout,
content/contentapps/andcontent/contentstores/, which are not read
To move a product from the earlier layout:
- each
contentapps/<app>/contentapp.yamlbecomesapps/<app>/app.yaml - the types under the app’s
contenttypes/folder move totypes/ contentstores/contentstores.yamlbecomesstores/stores.yaml
Starting from a profile
Section titled “Starting from a profile”The binary carries its bootstrap profiles, so a working file is one command away rather than something to assemble from this page.
content config list # the profiles availablecontent config generate bootstrap-dev # print onecontent config generate bootstrap-dev > .content.yamlcontent config generate bootstrap-full -d /etc/content/ # write <dir>/<name>.yamlcontent config generate all -d /etc/content/ # write every profileThe profiles span the integration axis: local development against a product tree on disk, fed
from the product’s meta, driven by a config server, vault-backed, and the full production target.
One generated file drives the server and the data verbs through -f.
Generating all requires -d/--dir; there is no stdout form for the whole set.
Local development
Section titled “Local development”bootstrap-dev reads the content plane the way a deployment does, through the tenant’s meta
client, pointed at a local copy of the product instead of a meta server:
hives: tenant: - path: "dev:dev:dev:dev" meta: localdir: ./metalocaldir is the product’s own checkout, read exactly as meta serves it — by the same paths,
with .git left out:
meta/content/types/blog.post.yamlmeta/content/apps/blog/app.yamlmeta/content/stores/stores.yamlA folder holding several products works too, as <localdir>/products/<product>/ (or the older
<localdir>/products/<product>/file/); a product such a folder does not hold is not found.
content config generate bootstrap-dev > .content.yamlcontent data bootstrap -f .content.yaml # once: the tables, content types includedcontent -f .content.yamlChoosing the source
Section titled “Choosing the source”With no contentsource: block, the content plane comes from the tenant’s meta, meta.url or
meta.localdir. That is the default, and the one to deploy. The other sources are for local work
and must be named:
contentsource: type: localdir path: ./product # a product checkout, content/ at its roottype | Content plane from |
|---|---|
(no block) or meta | The tenant’s meta |
localdir | A product checkout on disk, at path |
inline | contentstores: and contentapps: blocks in the boot file. These hold stores and apps only. A content type is a file, so an inline plane serves no content types |
Content stores
Section titled “Content stores”A store says what kind of store it is. The tenant says where its bytes live.
The stores are one list, in the product’s content/stores/stores.yaml:
contentstores: - name: default type: native active: true folderentityname: folder assetentityname: asset cacheexpiry: 1h version: enabled: true access: createasset: { language: expr, rule: 'true' } downloadasset: { language: expr, rule: 'env.asset.meta.quarantined != true' } # a rule per operation; see Assets| Key | Meaning |
|---|---|
name | How the store is addressed: /store/<name>/asset |
active | Serve this store |
cacheexpiry | The store’s Cache-Control policy |
version.enabled | Keep versions on re-upload |
locale | Require a locale on every asset operation |
index | database by default, or fs for a store with no database |
access | A rule per operation. See Assets |
Backends
Section titled “Backends”The bytes live where the tenant’s contentstores block says.
tenant: - path: "acme:prod:site:acme" contentstores: - name: default contentroot: type: os path: /var/lib/content/default
- name: objects contentroot: type: s3 url: s3.eu-west-1.amazonaws.com # host:port; the scheme comes from usessl accesskey: … secretkey: … usessl: true path: acme-content # the bucketThe bucket must already exist. An object store has no notion of creating a container on first write.
An fs-only store uses the same contentroot shape. What makes it database-free is index: fs on
the store declaration.
Content apps
Section titled “Content apps”A content app groups the content types a front end reads, names the store its media lives in, and holds the preview secret and the webhook subscriptions.
One file per app, in a folder named for it, content/apps/blog/app.yaml:
name: blogactive: truecontentstore: defaultcontenttypes: - blog.post - blog.authorlocale: default: en supported: [en, de]version: enabled: truepreview: secret: ${CONTENT_PREVIEW_SECRET}webhooks: - name: rebuild-site url: https://build.example.com/hook events: [AfterContentCreateSuccess, AfterContentUpdateSuccess, AfterContentDeleteSuccess] secret: ${CONTENT_WEBHOOK_SECRET} timeout: 5s active: truecontenttypes: records which types belong to the app. It does not declare them: the types are the
files in content/types/. A type may be named in full (blog.post) or by its last segment
(post), and a name the product does not declare is reported when the content plane loads.
Content authorization lives on the content types themselves, not on the app. See The content model.
Default actions
Section titled “Default actions”defaultstoreaction: falsedefaultappaction: falseBoth false is the production posture: a store or app operation with no rule denies. Set them
deliberately, and prefer declaring the rules.
Preview secrets
Section titled “Preview secrets”Each app signs its own preview tokens with its own secret.
- Supply it from your secret store rather than checking it into the product tree.
- An app with no secret answers
501to a mint request, rather than signing with an empty key. - Rotating the secret invalidates every token issued under the old one, which takes effect within a token lifetime.
- Two apps should not share a secret. A token names the app that signed it and is held to it after verification, so distinct secrets keep the check meaningful.
Webhooks
Section titled “Webhooks”A subscription is how a site learns that content changed. Polling the delivery API to find out is slow and wasteful.
Events fire from the engine’s hook chain, after the write has landed and after cross-locale propagation, so a receiver that immediately re-fetches sees a document already in step. A write made through the Data block is announced too.
| Event | Fires on |
|---|---|
AfterContentCreateSuccess | A content entry was created |
AfterContentUpdateSuccess | A content entry was updated |
AfterContentDeleteSuccess | A content entry was deleted |
An empty events: list subscribes to every event.
The delivery
Section titled “The delivery”POST /your-hookContent-Type: application/jsonUser-Agent: kisai-hotei-webhook/1X-Hotei-Event: AfterContentUpdateSuccessX-Hotei-Tenant: acme:prod:site:acmeX-Hotei-Delivery-Name: rebuild-siteX-Hotei-Signature: sha256=9f86d081884c7d65…{ "event": "AfterContentUpdateSuccess", "tenant": "acme:prod:site:acme", "timestamp": "2026-09-15T09:14:02Z", "data": { "entity": "blog.post", "space": "blog", "type": "post", "event": "AfterContentUpdateSuccess", "documents": ["01J9Y…"] }}documents names what changed, so a receiver can rebuild those pages rather than the whole site.
Any headers: you declare on the subscription are added to the request.
Verifying the signature
Section titled “Verifying the signature”X-Hotei-Signature is HMAC-SHA256 over the raw body, prefixed with the algorithm. A webhook URL is
usually public-facing and is not itself a secret, so verify every delivery.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret) { const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'); const a = Buffer.from(header ?? ''), b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);}Compare against the raw body, before any JSON parse and re-serialise.
Delivery behaviour
Section titled “Delivery behaviour”| Property | Detail |
|---|---|
| Asynchronous | A slow or unavailable receiver never slows or fails an editor’s save |
| Bounded | The in-flight queue holds 1024 deliveries and logs what it drops |
| Concurrent | Four workers, so one slow receiver does not stall the others |
| Retried | Three attempts with exponential backoff, starting at 500 ms |
| Timeout | timeout: per subscription, defaulting to 10 s |
A 4xx other than 408 or 429 is taken as a deliberate refusal and is not retried.
Return 2xx quickly and do the work afterwards. A receiver that builds a site inside the request
will hit the timeout and be retried.
Set active: false to suspend a subscription while a receiver is down, rather than deleting it.
Tenants served
Section titled “Tenants served”loadtenants: - acme:prod:site:acmeThis list drives config warm-up and per-tenant setup: content stores, their databases and their root folders. Add a tenant here when you add its hive, so that its stores are prepared at boot.
Tenant hives are keyed by CEPT. Keep them keyed that way rather than at a root path, so a request carrying an unserved tenant key is refused rather than resolved.
What to watch
Section titled “What to watch”| Signal | Why it matters |
|---|---|
| Webhook drops and failures after every attempt | A wedged receiver, and sites serving stale content |
| Asset store growth | Uploads are frequent and deletions are rare; storage is the cost that creeps |
403 rate on delivery routes | Usually a content type published without the access rules its readers need |
| Folder sizes | Listing is unpaginated, so a large folder is a large response |
| Cache hit ratio on derivatives | A miss means the transform ran; a persistent miss means the URL vocabulary varies per caller |
| Convergence reports at engine build | A content-model change above the additive risk level waits for an operator |
| Content-plane warnings at load | An app naming a type the product does not declare, or content in folders that are not read |
| Publish-to-visible latency | Editors lose trust in a CMS that feels slow to update |
See also
Section titled “See also”- Assets: storage modes and store access rules
- Preview: what a preview secret protects
- Meta layout: where the
content/folder sits in the product - The content model: what goes in a content type file