Skip to content
Talk to our solutions team

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.

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
PathHolds
content/types/<type>.yamlContent types, each an entities: document with nothing around it. See The content model
content/apps/<app>/app.yamlOne content app, in a folder named for it
content/stores/stores.yamlThe 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 .yml file
  • content in the folders of the earlier layout, content/contentapps/ and content/contentstores/, which are not read

To move a product from the earlier layout:

  • each contentapps/<app>/contentapp.yaml becomes apps/<app>/app.yaml
  • the types under the app’s contenttypes/ folder move to types/
  • contentstores/contentstores.yaml becomes stores/stores.yaml

The binary carries its bootstrap profiles, so a working file is one command away rather than something to assemble from this page.

Terminal window
content config list # the profiles available
content config generate bootstrap-dev # print one
content config generate bootstrap-dev > .content.yaml
content config generate bootstrap-full -d /etc/content/ # write <dir>/<name>.yaml
content config generate all -d /etc/content/ # write every profile

The 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.

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: ./meta

localdir 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.yaml
meta/content/apps/blog/app.yaml
meta/content/stores/stores.yaml

A 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.

Terminal window
content config generate bootstrap-dev > .content.yaml
content data bootstrap -f .content.yaml # once: the tables, content types included
content -f .content.yaml

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 root
typeContent plane from
(no block) or metaThe tenant’s meta
localdirA product checkout on disk, at path
inlinecontentstores: 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

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
KeyMeaning
nameHow the store is addressed: /store/<name>/asset
activeServe this store
cacheexpiryThe store’s Cache-Control policy
version.enabledKeep versions on re-upload
localeRequire a locale on every asset operation
indexdatabase by default, or fs for a store with no database
accessA rule per operation. See Assets

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 bucket

The 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.

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: blog
active: true
contentstore: default
contenttypes:
- blog.post
- blog.author
locale:
default: en
supported: [en, de]
version:
enabled: true
preview:
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: true

contenttypes: 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.

defaultstoreaction: false
defaultappaction: false

Both false is the production posture: a store or app operation with no rule denies. Set them deliberately, and prefer declaring the rules.

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 501 to 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.

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.

EventFires on
AfterContentCreateSuccessA content entry was created
AfterContentUpdateSuccessA content entry was updated
AfterContentDeleteSuccessA content entry was deleted

An empty events: list subscribes to every event.

POST /your-hook
Content-Type: application/json
User-Agent: kisai-hotei-webhook/1
X-Hotei-Event: AfterContentUpdateSuccess
X-Hotei-Tenant: acme:prod:site:acme
X-Hotei-Delivery-Name: rebuild-site
X-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.

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.

PropertyDetail
AsynchronousA slow or unavailable receiver never slows or fails an editor’s save
BoundedThe in-flight queue holds 1024 deliveries and logs what it drops
ConcurrentFour workers, so one slow receiver does not stall the others
RetriedThree attempts with exponential backoff, starting at 500 ms
Timeouttimeout: 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.

loadtenants:
- acme:prod:site:acme

This 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.

SignalWhy it matters
Webhook drops and failures after every attemptA wedged receiver, and sites serving stale content
Asset store growthUploads are frequent and deletions are rare; storage is the cost that creeps
403 rate on delivery routesUsually a content type published without the access rules its readers need
Folder sizesListing is unpaginated, so a large folder is a large response
Cache hit ratio on derivativesA miss means the transform ran; a persistent miss means the URL vocabulary varies per caller
Convergence reports at engine buildA content-model change above the additive risk level waits for an operator
Content-plane warnings at loadAn app naming a type the product does not declare, or content in folders that are not read
Publish-to-visible latencyEditors lose trust in a CMS that feels slow to update