# Dee Wan CMS documentation: full text > What Dee Wan is, how the live CMS API differs from a standalone backend you own, and where to start reading the documentation. Documentation revision `903447f1d74348b67db0dbc34302c9553f0de5fb23acaf757e8fc2e89383efe4`. Every public English page, in navigation order. Each page starts on its own line with ``. # Dee Wan CMS documentation Source: https://dee-wan-docs.pages.dev/ Dee Wan is a multilingual, multi-site headless CMS in your own Cloudflare account. ## Live CMS or a backend you own Generate a standalone backend you own: Prisma schema, migrations, routes, database and deployment. Its API runs independently; website blog, search, comments and forms [remain connected](/detachment-contract/#what-still-depends-on-dee-wan). The CMS public API reflects published changes immediately. Generated APIs and static pages use a snapshot; regenerate and deploy to update them. A standalone backend is optional. See the [standalone overview](/guides/standalone-backend/) or [Dee Wan and Strapi](/reference/strapi-comparison/). ![The seeded Dee Wan dashboard](/assets/screenshots/dashboard.webp) ## Getting started [Install Dee Wan](/guides/installation/), [create the first administrator](/guides/first-administrator/), then follow the [first-site walkthrough](/guides/first-site/). ## Common tasks Editors use the [page builder](/guides/page-builder/), [Blog](/guides/blog/) and [content planner](/guides/content-planner/). Administrators define [models and fields](/reference/models-and-fields/) and manage [people and access](/guides/people-and-access/). Developers start with [preview and the TypeScript client](/guides/preview-and-client/) and the [public API](/reference/public-api/). Scripts and agents use the [management API](/reference/management-api/). Extend Dee Wan by [writing a plugin](/plugins/authoring/). Operators [import from Strapi or WordPress](/guides/import-migration/) and maintain [backups](/guides/backup-restore/). Feature guides cover [draft-body search](/guides/content-editing/#find-and-open-an-item), [editing from the rendered preview](/guides/preview-and-client/#edit-from-the-preview), [browser origins](/reference/public-api/#browser-origins), [generated application accounts](/reference/standalone-backend-api/#application-account-administration) and [selected-content refresh](/guides/import-migration/#refresh-selected-content). ## Navigate the CMS **Search** or **Cmd/Ctrl+K** opens permitted workspace and **Settings** destinations. In the page builder, the shortcut may open the section gallery; **Search** still opens navigation. This site's **Search** searches documentation. ## Maturity Dee Wan is pre-MVP, not released. Features are tested locally; complete acceptance on a real Cloudflare account remains pending. The [1.0 product boundary](/boundary/) identifies supported features, supervised pilots and unavailable capabilities. # Install Dee Wan Source: https://dee-wan-docs.pages.dev/guides/installation/ This guide installs Dee Wan CMS into your own Cloudflare account. An installation is the admin Worker, the public read Worker, the admin UI on Cloudflare Pages, one control D1 database, media storage, and the credentials behind them. It is created once, has one claim link, and serves many sites (see the [product vocabulary](/reference/vocabulary/)). To look around first without a Cloudflare account, use the [local trial](/guides/local-development/#try-it-locally-with-no-cloudflare-account) instead: it runs the whole product against a local database on your machine. You install with the command-line installer: `npm run setup` for an interactive run, and `npm run install:cms -- ` for planned, resumable and scriptable runs. It needs a Cloudflare API token. It keeps your answers in `backend/wrangler.jsonc` and its progress in a step ledger, and runs the list of steps that `npm run plan` prints. A browser-based installer also exists, but it needs a Cloudflare OAuth application you register yourself and is not a supported installation path. See [Web installer preview](/guides/web-installer-preview/). > Dee Wan 1.0 is pre-release. Installing against real Cloudflare is one of the acceptance gates that > remain open. See the [1.0 product boundary](/boundary/) for what is and is not > covered. GitHub credentials depend on the workflow. Configure the admin GitHub App separately from the CLI device flow. The installer’s optional App link does not configure the Worker. Use the [authentication matrix](/guides/github-authentication/), then follow [first site to published content](/guides/first-site/). ## Before you start ### What you need - A Cloudflare account with Workers, D1, Pages, and R2 **or** Cloudflare Images. - Node 22 (22.14 or later) or Node 24 (24.10 or later). The repository ships an `.nvmrc` (24.18.1), so `nvm use` selects the right version. - npm exactly as `package.json` declares it (11.16.0), which is the npm that the `.nvmrc` Node ships with. - Git, to clone the repository with its submodules. - Optionally, your own domain. To use it, the domain must be a zone in the same Cloudflare account. Without one, the installation uses the addresses Cloudflare provides (`pages.dev` and `workers.dev`). - Optionally, an inference provider key for AI features. DeepInfra is the default; an OpenAI-compatible provider can be configured after installation. Cloudflare Access (Zero Trust) is not required. The installer offers it as an extra perimeter in front of the admin, and it is off unless you turn it on. ### Get the code and install dependencies ```bash git clone --recurse-submodules https://github.com/dee-wan-cms/dee-wan.git cd dee-wan node scripts/install-dependencies.mjs node scripts/install-dependencies.mjs frontend ``` The first dependency-install command installs the root and backend dependencies. The second installs the admin UI dependencies, which the installer builds and deploys. Always install with `node scripts/install-dependencies.mjs`, never with a bare `npm install` or `npm ci`. It refuses any other npm version, installs the locked dependency tree with install scripts disabled, and then rebuilds only the packages whose scripts are approved. A fresh clone needs no manual build step: `setup`, `deploy` and `migrate:d1` build the packages and the Prisma client if they are missing. ### What gets created These are the steps the installer runs, in order. `npm run plan` prints the same list for your configuration, with the state of each step. | Step | What it creates or does | | --- | --- | | Check Node and Wrangler | Checks only. | | Resolve the Cloudflare API token | Checks only. | | Choose the Cloudflare account | Checks only. | | Preflight — token scopes, plan, existing resources | Checks only. Stops the run before anything is created if a check fails. | | Create the control D1 database | A D1 database (default name `dee-wan-cms-db`). | | Apply migrations to D1 | The schema, and the seeded roles and workflow. | | Provision the private import archive bucket | A private R2 bucket for import archives. The installer creates it only when you choose it. | | Connect the initial site to its hostname | A public hostname for the initial site. | | Build the admin UI | Builds only. | | Deploy the admin UI to Pages | A Pages project (default name `dee-wan-cms`). | | Deploy the admin Worker | A Worker (default name `dee-wan-cms-backend`). | | Generate and store the session signing secret | A Worker secret. | | Provision media storage | An R2 media bucket, or a Cloudflare Images binding. | | Deploy the public API | The public read Worker (named after the admin Worker, with `-public` appended) and its hostname. | | Deploy the website form intake | The form intake Worker (named after the admin Worker, with `-intake` appended). Skipped when `DEPLOY_FORM_INTAKE` is `false`; website forms then take no submissions. | | Generate and store the site-token signing key | A Worker secret on the admin and public Workers. | | Attach the Worker routes | Two Worker routes on your zone: `/api/*` and `/.well-known/oauth-*`. The installer creates them only with your own domain. Teardown removes them only while they still point at this Worker. | | Point DNS at the deployment | DNS records under your domain. The installer creates them only with your own domain and DNS setup turned on. | | Put Cloudflare Access in front of the admin | An Access application and policy. The installer creates them only when you turn Access on. | | Check the deployment answers | Checks only. | | Mint the first administrator's claim link | A single-use link, shown once. | Resources such as the Workers, the Pages project, the D1 database and the R2 buckets are billable Cloudflare resources in the account you choose. With your own domain, the admin UI is served at `cms.` and the public API at `content.` by default. Without one, the admin UI is served at `.pages.dev` and the public API at `-public..workers.dev`. ## Command-line installer ### Credentials The installer needs a Cloudflare API token. `npm run setup` prompts for one if none is set, prints the permissions it needs with a reason for each, and gives a link to Cloudflare's token editor with those permissions selected. Include all accounts and zones. | Permission | Needed for | Why | | --- | --- | --- | | Account · Account Settings · Read | Every install | Read the account name and settings. | | Account · D1 · Edit | Every install | Create the database and apply migrations. | | Account · Workers Scripts · Edit | Every install | Deploy the admin, public-read and form-intake Workers, their secrets and custom domains. | | Account · Cloudflare Pages · Edit | Unless the admin UI deploy is turned off | Create and deploy the admin UI project. | | Account · Workers R2 Storage · Edit | Media in R2, or the import bucket | Create the media and import buckets. | | Zone · Zone · Read | Your own domain | Find the zone your domain belongs to. | | Zone · Workers Routes · Edit | Your own domain | Route the admin and site hostnames to the Workers. | | Zone · DNS · Edit | Your own domain with DNS setup on | Point the admin hostname at the deployment. | | Zone · Zone WAF · Edit | Your own domain (optional) | Install the sign-in rate-limit rule and the optional public API rule. Without it both are skipped with a warning; the Worker's own sign-in limiter still applies. | | Account · Access: Apps and Policies · Edit | Cloudflare Access only | Create the Access application and policy in front of the admin. | | Account · Access: Organizations, Identity Providers, and Groups · Read | Cloudflare Access only | Read your Access team domain. | `npm run setup` and `npm run doctor` print this list for the choices already in your config. The Access rows are requested only once you turn Access on. Any other choice you have not made yet is requested and marked with its condition, and a choice you turned off is not requested. The pre-filled token link selects the same rows, except Zone · Zone WAF · Edit, which the printed list tells you to add by hand. Verifying the token needs no extra permission. If you use Cloudflare Images, `setup` recommends a separate token with only the Cloudflare Images: Edit permission. To skip the prompts, set the token and account in the environment: ```bash export CLOUDFLARE_API_TOKEN=… # skips the token prompt export CLOUDFLARE_ACCOUNT_ID=… # skips the account picker ``` Credentials are resolved in this order: a variable exported in your shell, then the repository's `.env` file, then values in `backend/wrangler.jsonc`. When the environment overrides a different value in the config, the installer says so. The token is checked before anything is created. An interactive run allows three attempts. A non-interactive run stops at the first failure. Either way, a token that cannot be verified means no Cloudflare resources are created. Application secrets are never stored in `wrangler.jsonc`. See [Secrets](/guides/local-development/#secrets). ### Interactive install: `npm run setup` ```bash npm run setup ``` `setup` walks through the installation and saves each answer to `backend/wrangler.jsonc` as it goes, so a second run does not ask again. In order, it: 1. Finds or creates `backend/wrangler.jsonc` and resolves the token and account. If the token reaches several accounts, it asks which one to use. 2. Asks which D1 plan the account uses. Free allows 500 MB per database. Paid allows 10 GB per database. 3. Runs preflight and prints a report. If a check fails, it stops before creating anything ("Setup stopped before creating any Cloudflare resources. Fix the FAIL rows above."). If resources will be created or modified, it asks you to confirm. 4. Asks where the installation will live (your own domain, or the addresses Cloudflare provides), then the domain, the admin subdomain (default `cms`) and the public content API subdomain (default `content`). 5. Asks whether to set up DNS, whether to set up Cloudflare Access (default no), and whether to install a rate-limiting rule on the public API (default no). 6. Asks for the Pages project name and the Worker name. 7. Asks for the storage provider, **Cloudflare R2** or **Cloudflare Images**. For R2 it creates the bucket (default `dee-wan-cms-images`), enables public access through the managed `r2.dev` address or a custom media domain, and checks that uploaded files are actually served. 8. Asks whether to enable data import, which creates a private R2 bucket for uploaded archives (default no). 9. Creates the D1 database and offers to apply the schema (default yes). A fresh install needs no separate migrate step. 10. Builds and deploys the admin UI, the admin Worker, the public read Worker and the form intake Worker, stores the Worker secrets, and attaches the route, DNS records and Access application you chose. It writes `backend/wrangler.public.jsonc` and `backend/wrangler.intake.jsonc` once the account, zone and database id are settled, and never overwrites an existing one. 11. Offers to run health checks. 12. Asks for the first administrator's email, if `BOOTSTRAP_ADMIN_EMAIL` is not already set, and prints the claim link. It ends with "Setup complete!", a configuration summary and the next steps. If a required part is missing, it ends with "Setup did not complete" and names what is missing instead. `setup` also accepts `--access` / `--no-access` and `--public-rate-limit` / `--no-public-rate-limit` to answer those questions on the command line, for example `npm run setup -- --no-access`. ### Plan first: `npm run plan` ```bash npm run plan npm run plan -- --json ``` `plan` prints every step an installation would perform and the state each one is already in. It works only from the configuration and ledger on disk: it touches no network, creates nothing and costs nothing. `--json` prints a machine-readable plan with a `planVersion`. Each step has one of these states: | Mark | State | Meaning | | --- | --- | --- | | `✓` | done | Recorded as completed. | | `•` | to do | Will run. | | `–` | not applicable | Skipped for this configuration, with the reason. | | `✗` | blocked | Cannot run yet, for example because no Cloudflare API token was found. | | `?` | unresolved | A previous run asked for this and never recorded an answer, or it failed. `apply` checks the account before repeating it, and refuses rather than guesses. | | `↻` | re-checked | A check that runs on every apply. | ### The installer verbs: `npm run install:cms` ```bash npm run install:cms -- [options] ``` | Verb | What it does | Changes the account | | --- | --- | --- | | `plan` | Says what an install would do. The same as `npm run plan`. | No | | `apply` | Runs the install. | Yes, creates billable resources | | `resume` | Continues an install that stopped part-way. Refuses if no step has been completed yet. | Yes, creates billable resources | | `doctor` | Preflight checks. Changes nothing. | No | | `verify` | Drives a deployment end to end. Never point it at production. Add `--read-only` there. | Creates a site, a model and content, unless `--read-only` | | `teardown` | Removes what the install created. A dry run unless `--apply` is passed. | Only with `--apply` | | `manifest` | Prints a redacted record of the install (account, resource names, step history) for handing it over. It never contains a credential. | No | `apply` and `resume` run the same engine as `npm run setup`. The step ledger is what makes a second run continue rather than repeat: each completed step is recorded in a ledger file in the repository root, one file per Cloudflare account (`.dee-wan-cms-install..json`). A ledger file that cannot be read completely stops the run instead of being ignored. Options: | Option | Meaning | | --- | --- | | `--config ` | Which installation to act on. Default `wrangler.jsonc`. A bare name is looked up in `backend/`. | | `--account ` | The Cloudflare account to target, explicitly. Every step the command runs targets that account. | | `--json` | Machine-readable output. Implies `--yes`. | | `--yes`, `-y` | Never prompt. Fail instead of asking. | `verify` also forwards `--read-only`, `--public`, `--admin`, `--email`, `--model` and `--disposable`, and `teardown` forwards `--apply` and `--yes`. Any other option, a repeated option, or an option given with an empty value (such as `--account=` from an unset shell variable) is refused, not ignored. `apply`, `resume`, `teardown` and `verify` (unless `--read-only`) need an account. They take it from `--account`, then `CLOUDFLARE_ACCOUNT_ID`, then `account_id` in the config, and refuse if none is named. ### Exit codes `install:cms` exits with a fixed code, so a script can branch on the result: | Code | Name | Meaning | What to do | | --- | --- | --- | --- | | 0 | ok | The install is complete. | Nothing. | | 1 | usage | The command line was wrong, or `resume` found nothing to resume. | Fix the command. | | 2 | blocked | Something only you can supply is missing, such as a token or an account. | Supply it, then run again. | | 3 | failed | The run failed. | Read the output and fix the cause before repeating. | | 4 | incomplete | The run did not fail, but work remains. | Safe to retry as it is, usually with `resume`. | | 5 | unresolved | A step was started and its outcome is unknown. | Look at the account before repeating anything. | `plan` uses the same codes to describe the current state: 2 when a step is blocked, 5 when one is unresolved, 4 when work is left, and 0 when everything is done. ### Running unattended With `--yes`, `--json`, `DEEWAN_YES=1` or `CI=true`, every prompt takes its default. A question with no safe default is an error that names the setting to add, instead of a guess: - Where the installation lives: set `"DOMAIN_MODE": "custom"` with `"DOMAIN"`, or `"DOMAIN_MODE": "cloudflare"`, under `vars` in `backend/wrangler.jsonc`. - The D1 plan: set `"D1_PLAN": "free"` or `"paid"` under `vars`, or export `D1_PLAN`. - The account, when the token reaches several: set `account_id` in the config or export `CLOUDFLARE_ACCOUNT_ID`. - The first administrator: set `BOOTSTRAP_ADMIN_EMAIL` under `vars`. An unattended run does not ask for it, and without it no claim link can be minted. For example: ```bash npm run install:cms -- apply --account --json ``` ### Sharing a Cloudflare account The default resource names (`dee-wan-cms-backend`, `dee-wan-cms-db` and so on) are fixed, so two installations in one Cloudflare account collide. Set `DEEWAN_RESOURCE_PREFIX` to namespace every resource a run provisions: ```bash DEEWAN_RESOURCE_PREFIX=test-a1b2 npm run setup # creates test-a1b2-dee-wan-cms-db, … ``` The prefix must contain only lowercase letters, digits and dashes, start with a letter or digit, and be at most 24 characters long. Set it for a fresh installation, not an existing one. Wrangler finds the D1 database by the name in `backend/wrangler.jsonc`, so the prefix only works once `setup` has written the prefixed names into that file. See [Running the installer against a shared account](/guides/local-development/#running-the-installer-against-a-shared-account). Teardown does not decide by name. It deletes only what the install record says this installation created. See [Starting over or tearing down](#starting-over-or-tearing-down). ## After installing ### Claim the installation Nobody has an account until the installation is claimed. `npm run setup` prints a single-use claim link once, with the email address it is bound to and when it expires. It expires after 15 minutes. Only its hash is stored, so it cannot be printed again. Open it and create the first administrator as described in [Create and replace the first administrator](/guides/first-administrator/#claim-the-installation). If the link expires before anyone uses it, run the installer again (`npm run setup` or `npm run install:cms -- resume`). An expired claim link counts as not done, so the run mints a new one as long as the installation has not been claimed. `npm run claim:link` can also replace it. See [Create and replace the first administrator](/guides/first-administrator/#claim-the-installation). ### Verify it works - `npm run health` runs post-deploy checks against the deployed addresses. With Access enabled, it also checks the perimeter, using the service token in `CF_ACCESS_CLIENT_ID` and `CF_ACCESS_CLIENT_SECRET`. - `npm run verify -- --read-only --public ` checks a deployment without writing anything. Add `--model ` to read one published model. - `npm run verify -- --admin --public --email
--disposable`, with `VERIFY_PASSWORD` in the environment, creates a site, a model and content. Use it only on a disposable deployment: the admin API can deactivate a site but not delete it. - `npm run doctor` is a read-only preflight that names anything missing and how to fix it. - `npm run plan` should report nothing left to do. ### Add secrets for optional features Secrets are set with `wrangler secret put` from the `backend/` directory, never in a file. For example, the AI features need a DeepInfra key: ```bash npx wrangler secret put DEEPINFRA_API_KEY ``` For an OpenAI-compatible provider, set `AI_PROVIDER="openai-compatible"`, `AI_BASE_URL` and `AI_TEXT_MODEL` as Worker variables, then add `AI_API_KEY` with `wrangler secret put`. Optional `AI_VISION_MODEL` and `AI_IMAGE_MODEL` enable those capabilities; deployment-owned model lists use `AI_TEXT_MODELS`, `AI_VISION_MODELS` and `AI_IMAGE_MODELS`. See [Inference provider](/guides/local-development/#inference-provider). See [Secrets](/guides/local-development/#secrets) for every optional secret and setting: mail, the GitHub App for repository delivery, cache purge, AI limits and error reporting. Large media uploads: the admin streams documents, audio, video and other files up to 100 MB, the request body limit of Cloudflare's Free and Pro plans. Business allows 200 MB and Enterprise up to 5 GB ([Workers limits](https://developers.cloudflare.com/workers/platform/limits/), read 2026-10-12). Set it only if your plan allows more: `"MEDIA_REQUEST_BODY_LIMIT_BYTES": "200000000"` under `vars`. Values above 5 GB are capped at 5 GB. Settings → Site → Media files and the upload dialog show the current limit. Images and every whole-file path (management API, plugins, legacy buffered imports) stay at 32 MiB. Chunked native site/history transfers carry larger accepted files; see [Import and migration](/guides/import-migration/). ### Deploy changes The installer deploys everything once. After you change code or configuration, deploy again: ```bash npm run deploy # admin UI, admin Worker, public read Worker and form intake Worker npm run health ``` `deploy` refuses to run without `backend/wrangler.public.jsonc`. An installation that deliberately serves no public content deploys with `npm run deploy -- --admin-only`. See [Deploy](/guides/local-development/#deploy). `deploy` also ships `backend/wrangler.intake.jsonc` when it exists. Without it, or with `--no-form-intake`, it deploys no intake Worker and says that website forms take no submissions. With a non-default admin config, name the intake config with `--intake-config `. Website forms post to `/v1/interactions/form.submit` on the site's own host. A route `/v1/interactions/*` sends those posts to the intake Worker; it is more specific than the public API's `/v1/*`. Managed delivery attaches it to each site host when the deployment service runs with `MANAGED_RENDER_INTAKE_WORKER_SCRIPT` set to the intake Worker's name. For any other site host, add that route to the intake Worker yourself. `npm run provision:site` binds each site's database to the intake Worker too, when `backend/wrangler.intake.jsonc` exists. ## Upgrading To move an installation to a newer release, from the repository root: ```bash git pull npm run submodules:sync # update packages/ to the new pins node scripts/install-dependencies.mjs # reinstall root and backend dependencies node scripts/install-dependencies.mjs frontend # reinstall admin UI dependencies npm run build:packages && npm run prisma:generate # rebuild the vendored packages and Prisma client npm run migrate:d1 # apply new migrations to the control database npm run tenant:upgrade # only if you used npm run provision:site npm run deploy npm run health ``` - The rebuild step is needed because `setup`, `deploy` and `migrate:d1` build the packages only when they are missing, not when they are out of date. - `migrate:d1` prints the config and database it is about to change before it runs. Add `-- --config ` for an installation that is not `backend/wrangler.jsonc`, and pass the same `--config` (with `--public-config`) to `deploy`. - `tenant:upgrade` brings existing per-site databases forward. Run `npm run tenant:upgrade -- --plan` first to see what each site is missing without writing anything. - `deploy` refuses to ship the admin Worker while the control database has pending migrations, or when it cannot tell, so running it before `migrate:d1` stops rather than deploying new code over an old schema. `--skip-migration-check` overrides this. Use it only when you know the new code does not need the pending migrations. See [Database migrations](/guides/database-migrations/) for what the migration commands do and how to recover from a failed one. ## Starting over or tearing down `npm run teardown` deletes what setup created for an installation. Without `--apply` it is a dry run: it lists every resource it would delete and every resource it will keep, with the reason, and changes nothing. ```bash npm run teardown # dry run of the default installation npm run teardown -- --apply # delete it ``` ### Which installation Without options, teardown acts on the default installation: `backend/wrangler.jsonc`, with `backend/wrangler.public.jsonc` and `backend/wrangler.intake.jsonc`. For another installation, name its config files: ```bash npm run teardown -- --config --public-config --intake-config ``` A companion config you do not name is treated as absent. Teardown never borrows the default installation's companion files. ### What it removes - the admin, public-read and form-intake Workers, with their routes and custom domains. - the Pages project. - the D1 databases. - the R2 buckets. Each is emptied, then deleted. - the Access application. - the DNS records and rate-limit rules setup created. - the managed renderers recorded for the selected sites. ### What it keeps Setup writes an install record, `.dee-wan-cms-install..json` in the repository root. It is the same file as the step ledger. Teardown deletes only resources that the record says this installation created and that still match the identity recorded for them. It lists and keeps: - resources setup found already in place and adopted, including R2 buckets. - resources that changed since setup recorded them. - resources setup did not confirm creating, for example after an interrupted run. Running setup again confirms them. - resources it cannot read. Teardown deletes nothing until they answer. - the D1 databases and R2 buckets while any Worker of the installation stays deployed. Teardown never deletes your local config files. ### Deleting With `--apply`, you choose whether to export each D1 database to a local `.sql` file first. Then teardown names the Cloudflare account and asks yes or no. The answer defaults to no. Unattended, pass `--yes`, plus `--export-dir ` or `--skip-export`. Without `--yes`, an unattended run answers no and deletes nothing. Teardown deletes in this order: routes, custom domains, DNS records and the Access application first, then the Workers and the Pages project, then the R2 buckets and D1 databases. It confirms each deletion by reading the resource back. If something fails, the install record stays and teardown lists what remains. Fix the cause and run it again. The record is removed only when everything it lists is gone. An install record written by an older installer version is refused with a message. Move that file aside before running teardown. `npm run install:cms -- teardown` runs the same script for the chosen config and account. It forwards `--apply`, `--yes`, `--public-config`, `--intake-config`, `--export-dir` and `--skip-export`. ## Troubleshooting **Start with `npm run doctor` and `npm run plan`.** `doctor` names what is missing and how to fix it. `plan` shows which step is done, left, blocked or unresolved, without touching the network. **Building the admin UI fails because a package or `vite` cannot be found.** The admin UI's dependencies are not installed. Run `node scripts/install-dependencies.mjs frontend`. **The installer says `DOMAIN` is set but `DOMAIN_MODE` is not.** A domain without a mode is refused, not read as a custom domain. Under `vars` in `backend/wrangler.jsonc`, set `"DOMAIN_MODE": "custom"` to use that domain, or set `"DOMAIN_MODE": "cloudflare"` and remove `DOMAIN`. Then run again. Nothing was created. **A run stopped part-way.** Resume it with `npm run install:cms -- resume`. Completed steps are not repeated. `resume` refuses when nothing has been completed yet. Use `apply` then. **Exit code 4 (incomplete).** The run did not fail, and it is safe to retry without changes. Read the output for what was left, usually something to fix at Cloudflare, then resume. **Exit code 3 (failed).** Read the output, fix the cause, then run again. **Exit code 5, or a step marked `?` (unresolved).** A previous run asked Cloudflare for something and never recorded the answer, or it failed. The installer checks the account before repeating the step, and refuses if it cannot tell. Look at the named resource in your Cloudflare account, resolve it, then run again. **Preflight fails.** `setup` stops before creating anything. Fix the rows marked FAIL in the report and run again. **The token cannot be verified.** Check its permissions, its expiry, and that it covers the account. Nothing is created until verification succeeds. **"the API token lacks Zone → WAF → Edit".** A rate-limit rule was skipped. The install continues without it. Add the permission and run `setup` again if you want the rule. **An unattended run stops with a missing answer.** The message names the setting to add to `backend/wrangler.jsonc` (or the environment variable to export). Add it and run again. See [Running unattended](#running-unattended). **"Setup did not complete: this deployment has no way in."** No claim link could be minted, or the session signing secret is not installed. Set `BOOTSTRAP_ADMIN_EMAIL` under `vars` in `backend/wrangler.jsonc` (exactly one address), fix any cause the output names, and run `setup` again. **`deploy` refuses with pending migrations.** Apply them with `npm run migrate:d1`, then deploy again. See [Upgrading](#upgrading). **The claim link expired.** Run the installer again to mint a new one. See [Claim the installation](#claim-the-installation). ## Next step Open the claim link and [set up the first administrator](/guides/first-administrator/). # Create and replace the first administrator Source: https://dee-wan-docs.pages.dev/guides/first-administrator/ Dee Wan has no public registration. A fresh installation has no account until someone consumes its one-time claim link. The installer binds that link to one normalized `BOOTSTRAP_ADMIN_EMAIL`. The claim page never accepts a different address. ## Configure the methods before deployment `BETTER_AUTH_SECRET` is required and is generated by the installer. Put provider credentials in Worker secrets, never in `vars`. Put the non-secret settings in `backend/wrangler.jsonc`. | Method | Required configuration | | --- | --- | | Password | `BETTER_AUTH_SECRET` | | Magic link | `BETTER_AUTH_SECRET`, secret `RESEND_API_KEY`, and var `MAIL_FROM` | | Google | `BETTER_AUTH_SECRET`, var `GOOGLE_CLIENT_ID`, and secret `GOOGLE_CLIENT_SECRET` | | GitHub | `BETTER_AUTH_SECRET`, var `GITHUB_CLIENT_ID`, and secret `GITHUB_CLIENT_SECRET` | | Passkey | `BETTER_AUTH_SECRET` and a valid `BACKEND_URL` (plus `FRONTEND_URL` when the admin UI has its own origin). It cannot claim the installation. | On an unclaimed installation, `AUTH_INITIAL_METHODS` is a comma-separated list such as `"password,magic_link,google,github"`. If it is absent, password is the only initial method. A listed method is still unavailable when one of its required bindings is missing. `passkey` is accepted only beside a method that can claim the installation: a passkey is added under Account security after the first sign-in. After the first administrator exists, a superadmin can change the registry on the **Installation** page, under **Sign-in methods**. **Installation** is in the sidebar for superadmins only. The API refuses to enable an unconfigured method or leave every active superadmin without a usable method. ## Claim the installation Open the installer claim link and choose an available method: - **Password** sets a 12–128 character password on the bound address. - **Magic link** sends only to the bound address. There is no email field on the claim page. - **Google** must return that exact verified email. - **GitHub** must return that exact primary, verified email. The email domain does not have to match the provider's brand. For example, GitHub sign-in may prove an address ending in `@google.com`. Only an exact match between the normalized email addresses matters. The claim and any verification challenge are single-use and expire no later than 15 minutes after the original claim was minted. Password, magic-link, Google and GitHub sign-in never create an account. A verified provider with an unknown email is refused. Only a site's Cloudflare Access rule can admit a new person at sign-in (see [Sign in through Cloudflare Access](/guides/people-and-access/#sign-in-through-cloudflare-access)). If the unclaimed link is lost or expires, mint a replacement for the configured address: ```bash npm run claim:link -- --remote --bootstrap --email owner@example.com ``` Without `--bootstrap`, the command resets the password in an emergency for the sole active superadmin. It does not create the first account. See [Password ownership and recovery](/guides/people-and-access/#password-ownership-and-recovery). ## One account, several methods Provider callbacks cannot change the account's canonical email. The signed-in owner can change it through Account security after verifying the new mailbox. A verified Google or GitHub identity with the same normalized email links to the existing account instead of creating another one. GitHub requires the matching address to be primary as well as verified. Provider access, refresh and ID tokens are not retained. When you first claim the installation with a password, a later magic-link sign-in keeps that original setup password. You can continue to use either method without setting the password again. ## Replace a superadmin Do not edit the old account's email. Use this sequence: 1. In **Settings → People**, under **Add person**, enter the replacement email, pick a role and use **Add**. - **The address already has an account**: it gets the role at once. If its row says it cannot sign in yet, use **Create link** and send that password link privately. A matching verified Google or GitHub identity can also link on sign-in. - **No account yet**: no account is created. An invitation is made instead (see [Add a person](/guides/people-and-access/#add-a-person)). The replacement opens the invitation link, types the address it was sent to, enters the emailed code when mail is configured, and chooses a password. Only then does the account exist. 2. Confirm the replacement is in the People list and its row says **Can sign in** (or **Cloudflare Access** where Access is set up). A pending invitation is not an account and cannot be promoted. 3. On that People row, use the shield button (**Make … a superadmin**) and confirm **Make superadmin**. An account with no usable sign-in method is refused. Existing sessions for the promoted account are revoked. 4. Sign in as the replacement account. Confirm **Installation** is in its sidebar. 5. On the **Installation** page, under **Superadmins**, deactivate the old account. Deactivation signs the old account out and preserves content and historical references. Reactivation requires a fresh sign-in. The API refuses self-deactivation and any change that would remove the last active, usable superadmin. ## Account security Every signed-in user can open **Account security** from their identity menu (`/account/security`), including users without a site grant. It covers your own account only: admin language, linked Google and GitHub connections, and your passkeys. Password setup, magic-link configuration and the deployment-wide sign-in methods are not on this screen. Keep a second way to sign in, or a second superadmin who can sign in, before you remove your last method. For lost devices, forgotten passwords and a locked-out superadmin, see [Password ownership and recovery](/guides/people-and-access/#password-ownership-and-recovery). ### Admin language Under **Language**, pick English, ไทย, Русский, Español, Français or Deutsch. **Same as browser** follows your browser language and falls back to English. The choice is saved in your account and applies on every site and device. It changes only the admin interface, dates and numbers. It never changes a site's content Languages, which stay under **Settings → Languages**. Screens not translated yet stay in English. ### Disconnect a sign-in provider Account security lists your Google and GitHub connections. A connection can remain linked even when sign-in through that provider is unavailable. Choose **Unlink**, review the provider name and session warning, then confirm. The server removes that exact connection and revokes every CMS session for your account, including the current session, in one atomic operation. You must sign in again. No provider tokens or provider identity ids are returned by this screen or its API. Passwords and pending verification links are not removed. An ordinary user may remove their last provider. A superadmin may also remove their last personal method if another active superadmin can still sign in. The server refuses any unlink that would leave the deployment with no active superadmin able to use an enabled, configured method. A configured method without a usable account link does not count. When magic-link sign-in is configured and enabled, it uses the user's email. A refusal preserves the connection and all sessions. Unlinking disconnects the CMS account. It does not revoke the OAuth application's authorization at Google or GitHub. A later permitted sign-in with the same verified email can link the provider again through the existing sign-in flow. There is no provider-link button on Account security. ### Passkeys Passkeys are off until a superadmin switches them on (see [Passkeys](/guides/people-and-access/#passkeys)). Then each person adds their own under Account security, after signing in another way within the last 24 hours. **Sign in with a passkey** asks for no address. You can rename and remove your own passkeys. The last way you can sign in cannot be removed. Removing a passkey keeps current sessions. A deactivated account cannot sign in with a passkey. To check real email delivery, provider callbacks and passkeys on a real deployment, follow [authentication acceptance](/guides/authentication-acceptance/). Local checks do not prove them. ## Next step Take [your first site to published content](/guides/first-site/). # Create your first site and publish content Source: https://dee-wan-docs.pages.dev/guides/first-site/ This walkthrough uses the CMS admin and public read API, not a detached backend. The example model `article`, field `title`, slug `hello` and hostname `www.example.com` are illustrative. Use the values you create in place of these examples. ## 1. Install and claim the administrator Complete [installation](/guides/installation/). Open the one-time administrator claim link and set your password. Keep deployment credentials outside content and screenshots. On a local seeded rig, follow the [repository setup](/guides/local-development/#try-it-locally-with-no-cloudflare-account) instead. Do not treat local fixture credentials as production accounts. ## 2. Create or select the site When you create a site, choose **Website** or **Blog**. The choice cannot be changed later. A Blog gets one starter Post model and a Posts screen; see [Blog sites](/guides/blog/). This walkthrough uses a Website. Select the intended site before editing. Set its public hostname and languages in site settings. Choose a default language and strict or fallback locale policy. The selected site determines the scope of admin requests. The public Worker resolves the site by the request hostname. A raw Worker hostname is not automatically your site's hostname. ![Site settings for the selected site, including its public domain](/assets/screenshots/site-settings.webp) ![Language settings with the default language and missing-translation policy](/assets/screenshots/language-settings.webp) ## 3. Define an Article model In the model builder, create `article`, add a required text field `title`, and apply the model. Review every breaking-change warning before confirming. Choose public visibility only for information meant to be public. The [field reference](/reference/models-and-fields/) explains relations, media and components. Creating an Article model does not automatically create a rendered web page. ![The Content Builder model library](/assets/screenshots/model-builder.webp) ![The Article model editor with fields and model settings](/assets/screenshots/model-editor.webp) ## 4. Save and review content Open Content, choose the model, create an item in the intended language, enter its title and set the slug to `hello`. Save the draft. Saving is not publishing. Reopen it to confirm that it was saved. With review mode enabled, submit it through the displayed workflow and have an authorized reviewer approve it. Do not bypass a refused transition. ![The content list with stage, language and delivery information](/assets/screenshots/content-list.webp) ![A content item open for editing with the Publishing panel](/assets/screenshots/content-editor.webp) ![Workflow settings with review enabled](/assets/screenshots/workflow-settings.webp) ## 5. Publish and verify Use a role with publishing permission and select the available publish transition. From a server or terminal, request the site's public hostname: ```sh curl --fail --silent --show-error 'https://www.example.com/v1/article/hello?locale=en' ``` A successful response contains the published item under `data`. Draft-only items are not public. A missing host, private model, unavailable locale or missing published slug can produce 404. Read the [public API refusals](/reference/public-api/) before changing visibility or access to fix a failure. ## 6. Connect a renderer and confirm withdrawal Connect your frontend using the [generated client and preview guide](/guides/preview-and-client/). Rendered websites need their own build and deployment. Publishing a CMS record alone is not proof of a deployed page. Unpublish a disposable example and verify it disappears from public reads and, after a successful rebuild, from [search](/guides/search/). A failed search crawl must not replace the previous live index. ## Next step: choose how the site runs Everyday use needs no extra setup. The CMS keeps serving your content while you edit and publish. Continue with the [page builder](/guides/page-builder/), [Blog](/guides/blog/), [models and fields](/reference/models-and-fields/) and [people and access](/guides/people-and-access/). A standalone backend is optional. Choose it only when you want your own repository with a generated API Worker, database and deployment. It is generated from an existing site, so you can decide later. See [what Dee Wan generates](/guides/standalone-backend/). ## Start with an editable Astro website From the repository root, generate the maintained reference starter into a new or empty directory: ```sh npm run reference:starter -- --out ../my-website --config ./starter-config.json cd ../my-website npm ci npm run check npm run build npm run preview -- --port 4321 ``` Use the Node version in `.nvmrc`. Create `starter-config.json` before running the command: ```json { "site_id": "YOUR_SITE_ID", "canonical_origin": "https://YOUR_PUBLIC_CMS_HOST", "routes": { "format": 1, "routes": [{ "model": "YOUR_PAGE_MODEL", "path": "/{locale}/{slug}" }] } } ``` Replace the uppercase values with your site's actual ID, public API origin and page-model slug. The API origin's hostname must match the site's configured domain: public reads select a site by hostname, not by `site_id`. Each route needs `{slug}`; include `{locale}` for language-prefixed addresses. For a Blog, also set `"blog": true`. For a local CMS, set `"local": true`, use an explicit loopback API origin, and configure the site's domain to match that hostname, such as `127.0.0.1`. Run the website on a different port. Unknown configuration fields are refused. The starter includes pinned dependencies and a lockfile, Astro source, Wrangler configuration and the current page-contract and renderer-protocol compatibility record. Edit `src/template/` for layout and styling; run `npm run dev -- --port 4321` while editing. Its runtime uses the existing public API for published content, media, forms and search. Alternate-language links use actual published addresses. SEO and supported rich blocks use the same renderer as managed delivery and preview. No CMS management token, database or plugin server is included. Forms submit to the website's registered public interaction endpoint. The portable runtime forwards the bounded submission to the configured CMS origin; the CMS retains and delivers it through the existing form service. Browser submissions must originate from that website. No management credentials are forwarded. You own the emitted source. When updating the CMS, generate a fresh starter elsewhere and merge its maintained runtime and contract files together, preserving your template changes. The generator refuses to overwrite an existing website. This starter reads live CMS content; [standalone delivery](/guides/standalone-backend/) separately exports a backend and content snapshot. A local build does not deploy the website or prove provider acceptance. ## Reference website: reproduce it locally The repository ships a fictional reference website, Meridian Review. It is a fixed selection from the seed corpus (`scripts/seed-corpus/reference-site.ts`): English and German, three translated Blog posts with a cover picture, and one composed page with rich sections and three pictures. Nothing in it describes a real organisation. One test builds it end to end on a fresh database. From `backend/`, with the Node version in `.nvmrc`: ```sh DEEWAN_SITE_BUILD=1 npx vitest run test/reference-site-acceptance.test.ts --maxWorkers=1 --no-file-parallelism ``` It installs the site's dependencies and runs a real build, so it takes a few minutes and needs the machine to be otherwise idle. It listens on ports 43261 to 43263; set `DEEWAN_SITE_BUILD_REFERENCE_PORT` to move them. Without `DEEWAN_SITE_BUILD=1` it is skipped, and a skipped test proves nothing. In order, it: 1. creates the site from the repository migrations, with both languages, the Blog and the page model; 2. uploads the pictures, creates and publishes the posts and the page; 3. mints a saved preview link for a draft and checks that only that link opens it; 4. generates the site, builds it and serves it with Wrangler; 5. checks the localized page and post addresses, the rich content and the pictures in the built output; 6. adds a model field, publishes a post, renames the page and regenerates over the same destination, then checks that the file you own is byte for byte unchanged, that a migration was appended and that no applied migration was rewritten; 7. rebuilds and checks that the renamed page now serves; 8. publishes a wrong edit, restores the earlier version, and checks the Blog serves the original; 9. takes a SQLite backup copy of the database and checks that generating from the copy gives the same site data, ignoring creation and update times. This test checks recovery by restoring a saved version and by generating a site from a database backup copy. The repository has no one-command restore; see [backup and restore](/guides/backup-restore/). This is local evidence only. Deployed acceptance of the reference website is pending authority and has not been run: it needs a Cloudflare account (Workers, D1, R2), a GitHub repository, and a hostname, and it includes a provider restore. Pictures in the local run go to a recording bucket, not to R2. Do not read a passing local run as proof of a deployment. # Edit and publish content Source: https://dee-wan-docs.pages.dev/guides/content-editing/ This guide covers items in **Content**: creating, saving, publishing, translating, reviewing, recovering and removing them. Pages have their own editor; see the [page builder](/guides/page-builder/). Blog posts live under **Posts**; see [Blog sites](/guides/blog/). Field types and their rules are in [models and fields](/reference/models-and-fields/). What a role may do is in [people and access](/guides/people-and-access/). ## Find and open an item Open **Content** in the sidebar and pick a model. The list shows each item's title, slug, status and language. - **Search content…** finds saved titles, names, addresses and rich-text bodies, including unpublished drafts. Results show the matching field and a short excerpt. Markup, HTML attributes and JSON keys are not searched. Model, language, stage and field-access rules apply before matches and counts. - **Stage** filters by workflow stage. **Delivery** filters by **Live** or **Not live**. - **Fields** filters and sorts one model's list by what its fields hold. It is described under **Filter and sort by fields** below. - Open an item with **Edit** in its row menu. **Duplicate** copies it as a new draft. - A single-entry model opens its one item directly. See [single-entry models](/reference/models-and-fields/#single-entry-models). The first search prepares older saved content automatically in small batches. Keep the search open while it loads; change or clear the search to cancel it. A failed request leaves a retryable error, never a zero-result claim. Queries take up to 200 characters. This searches current saved content; public website search still searches published content separately. The message **You can read this content but not change it. It belongs to someone else, or your role grants reading only.** explains why you cannot edit the item. ### Search API The admin list `GET /api/content_instances?q=...` searches current saved text. Existing `where`, model, language, state, field filters, sorting and pagination still apply. Shared fields can match a secondary language through their primary saved value. The primary source must also be readable; hidden fields or unreadable primary entries do not contribute matches. A matching body adds `search_match: { field, label, excerpt }` to its row. The count endpoint and matching bulk selections use the same readable matches. The query is at most 200 characters, including its normalized case form; `%` and `_` are literal text. An older or changed value may need derived text. A bounded read can prepare small sets itself; otherwise `409 editorial_search_preparing` means the search has no complete count yet. Call `POST /api/content_instances/search/prepare` with the same filters as query parameters until `data.more` is false, then repeat the original read. Each call prepares at most 16 values and normally at most 1 MiB, reading a larger value alone. The CMS does this automatically and stops when the query changes or a request fails. No content or publication state changes. Derived fragments are internal and are not part of exports or generic API includes. `/first` rejects `q`; use the paginated list. The management `listEntries` contract remains separate and does not accept `q`. ### Filter and sort by fields With one model open, **Fields** opens a dialog. Add up to four conditions. Choose **All conditions (AND)** or **Any condition (OR)** for each group. **Add group** nests a group, up to three levels including the outer group. For example, match a gold tier AND either more than 100 views OR an empty date. Matches use the latest saved values, including drafts. Saved views, matching bulk selections, counts and pages keep the same groups. Older flat filters still mean AND. | Field type | Conditions | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Text | **is**, **contains** (ignoring case), **is empty**, **is not empty** | | Choice | **is any of** the options you tick, **is empty**, **is not empty** | | Yes/no | **is** yes or no, **is empty**, **is not empty** | | Number | **is**, **is more than**, **is at least**, **is less than**, **is at most**, **is empty**, **is not empty** | | Date | **is**, **is after**, **is on or after**, **is before**, **is on or before**, **is empty**, **is not empty** | | Link to other items | **includes any of** the items you pick, **is empty**, **is not empty**; **Linked value** filters a configured readable display field | | Point | **within radius**, **within area**, **is empty**, **is not empty** | - Numbers compare as numbers, so 9 is less than 10. Ordinary dates compare by day. Timezone-aware dates compare their stored UTC instants. - **Sort by** orders one text, choice, yes/no, number or date field, ascending or descending. A single-target, single-value relation can sort by its configured readable scalar display field; a multi-value relation cannot. A point field sorts by distance from the latitude and longitude you enter. Missing or unreadable values come last in both directions; ties keep a fixed order between pages. - **Linked value** uses the display field’s text, choice, yes/no, number or date conditions. Only readable related entries and fields contribute. Shared references from another site support membership conditions, not display-value filtering or sorting. - Point radii use latitude, longitude and metres. An area uses south, west, north and east bounds; west greater than east crosses the antimeridian. Edges are included. - Rich text, media, components and JSON fields cannot be filtered or sorted. The dialog lists them under **Not available here** with the reason. - A field your role cannot read is not offered. It is also not used if a saved view names it. - A field that is shared between languages is read from the main language's item; a language-specific field is read from the item in that language. - The conditions and the sort are part of the address, so they survive a reload and can be shared as a link. **Select all N matching**, the counts and the pages all use the same conditions. The list needs one model: with all models shown, **Fields** is not offered. ## Saved views A view is a named setup of one model's content list: the stage and delivery filters, field conditions, the search term, which columns show and in what order, and one sort (by a column or by a field). Open it from **Views**, beside the list's other buttons. - **Personal and shared.** **Save as new view…** keeps a view for you. A shared view is seen by everyone who can read the model. Creating, changing or deleting a shared view, and setting the site default, needs the permission to apply model changes (`model:apply`). Reading a model is enough to make personal views and to use shared ones. - **Save.** Opening a view applies its filters, columns and sort. Changing the list marks the view **Modified**, and **Save** replaces it. If someone changed the view after you opened it, you can **Use their version**, or **Keep my changes and save**. **Save as view** keeps the current list as a new view without touching the open one. - **Columns and sort…** chooses which columns show, their order, and sorting by **Created** or **Updated**. Columns can be the built-in ones or any text, number, yes/no, choice, date or relationship field of the model. Relationship columns show readable labels and a count. Open a cell to inspect up to 20 related entries; each opens its editor. Names and counts follow your current access. - **Defaults.** In the **⋯** menu, **Make my default** and **Make site default** pick the view a model opens with. Your default wins over the site default. A default applies when you open a model whose address has no filters and names no view, so a link that carries filters, such as a dashboard tile, is never replaced. A shared view can be your default; only a shared view can be the site default. - **Who sees what.** A view stores filters, columns and a sort, never content. Each time it is read, your own access is checked again: columns, field conditions and a field sort that use a field your role cannot read are left out and the view says how many parts are ignored without naming the fields, and the rows are the ones your role may read, as in any list. Saving a view stores the columns currently visible to you. - **Fields that no longer exist.** A column, condition or sort on a field that was deleted, or whose type no longer allows it, is left out of the list. The view still opens and is not rewritten. A notice names the removed fields, and **Remove them and save** repairs the view in one action for people allowed to change it. Everyone else is told to ask someone who manages models. Columns hidden by your role are not removed. - **Limits.** A name has up to 80 characters and is unique among your views of the model, and among the shared ones. Each person keeps up to 25 personal views per model, and a model has up to 25 shared views. A view has up to 40 columns and a search term of up to 200 characters. Deleting a view also removes the defaults that name it. Views are an admin preference. They change nothing that is published, nothing in the public API, and need no generation or deployment. ## Create an item 1. Pick the model and press **Create content**. 2. Fill in the fields. A new item starts in the site's default language. If your role cannot create that language, it starts in the first language your role can create. The language list offers only languages your role can create. 3. Press **Create draft**. The screen says **Content created as a draft.** Creating is refused for a language the site does not have (`unknown_locale`). If the language is removed while the item is being created, nothing is written (`language_changed`). Each language has its own address. Leave **Address** empty to make one from the title. The rules are in [item address](/reference/models-and-fields/#item-address). ### Vendor widgets in rich text In a rich-text field, choose **Vendor snippet** (under **More** on phones) to insert a provider's HTML, CSS and JavaScript. Give it an accessible title, then paste each part into its labelled code editor. The editors provide syntax highlighting, line numbers, bracket matching, search and undo/redo. Use **Format document** from the editor's right-click menu or visible actions menu, or press **Shift+Alt+F**. Prettier formats locally in your browser. Formatting does not save or run the code. Undo restores the previous text; invalid code stays unchanged with an explanation. Tab moves to the next control. **Preview snippet** runs the widget inside its isolated frame. Save the entry to keep it; publish separately to show it on the site. The provider handles submitted booking or form data. ## Save and publish Saving and publishing are separate steps. Saving never makes anything public. ### Save Press **Save**. Each save writes a new version of the item. - The **Unsaved** badge shows edits that are not saved yet. - What you type while a save is running is not part of that save. It stays **Unsaved** until you save again. - Leaving with unsaved edits asks first: **Keep editing** or **Discard and leave**. - **Preview here** can show unsaved edits in the site's design without saving them, where the site's renderer supports it. See [Preview unsaved changes](/guides/preview-and-client/). - A field rule or a plugin check can refuse a save. The editor names the field and keeps your edit. See [plugin content checks](/reference/models-and-fields/#plugin-content-checks). ### Proposed changes An integration can propose field changes for you to review. **Proposed changes** shows the source, requester, date, language and affected fields. Select a field label to jump to and focus that field. **Apply** needs you to save or discard your local edits first. It uses the ordinary version-checked save and your current permissions. **Dismiss** leaves the entry unchanged; the integration can propose again. If the entry changed after the proposal, the proposal is stale. Dismiss it or ask for a new proposal. ### Incomplete drafts By default, a draft may leave required fields empty. A new draft may also have no address. Invalid values, broken references, duplicate unique values and invalid component structure still refuse a save. An existing address cannot be cleared. A model's **Draft validation** setting can require complete values on every save instead. Publishing always checks the exact saved version in every affected language: required fields, address rules, references, plugin checks and required review tasks must pass. This also applies to publication overrides, imports, plugins and scheduled releases. A complete draft in English does not make an incomplete Thai translation publishable. ### Publish The **Publishing** panel shows the moves your role may take. You never see a move that would be refused. - With the review flow off, **Publish** makes the saved version public at once. - A move or a publish saves unsaved edits first. If that save is refused, nothing moves. - **Unpublish** takes the item out of public view. It stays as a draft. Nothing is deleted. The review flow is a site setting: **Settings → Workflow → Enable review flow**. ### Review flow With review on, an item moves through the site's review stages, for example **Submit for review**, **Approve**, **Send back** and **Publish**. Each move can carry a note for the next person. The **Dashboard** lists **Waiting for review** and **Sent back**. A row says what you can do, or who it is waiting for. Open the row to act. The move itself happens in the editor. ### Publish without review A role with the override permission (`content:publish_override`) sees an **Override** box. No built-in role holds it. A superadmin can always use this override. 1. Press **Publish all languages now** (or **Publish now** on an item with one language). 2. Write the reason under **Why is this necessary?**. It is required. 3. Confirm. The override applies to the whole translation group. The activity shows it as **Publication override**. It still checks the role's publish and unpublish language limits. It is refused (`locale_denied`, naming the languages) only when it would change a language outside them. A language that already serves the version being published, or is already offline on an unpublish, does not count. **Unpublish all languages now** works the same way, but it does not save unsaved edits first. ### Unpublish and what links to this Before an unpublish, the dialog checks what links to the item. - It names how many items link here, and how many of them are live. Their published copies keep the link, so those links will break. - Items in Trash that link here are marked **In Trash**. Links only in a draft are marked **draft only**. - If the list changes before you confirm, the move is refused with **What links to this changed. Check the list again.** ## Translations An item and its languages form one translation group. **Each language is saved separately.** ### Start a translation In the **Language** panel, press **Start a {language} translation**. The new language starts empty. Fill it in and save. - Shared (non-translatable) fields are edited in the primary language. The panel says how many there are. - When a language is not live yet, the panel says what visitors get instead, for example **Not live in Thai yet. Visitors see English.** See [languages](/guides/languages/). ### One language or all languages The **Publishing** panel has two scopes: | Scope | Buttons | Effect | | ------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | This language | **Publish {language}**, **Unpublish {language}**, a move with **· {language}** | Only this language changes. Other languages stay as they are. | | All languages | **Publish all languages**, **Unpublish all languages**, a move with **· All languages** | Every language of the group moves. A move between two unpublished stages, such as **Approve · All languages** or **Send back · All languages**, leaves a language that is already published live and **Published**. Take it off with **Unpublish {language}**. | Other language-only actions: - **Move this language to Trash** removes one language. The language that holds the item's history cannot go alone; move the whole item instead. - **Restore {language} only** in History restores one language from an older version. A role limited to some languages can act only in those languages. An action that spans languages is refused in its entirety. See [limit a role to some fields or languages](/guides/people-and-access/#limit-a-role-to-some-fields-or-languages). ## Assign and review Press **Assign** (or **Reassign**) in the **Publishing** panel. - Pick one **Assignee** and an optional **Due date**. The date is today or later. - The assignee must be able to move the item on from its current stage. - One assignment covers the item and all its languages. - The person you assign gets a notification in the admin; see [feedback and notifications](#feedback-and-notifications). The panel shows **Due {date}** and, after the due date, **Due {date} · Overdue**. - A workflow move clears the assignment. **Clear assignment** removes it by hand. - **Deactivated** or **No access** next to an assignee means that person cannot act now. ### Language assignments and review tasks **Review tasks** keeps work separate from the item's workflow assignment. Under **Language assignment**, choose an owner and due date for the current language, then **Save**. Other languages and the whole-item assignment keep their owners. Only eligible people are offered; simultaneous changes are refused rather than overwritten. Choose the task language before its owner. Changing language refreshes the eligible people; **All languages** offers only people who can read every language of the entry. For a task, enter **Task**, choose **Owner**, language and optional **Due**, then **Add task**. Keep **Required before publishing** on when publication depends on it. The owner must be able to read that entry and language. The owner or an authorized assigner can **Complete** it. **Reopen** starts another review; **Remove** removes the requirement. Completion records the content reviewed. Changing that language or shared content shows **Review changed content** and blocks publishing until the task is completed again. Changing only another language does not invalidate it. A task for **All languages** covers every affected language. When editing a future version, completion refers to that version's content, not the ordinary draft. ## Feedback and notifications ### Feedback on an entry The **Feedback** section in the side column holds notes from the people reviewing this entry. It is private to the admin. It is not the public [comments](/guides/blog/) feature and never reaches the site. - Type a note and press **Add feedback**. A note is 1 to 2000 characters, and an entry holds at most 500. - Each note shows who wrote it, when, and the version it was written on. When the entry has a newer version, the note says **Written on an older version (version {version}, now version {current}).** - **Resolve** closes a note and hides it. **Show {count} resolved** lists closed notes, and **Reopen** opens one again. - Reading follows read access to the entry. Writing and resolving need update access to it. A role limited to its own items can write only on its own items. Someone who can read but not update sees the notes without the box. - A note belongs to the item and the language you wrote it in. ### Field threads and mentions Choose **Field** before adding feedback to anchor a thread to a field, or leave **Whole entry** selected. **Reply** keeps the same field and language; **Resolve** closes the thread and its replies. Version labels show when a note predates later edits. Open **Notify** and select people or editorial teams. Choices include only people who can currently read the thread's entry, field and language. Up to 20 mentions can notify up to 50 people; a team grants no content access. The notification opens the relevant entry and language. Access is checked again when it is read, so a revoked reader sees no hidden title or field. People with site-grant permission can open **Editorial teams** under **Review tasks** to create or edit a site's teams and members. Task assignments, completions and mentions appear in the notification bell alongside workflow notifications. ### Notifications If notifications cannot be loaded, use **Try again** in the notification panel. Existing notifications stay visible if a refresh fails. The bell in the header lists notifications that need your attention. The badge counts unread notifications. | Kind | You get it when | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Assignment | Someone assigns the item to you. Assigning yourself, or saving the same assignee again, sends nothing. | | Review request | The item, or one of its languages, moves into a review stage. It goes to the person named as next to act. With no one named, it goes to everyone who can move the item on from that stage and may review the moved language, up to 50 people, never the person who made the move. | | Return for changes | An item, or one of its languages, in a review stage goes back to the starting stage. It goes to the person named as next to act, otherwise to the author of that language, if the author can still read it. | - Each notification names the item and its **current** review state, not the state it was created in, and links to it. A notification about one language names it (**Language: th**), shows that language's state and opens that language. - If you can no longer read the item or that language, because your role changed or it moved to Trash, the notification reads **An entry you were notified about is no longer available to you.** It shows no title and no link. - **Mark read**, **Mark unread** and **Mark all read** change only your own list. - A notification is written together with the move or assignment that causes it. A move that fails sends nothing, and a retried move sends nothing twice. - The editor, the management API and plugin commands notify the same way, because they share one path. A move of one language notifies like a move of the whole item. - An import is quiet by default. Whoever runs an import can choose to notify with **Notify reviewers about review changes in this import** on the run screen; the choice is stored on the job and needs the same `data:import` permission. Carried history, such as a carried assignment, never notifies. - Email is optional. When the install has mail configured (see [people and access](/guides/people-and-access/)) and `FRONTEND_URL` is set, the person also gets a short email with a link and no content. A failed email is not retried. The notification in the admin exists either way. - There are no reminders and no chat. ## Workflows Unsaved workflow edits are protected when leaving Settings or replacing or closing the editor. On mobile, Save and Cancel stay available while scrolling. If workflows cannot be loaded, choose **Try again**; a failed refresh keeps the current draft. **Settings → Workflow → Workflows** lists the site's named workflows. A workflow is the set of states an item moves through and the moves between them. The site starts with one, marked **Site default**. - **New workflow** starts from a copy of the site default. Give it a name. Add states, mark exactly one as the **Starting state**, mark the ones that publish, and add the moves. A move can need **Approvers**, **Publishers** or people who can unpublish. - A workflow has at most 16 states and 80 moves, and a site has at most 20 workflows. A state key is lowercase letters, digits and underscores, starting with a letter. The starting state must be able to reach a state that publishes. - **Edit** changes names, states and moves. **Delete** is offered only for a workflow no model uses and no item is attached to. The site default cannot be deleted. - **Workflow for each model** gives each model one workflow, or the site default. An item uses the workflow it is already attached to, then its model's workflow, then the site default. Changing a model affects new items. - Settings → History records creating, changing, deleting and assigning a workflow. - If someone else changes, deletes or starts using a workflow while you save, the save is refused with `workflow_changed`, `workflow_in_use` or `workflow_assigned`. Nothing is written and no item loses its review state. Reload and try again. An item move that was checked against an older version of the workflow is refused the same way (`workflow_changed`). ### Moving items in review A change that would leave items in a state that no longer applies is refused until you review a plan. This covers removing a state that items are in, and giving a model another workflow while its items are in review. - **Review how entries move** lists the states, how many items are in each, and some of the items. Choose a new state for each. For a model change you can also choose **Leave on their current workflow**. - **Move entries and apply** moves the items one by one and lists each result: **Moved**, **Refused**, **Changed meanwhile** or **Unchanged**. Each move is recorded in the item's history. - A reviewed move cannot publish or unpublish an item. Move those items with their ordinary buttons first. - A request moves 25 items. The screen repeats it until none are left. The edit or the model change is applied only when no item remains. If one could not be moved, nothing else changes. Fix it and apply again. - While items are attached to a workflow, its starting state and the publishing flag of its states cannot change. ## Edit together ### Who is editing The first person to open an item in a language holds it for editing. Others see **{name} is editing this.** They can read along: the form follows the holder's unsaved work, refreshed every 10 seconds. - **Also open: {people}. Shared fields and relations save for every language.** names people working on other languages of the same item. - **You are editing this in another tab.** means your own other tab holds it. - The hold ends about two minutes after the holder's tab stops renewing it, for example when the tab is closed or hidden. ### Take over Press **Take over** to edit. The hold moves at once. - The previous holder sees **{name} took over.** Their next save or move is refused. - Their unsaved changes are kept as their own recovery draft. They get them back when they hold the item again. - Holding or taking over a language needs your role to change that language (or to create it, when the item has no row in it yet). A role that edits only its own items must own that language's row. Otherwise the server refuses (`locale_denied` or `not_owner`). - While someone else holds an item, saves, moves, assignment, restore, the override and Trash from other sessions are refused. Plugins, imports, scheduled publication and spreadsheet apply are not blocked by a hold. ### Shared draft Unsaved work autosaves a moment after you stop typing. It is one shared draft per item and language, written by the holder. - People who may read the item see it after a reload, on any device. They see only the fields they may read, also in a conflict. A role that reads only its own items sees only drafts of the language rows it owns. - Read access is checked on its own, apart from the right to change. A role that may change an item but not read that row or language can still write the draft. It never sees the draft it replaces, and a conflict answer carries no draft. - An autosave or a discard after someone took over is refused and changes nothing. A refused autosave becomes your recovery draft. A refused discard says **Not discarded — taken over. Your work is kept.** What you typed stays recoverable as your recovery draft. - A draft is never a version. **Save** turns it into a version and clears the draft. - A draft is kept 30 days after its last change. - A new item, before its first save, keeps your own private draft. The status next to **Save** says what happened: | Status | Meaning | | ----------------------------- | ------------------------------------------------------- | | **Saving draft…** | The draft is being saved to the server. | | **Draft saved** | The draft is saved on the server. | | **Offline — kept here** | Kept on this device. Sent on reconnection or **Retry**. | | **Draft not saved** | The write failed. Press **Retry**. | | **Changed on another device** | Press **Choose** to pick a version. | | **Not saved — taken over** | Someone took over. Your work is your recovery draft. | ## Recover unsaved work | You see | What happened | What to do | | ------------------------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------ | | **Someone else saved this content** | Another save landed first. Your edits are still in the form. | **Load theirs**, or **Overwrite with mine**. | | **Unsaved changes from {age}** | A draft you never saved. | **Restore them** or **Discard**. | | **Unsaved changes from an older version** | Someone saved after your draft was made. | **Compare**, then **Apply my changes** or **Discard**. | | **Another device kept different unsaved changes {age}. Choose which to keep.** | Two devices wrote different drafts. | **Use the other device's** or **Keep this one**. | | **Your own unsaved changes** | Your work was kept when someone took over. | **Restore them** or **Discard**. | | **An old draft was removed** | The draft passed 30 days. | The saved version is on screen. | ## Version history Open **History** from the editor. It lists versions oldest first and marks **the version being edited** and **the version the public sees**. Step with **Previous revision** and **Next revision**. Each version shows its fields, links and images. Reading history changes nothing. Expand grouped fields to read nested blocks. Rich text appears as readable text; exact stored values remain under **Technical details**. Shared fields remain visible when you switch languages; language-specific links follow the selected language. Use **Compare with** to choose another saved version. Changed fields show removed text struck through and added text underlined. The selected version is the result side of the comparison. Use the language buttons to compare translations, search settings and page facts. Only fields and related labels you can currently read are shown. **Absent** means the field was not stored; **Empty** means an explicit empty value. A changed link or file is marked even when its visible label stays the same. Comparison never saves, restores or publishes. Load older history first to include older retained versions in the selector. ### Related entries In a relationship picker, **Create {model}** opens the ordinary entry editor without leaving your parent draft. The pencil button edits an existing related entry. Child fields, media, translations, validation and save conflicts work as in the full editor. Saving refreshes the selected entry and its label; it does not save the parent. Closing without saving keeps the previous selection and parent edits. If the child has unsaved changes, choose whether to discard them. Publication remains separate. ### Restore a version - **Restore this version** loads that version into the editor and saves it as a new version. It covers every language and every field. It needs the restore permission in every language of the item. - **Restore {language} only** restores one language. Other languages and what is live stay as they are. It needs the restore permission in that language. - Restoring never publishes. Publish afterwards if you want the public to see it. ### How many versions are kept **Settings → Workflow → Version history → Versions kept per item** sets the number. The default is 50. The minimum is 1. - Each save or restore of an item deletes its oldest versions beyond that number. - The version being edited and every published version are always kept. Future-version heads and history, and the base versions of open proposals, are retained while they are needed. - Lowering the number deletes nothing at once. Each item is trimmed at its next save. - A deleted version cannot be opened or restored. History says **Version {version} is no longer retained — this site keeps a limited number of versions. Showing the nearest one that is.** ## Trash ### Move to Trash Press **Move to Trash** in the editor or the list. This moves every language to Trash. **Move this language to Trash** removes one language. - A live item or language is refused. Unpublish it first. - Items in Trash leave lists, search, relation pickers and previews. - You need delete access to the model. ### Restore from Trash Open **Trash** above the content list. **Restore** brings the item back as a draft, with its versions, translations, links and media. - The dialog lists links that will not work, such as links to items still in Trash or deleted for good. - If another item took its address, restore is refused. Change that item's address first. ### Delete for good **Delete permanently** removes every language, version and link of the item, and its visitor comments. It asks first. It cannot be undone. ### Automatic deletion An item or language is deleted for good 30 days after it went to Trash. The **Deleted on** column shows that date. - A scheduled job runs once a day. In the supplied Worker configuration it runs at 03:17 UTC. - Each run deletes at most 50 items per site, oldest first. When more are due, the rest wait for the next run. - Sites that are deactivated are skipped until they are active again. - Restoring before deletion stops it. ## Bulk actions Tick rows in the content list. You can act on at most 25 at a time. - **Move to…** a stage, then **Move**. - **Publish now** and **Unpublish now**, for roles with the override permission. They need a reason, recorded as an override. - **Move to Trash**. - In Trash: **Restore** and **Delete permanently**. Each item is checked on its own, with the same rules as the single action. A refusal does not stop the others, and nothing is rolled back. The result shows **{done} updated, {refused} refused.** It lists each refused item with its reason, for example **not yours**, **live — unpublish it first** or **changed by someone else**. Languages of one item move together, so a second row of the same item is counted once. ## Change many items at once Tick the page checkbox at the top of the list. When more items match your filters than one page shows, a line appears: **Select all N matching**. Click it to select every match across all pages. Untick a row to leave that item out; the exclusion is kept as you turn pages. Changing the filters, the field conditions, the search or the content type ends the selection. ### Select every match - The selection is every item your role can read that matches the filters, field conditions and search at the moment you start. It is fixed then. Items that are added or change later do not join it. - A change covers at most 5,000 items. When more match, the list says so and does not offer the selection; narrow the filters first. - The available actions are **Move**, **Publish now** and **Unpublish now** (roles with the override permission), **Move to Trash**, and **Edit fields**. **Edit fields** needs the list narrowed to one content type. - Items that other content links to are skipped when they would go offline or to Trash. They are listed afterwards as refused; there is no way to approve them in bulk. ### Edit fields for many items **Edit fields** lists the fields of the content type that can be set to one value for every selected item: text, number, yes/no, date and choice fields. Tick a field, give it a value, and choose **Apply to N items**. Fields that are not offered are listed with the reason: - Rich text, links, files and structured content are edited one item at a time. - A field that must stay different on every item (unique). - A field filled in automatically. - A private field. - A field your role cannot change. Each item is saved like a normal edit: the same checks, field and language permissions, version history and cache refresh. Each changed item gets one new version. An item that already holds the value is left alone and reported as **No change needed**. ### Progress and results The change runs in steps of at most 25 items. A bar shows how far it is, and **Stop** ends it where it is; items not reached are not touched. If you close the page or lose the connection, the list offers **Continue** next time. After a failed step, **Try again** continues the same change and does not repeat what was already done. Only the person who started a change can see and continue it. Every item is checked again when its turn comes, with the same rules as the single action. The result lists each item as: - **Changed**. - **No change needed**. - **Refused**, with the reason, for example **not yours** or **live — unpublish it first**. - **Changed meanwhile**: someone saved or moved the item after the list was fixed. Nothing is overwritten. Use the buttons above the result to show one group. ## Spreadsheet round trip The **Export spreadsheet** → **Import spreadsheet** round trip updates existing items of one model without changing their IDs or addresses. Each changed item gets a new saved version. To create items, upsert or add translations from CSV/XLSX, use **Collection files** with the modes described below. 1. **Export spreadsheet** downloads an Excel workbook, one sheet per language. 2. Edit cells. Leave the `id`, `slug` and `current_version_id` columns as they are. 3. **Import spreadsheet**, choose the `.xlsx` file, or a `.csv` file with **Language of this CSV**. 4. **Check file**. Checking writes nothing. It shows rows, changes and blocked rows, with the row and column of each problem. 5. **Apply N changes**. Apply works only when no row is blocked, and only on the exact file you checked. Each changed item gets a new version. Nothing is published. What the file holds: - Only the items you may read and change. A role limited to its own items exports only those. A row for anyone else's item is blocked as not found. - Only the fields your role may both read and change, and one sheet per language your role may read. A file exported by a field-limited role can be checked and applied without the other columns. Their stored values are kept. A file that adds such a column back, or a sheet in a language your role cannot read, is blocked. - Each sheet's `slug` is that language's own address. - A change in a language your role may read but not change is blocked with `locale_denied` at its row and column. A shared (non-translatable) field is stored in the primary language, so changing it on any sheet counts as a change in the primary language. - In this update-only round trip, values for a translation that does not exist are blocked with `spreadsheet_locale_missing`. Add it in the editor first, or use **Collection files** to add it with the required language and field grants. - A rule problem on a field or language your role cannot see is shown without naming that field or language. - Relation cells contain JSON references, for example `[{"model":"author","id":"entry_id"}]` or `[{"model":"author","slug":"jane","lang":"en"}]`. References must name existing entries in this site and fit the field's target models and cardinality. - Media cells contain JSON references such as `[{"id":"media_id"}]` or `[{"storage_key":"folder/image.png"}]`, with optional `crop` and `focal`. They select existing library files; they do not upload a file or accept an arbitrary URL. - Component cells contain the same JSON values as a collection file, including nested and repeatable component values. Keep a structured cell blank to preserve its value; enter JSON `null` to clear a value or link list. Empty required values are allowed in draft mode; strict models and publication require them. Shared link or media values must agree across language sheets. - A row whose item changed after the export is blocked. Export again. - A workbook exported before each sheet carried its own address can be blocked with `spreadsheet_slug_mismatch` on the other languages' sheets. Export again. - Formulas are refused. A file is at most 10 MiB, with at most 20,000 rows per sheet. If the apply stops part-way, the screen says **Apply stopped** and which row. Rows saved before it are kept. Export again before you retry. A whole-site export is a different, operator task. See [import and migration](/guides/import-migration/). ## Collection create, update and upsert Use **Collection files** to import JSON, CSV or XLSX and create or update items of one model, including components, related items, media references and translations. It is separate from the update-only spreadsheet round trip above, whole-site copy and [migration from another CMS](/guides/import-migration/). Open it from the model’s content list. The button shows for roles that can read the model and create or update it. **Download collection** saves a `.json` file of the items your role may read: the languages, fields, related items and media you may read, and only your own items when your read grant is limited to those. A model with many items downloads in parts (**Download part 2**, and so on); each part imports on its own. For CSV/XLSX, use field keys as column headers, alongside `id`, `slug` and `current_version_id` where needed. Choose **Language of this CSV** for a CSV; workbook sheet names identify the site languages. Cells for components, JSON, relations, media and points contain JSON values, not flattened subfield columns. Timezone-aware date cells also use JSON, for example `{"datetime":"2026-11-01T06:30:25.123Z","timezone":"America/New_York"}`; `datetime` is the UTC instant, not the local clock time. Rows with the same match across language sheets form one translated item; shared values must agree. Spreadsheet selection starts with update-by-ID: choose a different mode and match explicitly to create or upsert. The same checks, per-row permissions and 500-entry limit apply to all three formats. ### The file ```json { "format": "dee-wan-collection", "version": 1, "model": "article", "entries": [ { "id": "…", "current_version_id": "…", "primary_lang": "en", "slugs": { "en": "hello", "th": "sawasdee" }, "locales": { "en": { "title": "Hello", "faqs": [{ "question": "Why?" }] }, "th": { "title": "สวัสดี" } }, "relations": { "related": [{ "model": "author", "id": "…" }] }, "locale_relations": { "th": { "tags": [{ "model": "tag", "slug": "news", "lang": "th" }] } }, "media": { "cover": [{ "id": "…", "storage_key": "…", "crop": null }] } } ] } ``` - `locales` holds field values as the item stores them: nested components, JSON and rich text keep their structure. A field left out is not touched. `null` clears it. - `relations` names related items as `{ "model", "id" }`, or `{ "model", "slug" }` with an optional `lang` (default: the site's main language). A relation with different links in each language goes under `locale_relations`. The `model` is always written, and it must be the model of the item found. A field that accepts one model refuses another. - `media` names files already in this site's library by `id` or `storage_key`. Importing a reference never uploads or copies a file. `crop` and `focal` are kept when written. - `slugs` holds each language's address. `id` and `current_version_id` are only read when matching by id. - A file is at most 10 MiB, 500 entries and 32 levels of nesting. Apply saves 20 rows per request and the screen continues until none are left. ### Modes and matching Choose what the file does and how existing items are found. | Mode | A row with no matching item | A row with a matching item | | ---------------- | -------------------------------------- | ------------------------------------------- | | Create | Created. | Refused (`collection_match_exists`). | | Update | Refused (`spreadsheet_row_not_found`). | Updated if it differs. | | Create or update | Created. | Updated if it differs, otherwise unchanged. | Match on **ID**, **Address (slug)** or a field of the model that is set to be unique. The slug is read in the entry's `primary_lang`. ID matches existing items only, so create and upsert by ID never make a new item; match on the address or a unique field to create. A row with no value to match on, two rows with the same value, two rows for one item, or a value that more than one item holds are all refused before anything is written (`collection_match_missing`, `collection_duplicate_match`, `collection_duplicate_target`, `collection_match_ambiguous`). ### Check, then apply 1. **Check file**. Checking writes nothing. It shows rows to create, rows to update, unchanged rows, kept rows and blocked rows, with the row, language and field of each problem. 2. **Apply changes**. Apply works only when no row is blocked, only on the exact file you checked, and only while the model's fields and rules are still the ones you checked against (`collection_definitions_changed`: check again). Each row is written like a manual edit: the same validation, history, cache clearing and audit. Nothing is published. What is blocked: a field or language that does not exist; a relation or media reference that names nothing, an item in Trash, an item of another site or the wrong model (`collection_relation_missing`, `collection_relation_in_trash`, `collection_relation_wrong_model`, `collection_media_missing`); a value the editor would refuse; a relation written in `locales`, or the reverse (`collection_field_reference`); a model you cannot create, read or update; a translation the item does not have yet when your role may not create that language or change that field (`locale_denied`, `field_denied`); and a changed field or language your role may not write (`field_denied`, `locale_denied`). A field or language you did not change is not checked against your write grants. ### Conflicts, results and retry If a row's `current_version_id` is not the item's current version and the row differs from the item, the item is **kept** as it is (`spreadsheet_version_conflict`). The other rows still apply. Export again to change a kept item. If you leave out `current_version_id`, the row is compared with the item as it is when you apply. A version from another site's file is ignored when the `id` is not the matched item's. Apply reports each row as **created**, **updated**, **unchanged**, **kept** or **refused**; a refused row shows the reason. It never claims the whole file applied as one step. If the screen says the last step could not be confirmed, choose **Resume**. Rows already written read as unchanged. Retrying the same file never creates a second item: a created row is remembered for 30 days by the file and its row number, and after that a create refuses the existing item and an upsert reads it as unchanged. Apply works only on a file that was checked, and it keeps the version each item had when you checked. An item that changed after the check is **kept** (`spreadsheet_version_conflict`) and the editor's change stays. A check that is older than 30 days, or an apply that skips the check, is refused with `spreadsheet_not_checked`: check the file again. A retry never overwrites an edit made after the file's first run. An item the file created and someone then edited reads as **unchanged** (already created by this file). An item the file updated and someone then edited is **kept** (`spreadsheet_version_conflict`). A translation the file started is not written again, so later edits to it stay. ### Add a translation A row that gives a language the existing item does not have yet starts that translation inside the item's translation group, with its address (`slugs`), field values, components, related items and media references. This needs the update grant on the model, the create grant for that language and write access to the fields. Without them the row is blocked (`locale_denied`, `field_denied`). A language that is not set up for the site is blocked as an unknown language. Invalid supplied values and addresses already used by another item block the row (`field_validation_failed`, `slug_reserved`). Missing required values block saves only for strict models; publication always requires them. ## Generate with AI AI generation needs the `ai:generate` permission and an AI provider set up for the installation. Without a provider, generation is refused. Each site has an hourly allowance, 250 requests by default. - **Text fields.** A field set up for AI generation in the model shows a **Generate with AI** button. **AI prompt not configured** means the field has no prompt. - **Rich text.** **AI Generate** opens a prompt. Write what you want and press **Generate**. - **Components with A/B variants.** **Generate variants with AI** adds variants. In text fields and rich text, the answer is shown for review first. Nothing changes until you press **Apply**. In a text field, Apply replaces the field. In rich text, Apply inserts at the cursor. An answer over the field's limit is truncated, and the panel says so before you apply. Images are generated in the media library, see [media](/guides/media/#generate-an-image-with-ai). The [content planner](/guides/content-planner/) writes whole drafts. ## Next step Manage files in the [media library](/guides/media/), and set up [languages](/guides/languages/). ## Update translations after save Open **AI translation updates** in the entry sidebar. Opt in, select existing target languages, then allow replacement of existing text, including manual translations. The source is saved first. AI translates only changed translatable text and rich-text fields into selected languages and saves one draft version through normal permissions and version checks. A save that changes no source text makes no AI request. Other fields and languages stay unchanged. Clearing source text clears that selected translated field without inventing replacement text. Switching source language resets consent. Failure keeps the source save and reports the translation failure. Publishing remains a separate step. Page settings also offer **AI translation updates** for existing composed-page languages. Select targets and explicitly allow replacement, including manual wording. The source saves first. AI compares previous and current source text with each target’s own text; it updates only existing text fields. Each target keeps its section IDs, order, layout, media, links, code, component pins, shared references and facts. Rich paragraph formatting and link destinations stay. Shared and component-pinned content is excluded. Targets save sequentially through ordinary page permissions and version/facts checks. If the page changes after the source save, review the latest page before retrying translation. Each target reports success, no corresponding change, or failure. An unchanged source or a source with no previous or current text makes no AI request. Clearing source wording can remove corresponding target wording. Source page, language or site changes reset consent. Translation failure keeps the source save; typing during translation stays in the source editor. Publication remains separate. ## Entry folders Open the compact **Entry folders** control on Content to browse one folder or **Unfiled**, across models or within the selected model. The folder filter stays in the address and survives reload and Back. Folder counts include only entries you can read; translations count as one logical entry. People with `site:settings` choose **New folder** to create a folder, or select a folder and choose **Edit** to rename it or change its parent. **Cancel** closes the form without saving. They can also delete empty folders. Names contain 1–80 characters; sibling names are unique ignoring case. A site can hold 500 folders with five levels of nesting. Move live entries and child folders out before deleting a folder. The confirmation shows entries in Trash that will become Unfiled if restored; their placement history stays. Creating or renaming a folder shows a success message. Open **Current folder** on an existing entry, then choose a folder and **Move entry**. The controls and placement history stay collapsed until opened. Moving needs read and update access to that entry, including ownership and entry rules. Every translation shares the primary entry's folder. A folder move changes no URL, revision or publication pointer. **Placement history** records moves separately from content revisions; restoring an old content revision keeps the current folder. The latest 100 moves appear in the editor. **Duplicate** keeps the source entry's folder, including for composed pages. A site copy carries empty folders and current placement. History transfer also carries placement events and actor labels. Destination IDs are remapped; folders with the same path of names are reused. Reimport does not move entries created by an earlier job. Undo keeps an entry if someone changed its placement after import. ## Context-dependent choices Model field configuration can limit relation choices with `filterChoices`, and select options with `when`. These use the same bounded conditions as field filters: at most four conditions and three group levels. Relation conditions test candidate target fields; select `when` conditions test sibling fields in the current form. Nested component choices use siblings inside that component instance. ```json { "type": "relation", "targetModel": "article", "relationType": "manyToMany", "filterChoices": [ { "field": "department", "op": "eq", "values": ["$field:department"] } ] } ``` ```json { "type": "select", "options": [ { "value": "general", "label": "General" }, { "value": "sales", "label": "Sales", "when": [{ "field": "department", "op": "eq", "values": ["sales"] }] } ] } ``` Operands can use `$field:key`, `$user:id`, `$user:email`, `$entry:id`, `$entry:model`, `$entry:locale`. Prefix a literal dollar value with `$$`. Missing context gives no matching choices. Unsaved sibling edits recompute the picker. An incompatible saved select value stays visible with a warning and a **Clear** action; you can also choose a replacement. Save checks selected values again, including retained dependent values; change an incompatible selection before saving. Relation choices also respect the person's existing read access. A choice rule grants no extra access. ## Structured inline content An active plugin can declare an inline item in **Blocks → Plugin blocks**. Place the caret inside a paragraph and choose its label. The item stays inside that paragraph. Select its visible label to edit the plugin's declared fields in a dialog; close the dialog to continue writing. The host draws the controls and preview. Values are structured data with the plugin type and version, not pasted markup. Undo, saved versions, restore, site copy and native import keep the values and references. The public and generated renderers allow inline markup only; a plugin cannot insert a block element into a paragraph. A disabled or unavailable plugin keeps the stored item but does not render it publicly. Existing block items remain available separately. ## Collection order For a collection, open **Collection order** and enable **Enable manual order**. This requires `model:apply`. Select **Use manual order** to request it in the list, then move entries with the arrow buttons or drag them before another visible entry. Translations share one position. Filtered, hidden and off-page entries keep their order relative to each other; a move uses the visible entry as its anchor. New groups append; Trash and restore preserve their position. Reordering creates no content version. Manual order is optional and supports up to 20,000 translation groups per model. Concurrent changes return a conflict: reload before moving again. Public consumers explicitly request `order=manual`; other sort modes keep their behavior. A field sort cannot be combined with manual order. Cursors bind the order revision, so an old cursor is refused after reordering. Native copies carry the source order for newly copied groups and preserve existing destination groups. Undo restores an imported order flag only if subsequent ordering or membership changes have not superseded it. ## Field groups Models may group fields under localized headings. The heading and its info control use the admin language, with the site's default language and original label as fallbacks. Group names do not change saved field keys or content values. ## Future versions Use Scheduling to add an entry to a release with **Version → Independent future version**. Give the release a name and time. Open **Future versions** on the entry and select that release's version. Each release has its own saved content and history; editing it leaves the ordinary draft and public version unchanged. Different campaigns can prepare different versions of the same entry or composed page. In the future editor, choose a language, edit fields or the page's sections and facts, then **Save**. **Future history** lists its saved versions. Required review tasks are checked against this future content. Return with **Back to entry** to edit the ordinary draft. **Preview through this release** uses the published site plus earlier scheduled releases, including their publication and unpublication actions, then this release. Composed pages use the preview in their page controls. Choose a release time first; unreadable content stays hidden. It is a private preview and changes no public cache or publication. The release freezes the exact future revision when its publication command starts. It then becomes read-only. Later ordinary draft edits are preserved. A normal release item still publishes the latest ordinary saved draft. Two releases cannot act on the same entry and overlapping languages at exactly the same time; choose different times. ## Batch AI Open one content type, select entries or **Select all matching**, then choose **Batch AI**. Choose text fields and **Rewrite**, **Translate**, **Terminology**, or **Summarize**. Translation also needs different source and target languages; terminology needs your glossary in **Instructions**. **Generate proposals** freezes the current selection. Later matches are excluded. Generation requires permission to use AI and read the source content; it does not save entries. Each result retains its base revision and original values. Translation creates at most one proposal per selected translation group. Use **Review proposals** to compare the original text, edit a proposal, apply it, or reject it. Applying uses your current field, locale and content permissions, validation and version checks. If an entry changed after generation, the proposal is refused as stale. Editing and resolving a proposal compare its revision too, so one reviewer cannot silently overwrite another's changes. Translation can add a missing configured language through the normal versioned save. Leaving the page keeps pending work available through **Continue**. **Stop** cancels pending generation permanently; it preserves proposals already produced and an in-flight result. Transient provider failures can be retried up to three attempts; committed proposals are not generated again. Reopen **Batch AI** to review the latest run, or open an entry to see its retained proposals. AI credentials stay on the server. # Media library Source: https://dee-wan-docs.pages.dev/guides/media/ The media library holds a site's images and files. Each site has its own library. Another site's files are never shown or matched. ## Open the media library Open **Media** in the sidebar to manage the library. The same library opens as the **Media Center** dialog wherever you pick a file: - a media field of an item: **Media center**; - rich text: **Insert Image** → **Media Center**, or **Upload New** to upload straight into the text; - an image slot of a page section in the [page builder](/guides/page-builder/). If the library cannot load, press **Try again** without closing it. Pick one or more files and press **Use N selected**. A media field takes only the kinds of file its model allows, up to its own size. See the `media` row in [field types](/reference/models-and-fields/#field-types). Reading the library follows media read access. Changes need three separate permissions: | Permission | Allows | | --- | --- | | `media:upload` | Add files: uploads, files saved from AI generation, files an import or a plugin brings in, files copied from an external source, and private uploads. | | `media:update` | Change what exists: alt text and descriptions, focal points, loading and fetch priority, replacing a file, folders, tags and public or private visibility. | | `media:delete` | Delete a file. A file in use is still refused. | The built-in admin and editor roles hold all three. The author role holds `media:upload` only: an author can add files and cannot change or delete one. The viewer role holds none. A custom role holds exactly what it lists under **Settings → Roles**. Where a control is unavailable for lack of a permission, it is disabled and says which permission it needs. ## Upload files Press **Upload** and choose files. They go into the folder on screen. The dialog states the size limit. Files are public unless you keep them private. See [Private files](#private-files). ### File kinds and formats Each site chooses what its library takes under **Settings → Site → Media files**. Turning a kind off keeps the files already in the library. | Kind | Formats | Default | | --- | --- | --- | | Images | PNG, JPEG, WebP, AVIF, GIF, SVG | Always on | | Documents | PDF, Word, Excel, PowerPoint; also ODF text, spreadsheet and presentation, RTF, Text, CSV | On, with PDF and the Office formats | | Audio | MP3, M4A, Ogg, WAV; also FLAC, AAC | Off | | Video | MP4, WebM; also QuickTime, Ogg video | Off | | Other files | **Any other safe file** | Off | - Programs, scripts and web pages are never accepted, whatever the setting. - The format is checked from the file's bytes, not only from its name. - SVG is an image: select it in image fields, preview it and render it with ``. It stays vector. The static subset accepts shapes, text, gradients and internal `#id` references, up to 1 MiB. Scripts, event handlers, style sheets, external resources, animation, entities and unsafe XML are refused. SVG uses R2 even when raster images use Cloudflare Images; public SVG needs `IMAGES_BUCKET` and `R2_PUBLIC_DOMAIN`. - An installation without public file storage takes images only. ### Size limits Each kind has its own size limit in the same setting. Every kind starts at 32 MiB. - Images: at most 32 MiB. - Documents, audio, video and other files uploaded here: the limit can be raised to the installation's upload bound. That bound is 100 MB unless an operator declares a larger Cloudflare plan limit in `MEDIA_REQUEST_BODY_LIMIT_BYTES`, at most 5 GB. - Formats checked as a whole file (Word, Excel, PowerPoint, ODF, Text, CSV): at most 32 MiB. - Ordinary imports, plugins and management API uploads: at most 32 MiB per file. Native `.deewan` transfers copy files in chunks and follow the destination installation's file-type and size limits, up to 5 GB where enabled. See [Import and migration](/guides/import-migration/#transfer-a-site-with-its-history). ### Duplicates Uploading a file the library already holds uploads nothing. The dialog says **Already in the library** and offers: - **Use existing**: use the file already there. It keeps its own description and tags. - **Upload a separate copy**: store a second copy. **Do the same for other duplicates in this upload** applies your choice to the rest of the batch. ## Find files - **Search by file name or alt text**. - **File type**: All, Images, Documents, Audio or Video. - A folder, or **All media** and **Unfiled**. - A tag filter. **Untagged** shows images without tags. ## Folders Folders organize the library. They never move or rename a stored file or change its address. - Each file is in one folder or in **Unfiled**. - **New folder** creates one inside the folder on screen. Folders nest at most 5 levels. - A site holds at most 500 folders. A name has 1 to 80 characters and is unique under its parent, ignoring case. - **Folder options** holds **Rename**, **Move folder to** and **Delete folder**. - Select files and use **Move N selected to** to move up to 200 files at once. - **Delete folder** removes it and its subfolders after one confirmation. No file is deleted. Its files become **Unfiled**. ## Tags Tags are flat labels on images, separate from folders. 1. Select images. 2. In **Tags · N selected**, type a name in **Add a tag** and press **Add tag**. Remove a tag from the selection with its remove button. - A tag name has 1 to 64 characters and is unique in the site, ignoring case. - **Manage tags** renames and deletes tags. Deleting a tag never deletes an image. A tag in use asks first and names how many images carry it. - Tags are private to the admin. No public API serves them. ## Alt text and descriptions Select files and press **Edit Selected**. Each site language has its own tab. - An image needs **Alt text** in every language of the site, 1 to 125 characters. **Save** is refused until every language has it: **Every language needs alt text before this can be saved.** A tab without it says **alt text missing**. - Other files have an optional **Description** per language. - A content item's version stores one alt text per image, copied from the library when the item is saved. On the ordinary save it is the site's default-language alt text. The public API serves that stored value as `alt`. A library change reaches the public API after the item is saved and published again. ## Frame an image in content Framing decides which part of an image shows. It never changes the file. - In an item's media field, set a **Focal point** on the image: click the part that must stay visible, then **Use this point**. - When the field has an aspect-ratio rule, the crop follows the field's shape. - Framing belongs to that one use. The same image elsewhere keeps its own framing. - **Default focal point** in **Edit Selected** starts new uses of the image. Existing uses keep their own focal point and crop. - Only images can be framed. How a website shows framed images is in [images on a generated site](/guides/preview-and-client/#images-on-a-generated-site). ## Image loading In **Edit Selected**, **Fetch priority** sets the browser hint: **High**, **Low** or **Auto**. **Loading** chooses **Eager** for immediate loading or **Lazy** to wait until the image is needed. Leave priority on Auto unless the image needs an explicit preference. These settings do not change the stored file. ## Edit an image ### Crop and rotate into a copy 1. Select an image, press **Edit Selected**, then **Crop and rotate**. 2. Rotate with **Rotate left** and **Rotate right**. Drag the crop or its corners. On the keyboard, arrows move the crop, Alt and arrows resize it, R rotates. 3. Press **Save as copy**, or Enter. The copy is a new file with its own size and uses. It takes the original's alt text, folder and tags, and says **Edited from {name}**. The original and every place that uses it are unchanged. If the library already has the exact result, that file is selected instead. ### Replace the original **More actions** → **Replace original** writes the edit over the original file. - First it lists the items that use the image. Their published pages will show the edited image. - It also counts uses in places you cannot open. - When the new file has a different shape, it says how many items frame the image with a crop or focal point, so you can check them. A linked file from an external source cannot be replaced or edited. Scripts and agents use `POST /api/manage/v1/media/{id}/replace` with multipart `file` and an idempotency key. The media id and content references stay the same; the delivery URL may change. Use the returned URL. Published versions that pin an older object keep those bytes. See [Management API](/reference/management-api/). ### Change the image format **Image format** in **Edit Selected** converts an image to **WebP** or **AVIF**, or leaves the current format unchanged. The conversion runs in your browser. Saving it replaces the original, so it shows the same where-used review first. ## Where a file is used Before a replace or a delete, the dialog says where the file is used: **Nothing references it.** or **Still used by {count} content reference(s): {names}.** - A use in content you cannot read is counted, never named. - Older versions an item still keeps also count as uses. See [how many versions are kept](/guides/content-editing/#how-many-versions-are-kept). ## Delete a file Press **Delete image** (or **Delete file**) on the file. - Deleting needs `media:delete`. Deleting a private file removes it from private storage. - A file in use cannot be deleted: **This image is in use**. Update the content that uses it first. Retained versions can keep it in use until they are trimmed. - A file that starts being used during the delete is kept. - Deleting asks first and cannot be undone. ## Private files Files are public by default: anyone with a file's address can open it, and content can use it. A private file is stored apart from the public site, has no public address, and opens only through a link made for you. - **Needs private storage.** An operator binds a private R2 bucket as `PRIVATE_MEDIA_BUCKET` (see `backend/wrangler.example.jsonc`). It must be its own bucket, with no public domain or public access: the public media bucket must not hold private files. Without it, **Keep new files private** is not offered and **Make private** is disabled with **This installation has no private file storage.** Setup does not create this bucket and does not check that it is private. - **Upload privately.** Tick **Keep new files private** before choosing files. It applies to the uploads that follow, including large files uploaded as a stream. Files a plugin or an external source adds are always public. A site copy or history file brings private files in as private. - **See what is private.** A private file shows a **Private** badge in the library and in its details. - **Open or download.** Press **Open** or **Download** in a private file's details. Each press asks the server for a link that opens that one file of this site and expires after 5 minutes. The library's previews and the audio and video players ask for the same kind of link. Any member who can read the library can ask for one. Nothing else serves the file: the public media address does not hold it, and image transformations cannot reach it. If a private audio or video preview cannot load, choose **Try again**. If **Open** or **Download** fails, **Try again** repeats that action and keeps your edits. - **Protected image previews.** Private raster uploads create WebP thumbnails (up to 320 px) and previews (up to 1280 px) in the same private bucket. Each is at most 2 MiB. Delivery checks the parent file and uses its short-lived link; neither the source nor a preview has a public URL. Older images or failed local conversions fall back to the original. In file details, **Refresh private previews** retries generation; it needs `media:update`. SVG stays vector and uses its original. Site copies and generated application transfers carry existing previews with checksums. Replacing or deleting an unreferenced private object removes its previews too. - **Protected rich content.** A private model can use private images, galleries and downloadable attachments in ordinary rich-text fields, including rich children inside plugin blocks. The editor opens them through the same protected library delivery. Public models, page sections, snippets, social images and Site Chrome still refuse private media (`media_private`). A private model requires an API token and cannot become public while any retained version pins a private file (`model_holds_private_media`). API answers keep file identities and withhold storage locators. Generated applications read rich-content files through the owning row’s guarded file route; see the next point. - **Site copies keep private files private.** A site copy and a history file carry every private file with its bytes, checksum, details and visibility. The import stores each one in the destination's private storage, under the private provider, and never in the public bucket; undo removes it from there. A private model arrives private. If this installation has no private file storage, the check blocks with `private_storage_unavailable` before anything is written. Building the file needs read access to the media library (the same access that opens a private file) as well as `data:export` when the site holds any private file (`export_private_media_forbidden`); `media:update` is not needed. Access is checked again on every download. Content-only exports (CSV, JSON, XLSX) never depend on private library files. If private storage is not bound, a site-copy or history export is refused by name without building a file (`export_private_storage_unavailable`). No storage credential or bucket name travels. - **A generated application serves its own private files.** When you exit to your own repository with private files, they are copied into your own private bucket (never the public one) and the row's address column holds a `private-file:` marker, not a URL. The application serves a file at `GET /api//files//`, only to a caller who may read that row under the model's own access: your account sessions, your service credential or your read tokens. Membership of the CMS library proves nothing there. Each request is checked again; nothing expires and nothing is minted. The Worker reads the bytes from its `PRIVATE_FILES` bucket; the Node server reads them from the directory in `PRIVATE_FILES_DIRECTORY`, and without either it answers `503 private_files_unavailable`. See [Standalone backend reference](/reference/standalone-backend-api/). - **Duplicates.** The duplicate check compares every file in the library, public or private. For a rich-text field, use `GET /api//files//?media=` and add `&variant=thumb` or `&variant=preview` for a protected image variant. The generated `src/private-files.ts` exports `privateRichText(html, endpoint, rowId)` to replace stored private markers with these same-origin, session-protected URLs before display. It rewrites only identity-bearing media from that field; the route reads the owning row again for every request. ### Make a file private or public Select the file, press **Edit Selected** and use **Visibility**. It needs `media:update`. Changing visibility never just flips a flag. The server copies the file to the other store under a new key, checks that the copy has the same length and SHA-256 as the recorded original, and only then switches the file over. After that it removes the old copy. The control shows **Copying and checking the file…** while it works, then says what happened: - **The file is private now. The public copy was removed.** (or the reverse for public). - **The change is saved. The old copy was kept because retained history still names it.** - **The change is saved, but the old copy could not be confirmed removed. It may still be reachable at its old address until it is deleted from storage.** Copies a browser or a cache already holds are outside the library's reach. - **The file was not moved. {reason}** when the copy failed or did not match: nothing changed and the unfinished copy was removed. If it could not be removed, the message says so. Making a file private is refused while any content or retained version uses it (`media_in_use`); update that content first. A file kept by an external media source cannot be made private. ## External media sources A plugin can offer media sources. Then the dialog shows **Sources** beside **Library**. 1. Pick a source, search, and use **Show more** for more results. 2. Add a file: - **Add** copies the file into your library. It follows the same kinds, formats, size limits and duplicate check as an upload. It keeps the source's credit. - **Link without copying** keeps the file with the source. The library holds only a link to it. You can edit a linked file's details, tags, folder and framing as you would for any other file. Its bytes cannot be replaced or edited. Deleting it removes only the library entry. If the plugin is turned off or removed, existing content keeps the stored address, the details say **The source is not available now.**, and new links from it are refused. A copied file's credit is saved with the item and served by the public API as `credit`. Show it under the image. ## Generate an image with AI Press **Generate** in the library. 1. Describe the image. Choose the **Model** and the **Aspect Ratio** (9:16, 1:1 or 16:9) under **Settings**. 2. Press **Generate**. **Regenerate** and **Generate New** try again. Nothing is saved yet. 3. Press **Save** to add it to the library, in the folder on screen. The duplicate check applies. Generating needs `ai:generate`. Saving needs `media:upload`. A generated image is always public. The installation needs an AI provider set up. Generation counts against the site's hourly AI allowance. ## Next step Use files in [content](/guides/content-editing/) and on [pages](/guides/page-builder/). ## AI alt text and captions Open the image details. Choose a language and a vision model from the live catalogue, then choose **Suggest alt text and caption**. The image bytes go to the configured AI provider; private images stay private. Review and edit both suggestions. **Apply and save descriptions** replaces the saved text for that language. Discarding the suggestions keeps the author's text. Concurrent metadata edits block the apply. After an upload, the optional **Review AI descriptions after upload** control opens the selected files for review. Inference accepts JPEG, PNG and WebP images, up to 10 MiB. ## Asset fields and history Open **Media** in the sidebar to manage files without choosing one for an item. File pickers use the same library. In **Asset fields**, a site administrator can add up to 24 text, number, boolean, select, date, media or relation fields. **Localized** stores separate language values; other fields share one value. Fields start private. Enable **Public** only for values the website should read. Field configuration requires `site:settings`; changing a file's values requires `media:update`. Open a file's **Asset metadata**, choose a language and enter values. The editor's footer **Save** saves custom metadata together with descriptions and file options for the selected files. There is no separate metadata save button. **Metadata history** offers retained revisions. History keeps up to 50 revisions within 512 KiB for current values and history together; the oldest whole revisions are removed first. Current values are never truncated. Changing a field's type, configuration, language mode or public setting makes retained values unavailable for public projection and historical restore until you enter values under the current definition. Relations follow the current site's model, document and language access. Public values can reference only public media and published public entries. Private metadata and its history are absent from ordinary media responses and public content. Public content exposes declared values under `mediaMetadata`, keyed by asset ID. This metadata follows the asset's current revision, including when many entries reuse the file. Generated website artifacts capture the projection at generation time; regenerate to update it. Version-pinned file bytes, framing and descriptions keep their existing behavior. Native site copies carry asset fields, values and retained history, remapping carried entry and media references. A copy does not overwrite metadata on an existing destination file. Undo keeps a changed or still-used file with its metadata, and refuses dependent cleanup; this includes retained references and reference cycles. Field definitions still used by retained values stay too. Public-cache clearing failures are shown after saving. If metadata cannot be saved, the editor stays open with your draft and opens **Asset metadata** to show the error. Correct the values and press the footer **Save** again. If the fields or values changed elsewhere, choose **Reload** to discard the local metadata edits and load the current version, then enter your changes and retry **Save**. ## Localized originals Open **Media → Edit Selected → Localized originals**. Choose **Language**, then **Replace file**. **Remove original** returns that language to its fallback. Both actions require `media:update`. The asset ID, references and language-specific descriptions stay the same. Resolution order: requested language, configured language fallbacks, default original. Published versions pin their exact files. Replacing or removing a language original keeps files needed by retained versions. Private originals use the ordinary private-file access rules. Site copies and full-history transfers carry explicit original languages and retained files. Management operations: `listMediaOriginals`, `replaceMediaOriginal`, `removeMediaOriginal`. Replacement is multipart `file` plus `expected_object_id`; use the empty string when that language has no original. Removal names the current `expected_object_id`. A changed pointer returns `409 media_changed`. Default-original replacement keeps its existing operation. # Languages Source: https://dee-wan-docs.pages.dev/guides/languages/ Each site has its own languages, one default language, and a rule for what visitors get when a translation is missing. Set them under **Settings → Languages**. Changing them needs the site settings permission (`site:settings`). For the full public contract, see [locales in the public API](/reference/public-api/#locales). For field and address rules, see [models, locales and pages](/reference/models-and-fields/#models-locales-and-pages) and [item address](/reference/models-and-fields/#item-address). ## Add a language 1. Open **Settings → Languages**. 2. Open **Select language** and search for the language. **Show dialects of {name}** lists its regional variants. 3. Press **Add language**. A language can be added once per site. When the site has no default language yet, **Set as default** makes the new one the default. Adding a language changes no content. Items get the new language only when someone starts a translation. See [translations](/guides/content-editing/#translations). ## Choose the default language The default language is marked **Default**. Press **Make default** on another language to change it. - A read with no `locale` gets the default language. - Under the fallback rule, the default is the last language tried before the item's own primary language. - A new item starts in the default language. - Changing the default clears the site's cached public reads. If that fails, the screen says so, and readers may get the previous default for a while. - The default language cannot be removed. ## Choose what visitors get when a translation is missing Under **Missing translations**, set **When this language is missing**: | Choice | Rule | A visitor asking for a missing language gets | | --- | --- | --- | | **Show the default-language page** | fallback | The first published language in the chain below. The response's `locale` says which one. | | **Show page not found** | strict | Nothing. Lists leave the item out. A direct read answers 404 `locale_unavailable`. | Only published languages are ever served. A draft language never fills in for another. When nothing is set, the screen says **No behaviour is set, so this site's public API is not serving.** Pick one to repair it. ### Fallback languages With **Show the default-language page** and more than one language, **Fallback languages** appears. Each language can list languages to try, in order, before the default. 1. Press **Edit** on a language. 2. Under **If {language} is missing, show**, press **Add a fallback language** and choose one. 3. Order them with the move buttons. Remove one with its remove button. 4. Press **Save**. Before you save, the screen counts what visitors of that language would get: **Translated**, **Shown in {language}** and **Not shown**. - A fallback language's own fallbacks follow it. With `pt-BR → pt` and `pt → es`, a `pt-BR` visitor gets `pt-BR`, then `pt`, then `es`, then the default. - The screen warns when your change **Also changes what … visitors see** for other languages. - A list holds at most 8 languages. - A save is refused when languages would fall back to each other, when a language falls back to itself, lists one twice, or names a language the site does not have. - With no list, a language falls back to the default alone. - Under **Show page not found**, fallback languages are ignored. ## What visitors get A public read names its language with `?locale=`. Without it, the read serves the default language. - Under fallback, a read tries the requested language, its fallback languages, the default language, then the item's primary language. It serves the first one that is published. - Under strict, it serves only the requested language, and only while that language is published. - Each language of an item has its own address. A single-item read lists every live language and its address in `alternates`. - The editor shows the result for each language. For example, **Not live in Thai yet. Visitors see English.** or **Not live in Thai yet. Visitors get page not found.** The site determines the rule. A visitor or caller cannot change it. ## Field fallback To fill individual missing fields, open the model field's **Properties** and enable **Field fallback**. It is off by default. The field must be optional and translated: text, rich text, number, Boolean, date, select, JSON or component. Addresses, relations, media and shared or required fields do not offer this option. In the content editor, **Use fallback** removes that field's translation. **Enter translation** starts a local value again. Clearing a field is intentional: an empty value, `null`, `false`, zero or an empty list never borrows another language's value. With the site's fallback rule, an absent field uses the first published value in the fallback chain, then the item's primary language. Each source language is read from its own published version; a newer draft cannot fill the field. With the strict rule, field fallback is ignored. Public single-item reads, lists, filters, sorting, search and generated website pages use these effective values. The response's `locale` still names the item's served language. The editor keeps local values separate, so borrowing a value does not copy it into the translation. Management saves can remove a translation with `unset_fields: {"th": ["subtitle"]}`. Only opted-in fields are accepted. A field cannot be set and unset in the same save; field and language permissions still apply. Omitted keys in an ordinary partial save keep their existing values. ## Limit roles to languages A role can access every language by default. To limit it, open **Settings → Roles**, edit the role and open **Limit fields and languages**. - **Limit languages** picks the languages the role works in. It applies to create, update, delete, restore, review, publish and unpublish. - **Can read every language** keeps reading open, so a translator can read the source language while editing only their own. - An action outside the role's languages is refused with `locale_denied`. An action that spans languages, such as publishing a whole item or performing a bulk action, is refused in its entirety. - Shared fields, shared relation lists and media live on the primary language. Changing them needs the right to update the primary language. The full rules are in [limit a role to some fields or languages](/guides/people-and-access/#limit-a-role-to-some-fields-or-languages). ## Work on one language These actions change one language and leave the others as they are: - **Start a {language} translation** adds a language to an item. - **Publish {language}** and **Unpublish {language}** in the item's **Publishing** panel. - **Move this language to Trash**, and restoring that language from Trash. - **Restore {language} only** in the item's History. - Each language's own address. - Relation fields with **Different links in each language**. See [different links in each language](/reference/models-and-fields/#different-links-in-each-language). - Alt text and descriptions per language in the [media library](/guides/media/#alt-text-and-descriptions). Actions marked **All languages** move every language of the item. A move between two unpublished stages, such as **Approve · All languages**, leaves a language that is already published live; take it off with **Unpublish {language}**. See [one language or all languages](/guides/content-editing/#one-language-or-all-languages). ## Remove a language Press **Remove** on the language. The confirmation counts what the language holds: content items written in it, translation groups whose primary language it is, and media descriptions. - Removing takes the language out of the authoring controls. Nothing stored is deleted. - Published content may keep serving this language until it is changed and published again. - The default language cannot be removed. Make another language the default first. - A language that is a fallback for others cannot be removed: **{language} is a fallback for {languages}. Remove it from their lists first.** - If what the language holds changes before you confirm, the removal is refused. Review the new counts and try again. - New content cannot be created in a removed language, or in one the site never had (`unknown_locale`). A create request that runs while the language is being removed writes nothing (`language_changed`). ## Fix common problems | Problem | Cause | Fix | | --- | --- | --- | | The public API answers 500 `site_locale_policy_invalid`. | No missing-translation rule is stored. | Choose one under **Missing translations**. | | A read answers 404 `locale_unavailable`. | The item is published, but in no language the rule may serve. Under strict, only the requested language counts. | Publish that language, add a fallback language, or switch to **Show the default-language page**. | | Visitors see another language. | The fallback rule applies, and the requested language is not published. | Check the response's `locale`. Publish the translation. | | **Remove** is refused. | It is the default, or a fallback for another language. | Change the default, or edit the fallback lists. | | A fallback list does not save. | A loop, a repeat, an unknown language or more than 8. | Fix the list as the screen says. | | Adding a language to an item is refused with `language_in_trash`. | That language of the item is in Trash. | Restore it from Trash. | | A translator cannot publish their language. | The role lacks publish in that language, or the action spans languages. | Use the language's own button, or extend the role. | ## Next step Translate and publish items in [edit and publish content](/guides/content-editing/). # Build and edit pages Source: https://dee-wan-docs.pages.dev/guides/page-builder/ Pages are structured documents with a title, facts and up to 24 sections. You type on the page itself, pick sections from a visual gallery and set each section's style in a side panel. Unsaved changes are kept as the page's draft until you save them as one version. ## Open or create a page Open **Pages** from the main navigation. The library lists the site's pages, most recently changed first. Each row shows a small picture of the page's sections, its address, when it changed, whether it is **Live**, **Live · edited** or a **Draft**, and how many of the site's languages it has, for example "2 of 3 languages · Missing TH". Search by title or address, and filter by **All**, **Live** or **Draft**. A row's **⋯** menu opens, renames, duplicates or moves the page to Trash. A duplicate is an unpublished copy of every language. **Trash**, beside **New page**, lists pages moved there in the last 30 days, every language together. **Restore** brings one back as a draft with its history and opens it. **Delete permanently** removes it for good after asking. A live page is unpublished first; Move to Trash is not offered while it is live. After 30 days a page in Trash is deleted for good. ![The Pages library with search, the status filter and one live page](/assets/screenshots/pages-library.webp) **New page** offers three starts: - **Start from template**: Landing, About, Service, Event, Menu, Pricing or Blog post. A template adds empty sections for you to fill in. It never adds sample text. - **Describe it to AI**: say what the page is for and press **Generate page**. The four examples (landing, about, service, event) only fill the prompt. - **Blank page**: start empty and add sections. Template and blank pages ask for a page name. **Page address** is optional. Without one, the address is made from the name's Unicode letters and numbers, including Thai and Cyrillic, with spaces replaced by hyphens. A name with no letters or numbers opens **Page address** and asks you to type one. **Page address** takes at most 180 characters. ## The top bar The top bar holds: - the page name, with a pencil to rename it - one status for this language: **Live**, its own workflow state such as **Draft** or **In review**, or **Not published** when its state says published and nothing is live - a language choice, when the page exists in more than one language - the save state (**Saved** or the number of unsaved changes) and **Save** - **Preview**, only when the site has a verified renderer (see [Site preview](#site-preview)) - **View page** once this language is published and the site has a public address. It opens the live page in a new tab - the next workflow step. With unsaved changes it saves first, for example **Save and publish** or **Save and submit for review**. Review is never skipped - **⋯** (More page actions). **⋯** holds **Ask AI to change the page**, **Site preview** when available, **Page settings**, **Discard changes** while you have unsaved work, and a step that takes the page off the site, such as **Unpublish**. On a narrow screen, **Preview** and **View page** move into this menu too. ## Edit on the page The canvas is a **Structure preview**: section types, order, images, style and arrangement are exact. Typography, colour and theme may differ from the site. **Phone**, **Tablet** and **Desktop** above it set the canvas to 390, 768 or 1024 pixels wide. Choose the active one again for the full width. When the section panel leaves less room than the chosen width, the canvas keeps that width and scrolls sideways. A phone shows no width choice. ### Site design The canvas offers **Site design** and **Structure preview** only when all of these hold: - the site has an active managed release; - the installation has the render signing key (`MANAGED_RENDER_HMAC_KEY`, 32 or more characters); - the site's own bound host, as `https://`, is the address that release was made for. **Site design** draws your unsaved working page through that release's own template, so fonts, colours and spacing are the site's. Otherwise the editor shows the Structure preview only. - The render is private. It goes from the editor to the site's renderer and back, and is never stored, cached or published. - The drawn page keeps up with your edits. Typing waits for a short pause. Adding, removing or moving a section redraws at once. - Click a section or a text value in the rendered page to select it. The section panel opens at that field. Selecting a section or field in the panel highlights it in the drawn page. - Esc in the drawn page returns to the Structure preview with the same selection. - A failed render never touches your working page. The last drawn version stays with a note such as "Not updated. Showing the last design." **Retry** appears when trying again can help: a lost or slow answer, a renderer fault, or a render that did not start. A page the design cannot draw (an empty required field, something the release cannot show, a missing translation, a page too large) is not retried: change the page or switch releases. **Structure preview** is always one press away. - A page with an empty name, no sections or an empty required field is not sent. The note says what to fill in. **Site design** is different from [Site preview](#site-preview): Site preview shows a _saved_ version through a custom renderer. A managed release is prepared and activated in Settings → Source & delivery → Site template (see [Managed site templates](/guides/managed-templates/)). A site without an active managed release sees the Structure preview only. Click a heading, paragraph, list item or button label on the canvas and type. Bold, italic and linked words keep their formatting while you type around them. Empty required fields show a placeholder such as **Add heading**. Controls for everything you cannot edit by typing are in the section panel beside the canvas (at the bottom of the screen on a phone or tablet): links, images, adding and removing list items, paragraph formatting and style. ![The page editor with the first section selected and its panel open at Style](/assets/screenshots/page-editor.webp) A selected section shows a small toolbar above its content: drag handle, move up, move down, duplicate, Ask AI (the sparkles button) and **⋯**. The section's **⋯** holds **Change section type**, **Duplicate section**, **Copy section**, **Paste section after this one** and **Remove**. A copied section can be pasted on any page of this site. **Sections N / 24** opens the section list, where the up and down buttons and the drag handles also reorder sections. Dragging is optional. The buttons and keys do the same. ### Add a section Use **Add section here** on the canvas, **Add section** in the section list, or press / or Ctrl/Cmd+K. The gallery shows a picture of every section type in each of its visual variants, and patterns such as **Page header**, **Big statement**, **Quote** and **FAQ**. Search by name or description. The section you pick enters the page at once, with its required fields empty and the cursor in the first one. Save stays unavailable until those fields are filled. | Group | Section types | | ------------------- | ---------------------------------------------------------------- | | Openers | Hero | | Text | Text, Text with image | | Lists & proof | Feature grid, Cards, Stats, FAQ, Testimonials, Logo strip, Steps | | Media | Gallery and video | | Pricing & menus | Pricing, Menu | | Contact | Contact details | | Posts & collections | Latest entries (pattern: **Latest posts**) | | Forms | Form | | Custom | The sections your developer registered for this site | | Calls to action | Call to action, Site search | | Shared sections | This site's shared sections, by name | **Latest entries** lists the newest published entries of a public collection. The **Latest posts** pattern fills it in for a Blog's posts. Choose the collection, fields and count in the inspector; see [Blog sites](/guides/blog/#latest-posts). **Form** collects what visitors type. In the inspector you set its fields (label, kind, required, help text and, for a choice, its options), an optional consent statement, the button text and the message shown after sending. Where submissions go is not part of the page: set it in **Settings → Integrations → Website forms** (an email address or a signed webhook). Forms take no submissions until that is switched on. On the canvas, select the form and use **Preview** to see each state a visitor can encounter: sending, needs fixes, already received, sent, too many tries, not sent and not taking submissions. For files, choose **File upload**. Set **File types** (for example `.pdf, .png`), **Maximum files** and **Maximum size per file**. Empty file types allow all supported images and documents. Uploads are bounded to 5 files and 10 MiB per submission, with at most 5 MiB per file. Files stay private with the submission and are downloaded from its receipt; see [File attachments](/guides/website-forms/#file-attachments). **Custom** sections are ones a developer registered through the site design (see [Managed templates](/guides/managed-templates/#registered-sections)). Each is a fixed version: its fields come from that version, and the canvas shows the values with a note that the site design draws the final look. When a newer version is available, the inspector offers **Update section**. Values the new version still accepts are kept and the rest start empty. Updating changes this language only. **Site search** puts the site's search box on a page, with an optional heading and the text shown inside the box. Visitors get results in the page's language. It is offered only while site search is on in Settings, whichever design draws the site. If search is turned off later, a page that still holds the section cannot be saved until it is removed. **Saved patterns** come first in the gallery when the site has any. A saved pattern holds sections with their words, saved from a page. Inserting one adds a copy: later changes to the pattern never change a page that already used it. To save one, open a section's **⋯** menu and choose **Save as pattern…**, then name it and tick the sections to keep. **Manage saved patterns**, below the gallery, renames or deletes them; pages keep their copies either way. **Shared sections** keep one piece of content in one place and show it on many pages: an announcement, a promotion, footer content, a call to action. Make one under **Pages → Shared sections → New shared section**. It opens in this editor: add up to 6 sections (no form, site search, custom section or other shared section), then save and publish it like a page. It has no address of its own, and the editor shows **Used on N pages**. Pick one from the gallery's **Shared sections** group to add it to a page. The page keeps a link to it, not a copy: - The canvas draws it as it is published, marked **Shared**, with **Edit shared section**. Change it there; once you publish it, every page using it shows the change. A draft never shows on a page. - **⋯ → Make a separate copy** turns it into ordinary sections in the current language, using the content shown in that language. Other languages keep the link. Later changes to the shared section no longer reach the copy. - A page cannot be published while a shared section on it is unpublished. The quality list says which one. - Unpublishing a shared section lists every page using it and asks you to confirm. Those pages then show nothing in its place. - A saved pattern cannot hold a shared section. Copy and paste keeps the link on this site only. The gallery offers only what the site's renderer can show. Without a renderer, every type is offered, except Site search while search is off. ### Reusable components A reusable component is a section you made once and can add to any page of this site. It is made from a section on a page, built in isolation and reviewed before anyone can use it. **Make one.** Open a section's **⋯** menu and choose **Save as reusable component…**, name it, and optionally say what should change, for example "larger heading". The section's words become fields people fill in each time it is used. This needs the `site:deploy` and `ai:generate` permissions. A section made of words and images can be saved; a form, site search, latest entries, custom or shared section cannot, and a section with missing fields must be completed first. AI writes the component and its fields, the platform refuses code that imports anything but the page contract or accesses the network, and the build then runs with the network off. It takes several minutes. If no answer arrives the form says the component may still have been made. **Review it.** **Pages → Components** lists every component with each version's state: **Awaiting review**, **Ready to use**, **Rejected** or **Removed**. **Review** shows the 8 build checks, the fields it asks for, what it imports, a preview of its own sample content (every field filled, or only the required ones) and, with `site:infra`, its source code. **Use this component** needs `site:deploy`. The same person who made the component may approve it; there is no second reviewer. If the site's design changed after the build, **Use this component** is unavailable until you choose **Rebuild**. **Reject** keeps the record and cannot be undone. Using a component changes no page. **Use it.** A component that is ready appears in the gallery with the other custom sections, under its name. The page keeps the exact version it was given. A newer version never changes a page: the section panel offers **Update section**, as for any custom section, and updating takes the newer version of that component. A page that uses a component is stored in page format 3 (see [Page contract](/reference/page-contract/#reusable-components-format-3)). **New versions and copies.** In the Components tab, **⋯** on a component with a ready version offers **Make a new version…**, which asks AI to change the current code, and **Copy as a new component…**, which starts a separate component from the same code. **Copy to site…** copies the ready version to another site where you have permission to build (the list shows only those sites). You need `site:infra` here to read the component's source and `site:deploy` in the other site; AI is not needed. The copy is a new component there, with its own name and key, built and reviewed in that site like any other and awaiting review there. It shares nothing with this site: no page, no pinned version, no build. This site's component and pages are not changed. Only a version that was reviewed can be copied. If an answer is lost, try again: the same copy is returned instead of a second one. **Limits.** The site's design holds at most 16 custom sections and components together; every older version still in it counts. Making components needs the same setup as template candidates: AI, the isolated build service, its sandbox image and artifact storage. Without them the tab says what is missing and nothing is built. A build that no version of any page pins, and that is neither the accepted design nor part of a release, is removed from storage 90 days after a daily check first finds that nothing references it (a version never promoted counts its 90 days from when it was made or rejected). A page that pins a removed version cannot be published, and a page version that pins one cannot be saved or restored; the message names it. A removed version cannot be used or approved again, and making the same content again is refused by name. Save the section as a new component under another name, or change its content. ### Style The section panel shows a style line, for example "Side by side · Muted · Roomy". Click it to choose: - **Look**: the section's layout, shown as pictures, such as **Centred**, **Side by side** or **Full image** for a hero - **Image position** for a text-with-image section - **Background**: Default, Muted, Dark or Accent - **Spacing**: Default, Compact or Roomy - **Motion**: None, Reveal or Parallax. The canvas names the motion. The site plays it. Style applies to the language you are editing. ### Links, images and text A link goes to a page of this site (search by name or address), a web address or an email. Paragraphs support bold, italic, links and bulleted or numbered lists from the toolbar above them. An image comes from the media library. **Add alt text** saves the description to the media library for this language. ## Undo, save and the quality check Ctrl/Cmd+Z undoes and Ctrl/Cmd+Shift+Z (or Ctrl+Y) redoes up to 100 changes to the page since the last save. Facts and required wording are not included in undo history: undo leaves them as they are, but they are saved, discarded and kept in the draft with your other changes. Alt text saved to the media library stays saved. Typing in one field counts as one step. History clears when you save or discard. With a section selected and no text field focused: Alt+↑/↓ moves it, Ctrl/Cmd+D duplicates it, Delete or Backspace removes it after asking, and Esc deselects it. **Save changes** or Ctrl/Cmd+S creates one version from all your changes to the language you are editing: the page name, facts, required wording, sections and the settings that enable layout controls. It never changes another language. Either all changes are saved or none are: a refused save writes nothing and keeps your changes. If the answer is lost, or the server answers in a way the editor cannot read, the save may have happened. The save state reads **Save outcome unknown** and **Retry save** sends the same save again. It is replayed, never saved twice. The same holds for creating a page, applying AI changes and setting the first address. The address, workflow, publishing, preview and deleting the page are separate actions, not part of **Save changes**. **Discard changes** asks first, then returns to the saved version. Removing a section shows a notice with **Undo**, which puts the section back without saving anything, and **View history**, which opens the saved versions. Below the top bar, **Finish before saving (N)** opens by itself while something must be fixed. N counts every row. **Check the page** lists them, for example "2 to fix · 1 suggestion". When only suggestions remain it reads **Suggestions (N)** and stays closed until you open it. Each row takes you to its section and field. - To fix (Save is unavailable): an empty required field, text that is too long or contains `<` or `>`, an unusable link, a site search section while site search is off. - Suggestion (never blocks): an image without alt text, a link to an address that no page on this site uses, a section that holds only a heading, another language of the page not translated yet. The language row opens that language. An empty page name also blocks Save. It is listed under the top bar. ## Drafts and editing together Unsaved changes to a page are kept on the server as that language's shared draft, for 30 days after the last change. The draft belongs to the page and language, not to you: other people who may read the page can see it. - **One editor at a time.** The first editor to open a page language holds its editing lease. Only that editor writes the draft, saves, or moves the language through the workflow. The lease is renewed while the tab is visible, ends when the tab closes, and lapses two minutes after the last renewal. Imports, plugins and scheduled publication are not blocked; the version check still protects the editor's save. - **Others read along.** Another person who can edit the page sees "\ is editing this." The editor is read-only and follows the holder's draft: it checks for a newer draft every 10 seconds while the tab is visible. Your own second tab says "You are editing this in another tab." - **Who can see it.** A person who may only read the page sees the draft as it was when they opened the page. Seeing a draft needs read access to the page itself: a role that reads only its own pages sees a draft only for a page language it owns. A role that cannot read the page title gets the draft with an empty title, and a role that cannot read the page's sections gets no draft at all (`field_denied`). This holds for every draft answer, a conflict included. - **Take over** moves the lease to you at once, and you go on from the holder's shared draft. They see "\ took over." and their next save is refused. Their unsaved changes are also kept as their own recovery draft, and their draft state reads **Not saved — taken over**. An autosave or a discard they send after the takeover is refused and changes nothing. A refused discard says **Not discarded — taken over. Your work is kept.** **Back to list** leaves without taking over. Holding or taking over a language needs your role to change that language (`locale_denied` otherwise), and a role that edits only its own pages must own that language's page (`not_owner` otherwise). - **Recovery drafts are private.** Only you see your recovery draft. When you hold the lease again, the editor offers it: **Restore changes**, or **Review changes** if the page was saved in the meantime. **Discard** drops it. - **AI drafts are private.** An AI answer you have not applied, and an AI request for a page that does not exist yet, are kept as your own drafts. No one else sees them. The top bar says **Saving draft…**, **Draft saved**, or **Draft not saved** with **Retry**. A draft that cannot reach the server stays in this browser (**Offline — kept here**) until it can. A shared draft made on the version on screen opens with the page. One made on an older version shows **Unsaved changes from an older version** with **Review changes** or **Discard**. If the server's draft changed while this browser held changes it had not sent, choose **Use the other device's** or **Keep this one**. If someone saved first, the review lists what overlaps and never saves on its own. **Save changes** removes the draft. A draft is not a version and never appears in the history. ## Ask AI for a change Choose **⋯** → **Ask AI to change the page**, or the sparkles button on a section. The Ask AI panel floats at the bottom of the screen. Choose **Whole page** or **Section N**, describe the change, and press **Generate changes** (**Generate section changes** for a section) or Ctrl/Cmd+Enter. A page with no sections shows the request box on the page itself, with **Generate page**. For a section, **Shorter**, **Friendlier**, **Fix spelling** and **Fill empty fields** fill in a request for you. They do not send it. **Recent** lists the last 20 requests you sent from this browser, newest first. Choose one to fill the box; it is not sent. **Clear recent** empties the list. The list belongs to this browser, this site and your account. It is never sent to the server. ![The Ask AI panel for one section, with the one-click requests](/assets/screenshots/page-editor-ai.webp) AI works from the page as you see it, including unsaved changes, plus the site settings and the page's saved facts. You can type a request at any time, but it is not sent while a required field is empty or while facts have unsaved edits. The panel says why, and **Save changes** saves the facts first. It only uses section types and styles the site's renderer can show. The answer is a proposal shown on the canvas. Switch between **Current** and **Proposed**. Inserted, changed, moved and removed sections are marked, and **Previous change**/**Next change** walk through them. You may keep some text changes and drop others. An answer that adds, removes or moves sections is applied or discarded as a whole. Applying the proposal puts the accepted changes into the page, together with your unsaved edits, and saves everything as one new version. The answer stays until that save succeeds or you discard it. If the save is refused, for example because the answer omits required wording from the facts, nothing is saved: the page goes back to how it was before you applied the proposal, your own unsaved edits stay, and the answer is still there. If the changes would leave the page with something to fix, nothing is applied. If another save completed first, the changes are kept for review. An answer that has not been applied is kept as your own draft. After a reload, or on another device, **Show it** brings it back; it is never applied until you choose to apply it. ## Page address A page without an address can be saved as a draft but cannot be published. The editor shows "This page needs an address before it can be published." with an **Address** field and **Set address**, and the publish step names the same reason. Save or discard your page changes first: the field is unavailable while there are unsaved changes. An address another page already uses is refused. **Set address** sets the first address. People who cannot edit the page see that someone who can has to give it an address. To change an address later, open **⋯** → **Page settings** and press **Change** beside the address. The change is saved as a new draft version. The live page keeps its old address until that version is published; publishing adds a redirect from the old address (see [Search and sharing](#search-and-sharing)). ## Page settings **⋯** → **Page settings** opens a panel with the page address, **Facts**, **Workflow**, **Version history** and **Move to Trash**. Facts are generation inputs, not text pasted into sections. **Required wording** must appear exactly as written: an AI proposal that leaves it out is refused, and so is a save of a page without it. Editing facts or required wording changes the page like any other edit: there is no separate save in the panel, and **Save changes** saves them with the rest of the page. ![Workflow settings with review enabled and the version-history limit](/assets/screenshots/workflow-settings.webp) Review is a site setting under **Settings** → **Workflow**. The page's **Workflow** panel shows its assignment, its history and the actions the workflow allows. Publishing exposes a saved version. On a page with more than one language, each language has its own state and its own actions, such as **Publish Thai** or **Unpublish Thai**; they move that language only. The other actions move every language, such as **Publish all languages**. One language can stay live while another is in review. An all-languages move between two unpublished stages, such as an approval, leaves a language that is already live as it is; take it off with its own **Unpublish** action. Drafts and unapplied AI proposals are never published. A page that uses something the site's design cannot draw is refused, and the message names it: a custom section version the active design does not include. ## Site preview Without a verified renderer, the editor shows the Structure preview only and offers no **Preview** or **Site preview**. Publishing works. The editor cannot verify how the site renders the page. Editing never depends on a renderer. **View page** does not need one: it opens the published page at the site's public address. A renderer is verified under **Settings** → **Source & delivery** → **Advanced — custom renderer** (see [Preview and the generated client](/guides/preview-and-client/)). Its address must use HTTPS. Plain HTTP is allowed only for a loopback address such as `localhost`. Once it is verified, **Preview** opens the saved page in a new tab. When the renderer also supports the editor, **⋯** → **Site preview** shows a saved version as the site renders it and names it, for example "Saved version 7 as the site renders it". The site marks it "Preview of a saved version. The live page may differ." Unsaved changes are never sent to the renderer, and the preview says when you have some. After a save it keeps the previous render on screen until the new version is ready, and keeps it with **Retry** when a refresh fails. Select a section in the preview to edit it. **⋯** → **Back to editing** returns to the Structure preview. The last render is kept while you edit, so coming back to **Site preview** shows it again even when the next refresh fails. ## Site menus, footer and SEO **Settings** → **Generation** → **Site context** contains the shared inputs used to generate pages. It has three documents: **Site brief** (approved facts, audiences, voice, terminology, calls to action, required legal wording and forbidden claims), **Theme** (colour, typography, spacing, motion and accessibility tokens, and the section shapes the renderer may use) and **Menus & SEO**. **Menus & SEO** has: - **Menus**: lists of links. **New menu** makes one; **Edit** opens it. A menu's **Name** is the same in every language. Each language has its own items: labels, order, nesting and which items exist may differ. Each item has a **Label** and **Goes to**: a **Page**, **Post**, **Category**, **Tag** or **Author** found by name, the **Blog** list, a **Web address** (https), an **Email**, a **Phone** number or a **Site path**. A top-level item can be **Nothing (heading)** over the items under it. Drag an item by its handle, or focus the handle and press the up and down arrow keys, to reorder it; **⋯** has **Move up**, **Move down**, **Add item under** and **Remove** - **Where menus appear**: which menu shows at the **Top of every page** and at the **Bottom of every page**, per language - **Footer note**: a line at the bottom of every page, per language - **Regions**: places your template shows on every page. The built-in template has **Above the page content** and **Below the page content**; the template decides where each appears. Per language, **Add…** puts a menu, a shared section or **Search** in a region; on a Blog site also **Recent posts** (choose how many, up to 10), **Categories**, **Tags** and **Posts by month**. **Posts by month** links the 30 newest posts at most, grouped into 12 months at most; a month has no page of its own. A shared section with **Latest entries** shows the newest published entries in the page's language, as it does on a page: a managed site reads them on each request (at most 8 different lists per language; a later one shows its refusal), and a generated site pins them when it is generated. A Website rejects Blog items with a specific error (`region_item_unavailable`). A region holds up to 8 items and shows each one once: one **Search**, one of each Blog list, and a given menu or shared section once (`duplicate_region_item`). Move items up or down, or remove them. A region with no items shows nothing. A language without its own list shows the one its language rule serves (**Same as \.**); its first change starts its own list, and **Use language rule** drops it again. A managed site that has accepted no template has no regions yet. A list for a region the template no longer has is marked **Not in this template**; **Remove** drops it - **Route names**: a name and a path for each route - **Default SEO**: **Title template**, **Site description** and **Default social image** (chosen from the media library; used when a page sets no image of its own) - **Who runs this site**: an organization or a person, a **Name**, a **Logo** (a **Photo** for a person) and up to 8 https **Profile links**. Search engines can show them with the site; they go into each page's structured data (see [Search and sharing](#search-and-sharing)) - **Search engine verification**: the code Google Search Console, Bing Webmaster Tools, Yandex Webmaster or Pinterest gives you. Paste the code or the whole meta tag; only the code is kept - **Sitemap and robots**: **Publish a sitemap** (every live page that is not hidden from search engines), **Change frequency** and **Paths to disallow** - **Redirects**: an **Address** on this site and **What happens** there (see [Redirects](#redirects)) **Language** switches the language whose menu items, placements and footer note you edit. A language with no version of a menu yet says so and shows what visitors see there now. **Copy** starts it from another language's items (they get new identities); **Start blank** starts it empty. **Delete \ version** removes that language's items after you confirm; other languages keep theirs. A language without its own placements uses the ones its fallback gives it and says **Same as \**; changing one gives it its own. A language with no version of a menu, no menu placements or no footer note follows the site's language rule (**Settings** → **Languages**): with fallback, it shows the first language along its fallback chain that has one; with strict, it shows nothing. The site marks a menu or note shown in another language with that language. **Also shown in** names the languages that see a version through fallback. A menu item names the page, post or archive it goes to, not its address. The reading language's live address is used when the site is read, so a moved page keeps its menu link. An item whose page is not live yet is hidden on the site and says **Not live yet — hidden** here; **Hidden in** names the languages it is missing from. Every item needs a label. A generated site gets its menus when it is generated. A managed site reads them on every page, so saving and accepting a menu change, or publishing a page, needs no new release. Regions work the same way. A generated site's layout places `page_start` and `page_end`. What they show is resolved at generation and pinned in `apps/site/src/lib/chrome.json`; published changes reach the site with the next generation. A Latest entries section there reads public models only; a private model shows `collection_model_missing`. Its summaries embed content only from origins accepted at generation. A managed site reads them on every page. ![Settings, Generation, Site context: Menus & SEO](/assets/screenshots/generation-context.webp) **Save a new version** keeps a version, with an optional note in **What changed (optional)**. It does not change what pages are generated from. **Use this version** in **Version history** accepts a version: later generations use it, and pages that already exist keep the version they were written from. If someone else saved first, nothing is saved and **Reload** shows their version. Editing these documents needs the site settings permission. Without it they are read only, or not shown at all. ## Search and sharing **Search and sharing** sits under the page address in **Page settings** (the page menu) in the page editor, and under the fields in the content editor. It is closed until you open it. It sets, for the language you are editing: - **Search title**: replaces the title template for this page. About 60 characters show in results. - **Search description**: about 160 characters show in results. Without it, a `description` field is used, then the Site Chrome **Site description**. - **Social image** and **Image description**: shown when the page is shared. Without it, the Site Chrome **Default social image** is used. - **Canonical address**: an `https` address or a path on this site, only when another address serves the main copy of this page. - **Hide from search engines**: the page asks not to be indexed, leaves the sitemap and is not listed as another language of its translations. - **Page type**: **Ordinary page**, **About page**, **Contact page** or **Article**. Search engines read it from the page's structured data. Nothing here is filled in from the page's text or images. **Save search settings** saves a version like any other save. The live page changes only when that version is published. Save or discard other changes on the screen first. The management API takes the same `seo` key on an entry save and on a page save. A site copy and a history transfer carry the overrides of every version (bundle 1.7), with the social image moved to the copy's media. Delivering a standalone backend moves social images, the default one included, to the adopter's media storage with the rest of the site's images. Both renderers write the same head from these values: the meta tags, each verification code as its engine's meta tag, and one `application/ld+json` block. A home page carries `WebSite` and the organization or person; a Blog post is a `BlogPosting` with its date, cover and authors; any other page is its **Page type**. A page hidden from search engines carries no structured data. Nothing is stored as JSON-LD or script: the renderers build it from the values above. **Sitemap.** `/sitemap.xml` is an index. Each model's live pages are listed in chunks of 5,000 at `/sitemap--.xml`, each page with its last publication and up to 20 images (its social image, then the images it shows). Hidden pages are left out. On a generated site, page chunks are written at generation and a Blog's posts are read live. `/robots.txt` names the index when indexing is allowed and the site has an origin. When a publication moves a live address, for example after the slug changes, publishing adds a permanent redirect from the old address to the new one in Site Chrome and accepts it. The workflow panel says "Redirect created" with both addresses and a link to **Menus & SEO → Redirects**. A redirect that already pointed at the old address is pointed at the new one, so no visitor follows two hops. When an entry moves back to an address its own redirect held, that redirect is removed. No redirect is made, and the panel says why, when the old address is now used by another page or route, when the redirects would loop, or when Site Chrome already holds 256 address redirects. The publication itself still succeeds. Unaccepted Site Chrome changes are kept and get the same redirect. A draft address change never moves the live page or makes a redirect. Publishing at an address another redirect holds is refused: a live page would silently disable that redirect. The workflow panel names the address and offers **Remove redirect**, then publish again. When Site Chrome has unaccepted changes, remove it in **Menus & SEO → Redirects** instead. ## Redirects **Menus & SEO → Redirects** lists each **Address** on this site and **What happens** there: | What happens | Status | Send to | | --------------------------------- | ------ | -------------------------------------------- | | Moved permanently | 301 | a path on this site or an `https` address | | Moved for now | 302 | the same | | Moved for now, keep form data | 307 | the same | | Moved permanently, keep form data | 308 | the same | | Removed | 410 | none: the site's error page answers with 410 | | Unavailable for legal reasons | 451 | none: the site's error page answers with 451 | **Advanced: patterns** holds rules for many addresses at once. `{name}` matches one part of an address and a final `{name*}` matches everything after it; **Send to** may reuse the names, but never in the host. Examples: `/news/{slug}` to `/blog/{slug}`, and `/{rest*}` to `https://old.example.com/{rest}` for everything the site does not serve. Rules are applied in this order: a live page always wins, then an exact address, then the pattern with the most fixed parts, a one-part pattern before a catch-all. A site holds up to 256 addresses and 32 patterns. Saving refuses, by name: - a redirect from an address a live page uses, or one the site serves itself (`/sitemap.xml` and its chunks, `/robots.txt`, search, the feed, `/preview`, `/owned/…`, the Blog list and archives); - redirects that can lead back to where they started, through any mix of addresses and patterns (for example `/{rest*}` to `/archive/{rest}`). A second hop that ends is allowed: `/news/{slug}` to `/blog/{slug}` beside a catch-all to another domain works, one redirect at a time; - two patterns that can match one address when neither is more specific. **Check covered pages** lists the live pages each pattern covers. They keep working; the pattern answers there only when no live page occupies that address. Saving a new or changed pattern needs that check: when the check is out of date, saving stops, shows the covered pages, and **Save with these patterns** saves. A generated site pins the accepted rules in its artifact (`src/lib/redirects.json`) and answers them from its on-demand 404 route. A managed site reads `GET /v1/_redirects` and keeps the answer for 30 seconds. Both run the same matcher, so a rule answers the same way in either. ## Languages and permissions Each language owns its page: its sections, their order, type, look, style and words. Adding, removing, moving, changing the type of or restyling a section, and every save, change only the language you are editing. Two languages of one page may look completely different. A language with no page yet shows **\ has no page yet** with two choices: - **Copy** starts it from the language chosen beside it: the same sections, order, look and words, under new section identities. A shared section stays a link to the same shared section. Then edit the copy; the source language does not change. - **Start blank** starts it with no sections. Either choice saves one version holding that language only. Nothing is copied until you choose. In history, a version with several languages offers **Restore \ only**: that language gets its sections and page name back from the version, and every other language stays as it is now. **Restore** still brings back every language of the version. Neither changes what is live. A refused save changes nothing: no language, fact, page name or version. People who may only read a page can open it and its history but get no editing controls. Page settings, AI writing, facts, publishing and renderer settings keep their own permission checks. See [Models and fields](/reference/models-and-fields/) for the page model, [Preview and the generated client](/guides/preview-and-client/) for renderer verification, and the [page-document contract](/reference/page-contract/) for the stored shape. ## Next step Next, read [Blog sites](/guides/blog/) if your site is a Blog. Otherwise go on to [models and fields](/reference/models-and-fields/). # Blog sites Source: https://dee-wan-docs.pages.dev/guides/blog/ A site is created as a **Website** or a **Blog**. The choice is made once, when the site is created, and cannot be changed later. - **Website**: pages first. Add your own content models in Content Builder when you need them. - **Blog**: posts first. Dee Wan adds one small Post model and shows Posts in the main navigation. Sites created before this choice existed are Websites. ## What a Blog adds After the site record is created, Dee Wan runs Blog setup. Setup adds one ordinary content model: | Field | Type | Required | Language | | ------------------ | ---------- | -------- | ------------------------- | | `title` | text | yes | per language | | `cover` | media | no | per language | | `body` | rich text | yes | per language | | `publication_date` | date | no | shared by all languages | The model is `Post` (slug `post`), localized, with `title` as its display field. Setup adds no components, categories, keywords or other models. Categories, tags and credited authors are an optional add-on: see [Categories, tags and authors](#categories-tags-and-authors). A post's **address** is the ordinary content address (its slug). `publication_date` is a date you show to readers. It does not publish anything. ## Publishing posts Posts use the ordinary content lifecycle: the same editor, validation, versions, workflow, review, publish, unpublish, history and permissions as every other model. A post is public only when it is published. Drafts never appear on the public Blog or in Latest posts. Publishing a post does not rebuild the site. Blog pages read published posts when they are requested. ## Navigation On a Blog site the sidebar is: - **Main**: Dashboard, Posts, Pages, and Planner for people who may use AI writing (`ai:generate`). - **Advanced**: Content, and Content Builder for people who can read models. - **Configuration**: Settings, and Installation for superadmins. An item your role cannot use is not shown, and a group with nothing in it is left out. Website navigation is unchanged. **Posts** is a view of the `post` model in the ordinary content list: search, workflow filters, cover, title, address, publication date, status and the usual row actions. Creating or opening a post uses the ordinary content editor; its breadcrumb and Cancel return to Posts. **Content** and **Content Builder** still show every model, including `post`. ## Public Blog pages | Address | Shows | | ----------------------- | -------------------------------------------------------- | | `/blog` | Published posts, 12 per page | | `/blog?page=2` | The next 12, and so on | | `/blog/` | One published post: title, cover, publication date, body | | `/blog/category/` | Published posts in one category, 12 per page | | `/blog/tag/` | Published posts with one tag, 12 per page | | `/blog/author/` | Published posts credited to one person, 12 per page | Outside the default language, the same addresses start with the language code, for example `/th/blog`, following the site's existing locale routing and fallback policy. A post answers at its own slug in each language: `/th/blog/`. - Posts are listed newest `publication_date` first. Posts without a date come last. - `page` must be a positive whole number. A page past the end answers 404. - A Blog with no published posts shows an empty Blog page, not a 404. - A missing, draft, unpublished or unavailable-language post answers 404. A page, Site Chrome route or redirect may not use `/blog` or anything under it on a Blog site. Such a conflict blocks rendering with `blog_route_conflict` until you change one of the addresses. One exception: a redirect may start at a post's own address (`/blog/`) that no live post uses, so a post published under a new address keeps its old link working. A post's **Search and sharing** settings apply to its page. ### RSS feeds Each Blog language has an RSS 2.0 feed of its 20 newest published posts: | Address | Feed | | ----------------- | ------------------------------------- | | `/rss.xml` | Posts in the site's default language | | `//rss.xml` | Posts in another language, `/th/rss.xml` | Each item has the post's title, its full address, a summary, its credited authors and its publication date. The summary is the post's search description, or the start of its text. A post leaves the feed as soon as it is unpublished or moved to Trash, without a rebuild. Every Blog page names its feed in its head, so a feed reader finds it from the page address. A language without its own Blog address has no feed of its own. ## Comments Visitors can comment on published posts. Comments are off until you turn them on in **Posts → Comments** with **Accept new comments**. That switch is the post model's **Visitor comments** setting, the same one any model has; see [Visitor comments](/reference/models-and-fields/#visitor-comments). Off hides every comment and refuses new ones; nothing is deleted. New comments wait for moderation. Nothing is shown until you approve it. Each comment is **Waiting**, **Approved**, **Spam** or **Refused**. Approve, Refuse or Mark as spam one comment, or up to 25 selected at once. **Delete permanently** removes a comment and its replies. | Action | Permission | | ------------------------- | -------------------------------------------- | | Read and moderate | `content:publish` | | Delete permanently | `content:publish` and delete on `post` | | Turn comments on or off | `site:settings` in **Posts → Comments**, or `model:apply` for the model's **Visitor comments** switch in Content Builder | | Mark Helpful as yourself | read on `post` | Every action sends the state you saw. A comment someone else moved meanwhile is left as it is, and the list is read again. Moderation never sends notifications. - A comment belongs to its post and one language, not to the address. Renaming a post keeps its comments. Each language has its own thread. - One reply level. A reply answers an approved top-level comment of the same post and language. - Visitors and team members can mark an approved comment **Helpful**, once each. The count shows on the comment, and **Most helpful** orders comments by it. Who reacted is never shown. - Unpublishing a post or moving it to Trash hides its comments. Publishing it again shows them. - **Delete permanently** in Trash also deletes the post's comments, and so does the automatic deletion after the Trash retention. The confirmation states how many comments will be deleted. - A comment stores the display name, an optional website, an optional email, the text, the consent, the time and the state. The email is private: no public read, page or cache carries it. Dee Wan never emails a visitor. A comment without an email shows **Not provided** in its details. - The form asks for a name, the comment and consent. Website and email are optional and are checked only when filled in. The consent covers only what the visitor enters. - A comment imported without consent, such as one from a WordPress export, shows **No consent**. Its source never recorded that the person accepted this site's comment form, and nothing in Dee Wan changes that. It arrives pending and visitors do not see it. **Publish imported comment…** shows it to visitors after you confirm the dialog. That is your decision to republish it; it does not record consent, and the comment keeps **No consent**. Settings → History records who published it and when, never the text. Approving a selection skips these comments and says how many need review one by one. **Refuse**, **Mark as spam** and **Delete permanently** stay available. Moving a published imported comment out of Approved hides it again; showing it again needs **Publish imported comment…** once more. | Limit | Value | | ------------------------------ | ---------------------------------------- | | Name | 80 characters | | Website | 200 characters, `https://` or `http://` | | Email | 254 characters, optional | | Comment | 4,000 characters | | Comments per client and site | 10 every 10 minutes, apart from forms | | Reactions per client and site | 60 every 10 minutes, apart from comments | | Replies shown under one thread | 20 | | Count shown | up to 999, then `999+` | Each form carries a key. Sending the same comment again with its key answers with the first receipt; the same key with different text is refused. A hidden field catches simple bots. The limit counts a keyed hash of the visitor's address, never the address, and the count is erased after a day. ### Comments on the public Blog A post page shows approved comments, 20 threads a page (`?comments_page=2`), and the form. Visitors sort them **Oldest** (the default), **Newest** or **Most helpful** (`?comments_order=`), and each comment has a **Helpful** button. The form works without JavaScript: it posts to `/v1/interactions/comment.submit` on the site's own host and returns to the post with the outcome; **Helpful** posts to `/v1/interactions/comment.react` the same way. A generated site also sends it in place and shows busy, error, repeat, limit and success states without leaving the page. A preview never takes a comment. The intake route `/v1/interactions/*` must reach the intake Worker, as for website forms. Renderers read comments from `GET /v1/_comments`; see [the public API](/reference/public-api/). ### Comments in copies and backups A site copy and a site history transfer carry the comments of every exported post in `comments.jsonl`, with their state, private email (or `null` when none was given) and reaction counts. Who reacted does not travel. Importing recreates them on the posts that import created, replies under their own thread. Importing the same copy again never duplicates them, and undoing the import removes them. A comment without consent always arrives pending, even one published on the source site: publish it again on this site if you want it shown. Spreadsheet exports do not include comments. A WordPress import brings reader comments in held for moderation and without consent; see [Import from WordPress](/guides/import-migration/#import-from-wordpress). Strapi imports never create comments. A database backup includes them like all site data. ## Categories, tags and authors An optional add-on for a ready Blog. Open **Posts**, press **⋯**, then **Add categories, tags and authors**, then **Add**. You need the right to change models (`model:apply`). The menu item is shown while the `post` model lacks any of the three fields below. It adds ordinary models and fields, nothing else: | Model | Slug | Fields | | ---------- | ---------- | ------------------------------------------------------------------------------------------------------ | | `Category` | `category` | `name` (text, required, per language), `parent` (one other category, optional) | | `Tag` | `tag` | `name` (text, required, per language) | | `Person` | `person` | `name` (text, required, per language), `bio` (short text, per language), `photo` (media, per language) | and three many-to-many relation fields on `post`: `categories` → `category`, `tags` → `tag` and `authors` → `person`. The three models are public and show `name`. All three are translated: each language has its own name and address. A person's bio and photo also belong to each language. `parent` and a post's `authors` list are shared by every language. - After setup, you manage their structure in **Content Builder**, like any other model. - **The post editor** assigns them, with the ordinary relation fields. Authors are shown in the order they were added to the post; to change it, remove an author and add them again. - **A person is a credited name**, not an account. It is never linked to a user, to who created or owns a post, or to who a workflow step is assigned to. Adding it is safe to repeat. Everything already in place and identical is kept. If a `category`, `tag` or `person` model already exists and differs, or `post` already has a `categories`, `tags` or `authors` field that differs, nothing is added and the panel lists each one. A `category`, `tag` or `person` model that is not translated is listed as having different language settings. Rename or remove it in Content Builder, then press **Try again**. All models and fields are added together or not at all. ### Parent categories `parent` is a hierarchical link. A save that makes a category its own parent, or puts it under one of its own subcategories, is refused and the message is shown on the **Parent category** field. A restored version is held to the same rule. In Content Builder this is the **Hierarchy (parent/child)** switch, offered on any to-one relation to its own model; see [Models and fields](/reference/models-and-fields/). A generated standalone API keeps `parent` and refuses the same loops inside its database write. ### Categories and tags on many posts On **Posts**, select posts, press **Categories and tags**, choose **Categories** or **Tags**, then **Add** or **Remove**, pick up to 10 items and apply. Each post saves a new draft version with its own permissions and version check. Nothing is published. A post that already has the change is left as it is. A post that changed meanwhile, that you may not change, or whose own fields need fixing is listed as not changed, with the reason. Authors are not offered here. ### Deleting a category, tag or person The ordinary content rules apply. A published item must be unpublished before it can go to the Trash, and unpublishing lists the posts that link to it. In the Trash it keeps its links and **Restore** brings it back. **Delete permanently** removes the item and its links from posts and keeps the posts. ### On the public Blog A post page shows **By** and its authors under the title, and its categories and tags after the body. Each name links to its archive: `/blog/category/`, `/blog/tag/` or `/blog/author/`, with the language prefix outside the default language. - A post uses the current published names and addresses of its categories, tags and authors. Publish a correction to that related item; the post itself does not need another save or publication. Draft edits stay private. Withdrawn, trashed, deleted or private related items are not shown. - Existing public caches follow the normal invalidation and cache lifetime rules. The post body and its saved revisions remain unchanged. - In each language a category, tag or author shows its own name and links to its own address. Under a strict language policy one without that language is not shown on the post. Under fallback it shows in the language the site falls back to. An archive lists the posts that show that address in its language. The feed credits authors the same way. - An archive lists published posts linked directly to that item, newest `publication_date` first, 12 per page. Posts in a subcategory are not included. An archive with no published post answers 404. - A Blog without the add-on answers 404 on these addresses. If `post` has a field named `categories`, `tags` or `authors` that is not a relation to `category`, `tag` or `person`, the Blog is not renderer-ready (`blog_contract_drift`) until it is changed back. ## Rich text safety The post `body` is one rich document, stored as canonical HTML. Every save checks it: markup outside the document is refused by name (field, language, element or attribute) and nothing is written. Dee Wan's previews and renderers never insert the stored value without processing it: both renderers render it with the page contract's `renderRichDocument`. The document holds paragraphs, headings, lists, quotes, code blocks, rules, bold, italic, underline, strike, inline code, sub- and superscript, links and images, plus: - tables, up to 12 columns and 200 rows, with an optional caption, header row and row headers. A wide table scrolls inside its own box on a phone; - image galleries of 2 to 24 library images, each with an optional caption; - audio, video and file blocks naming one library file of that kind. Audio and video play in the page with a download link under the player; a file is a link with its name, type and size; - embeds, made by pasting one https link: YouTube and Vimeo are built in, and an administrator can allow other origins (see below). A block stores the provider id, the canonical https address and a title. It never stores markup, script or CSS. - plugin blocks, declared by an installed plugin (see below). A block stores its type, the version it was saved with and its field values. It never stores markup, script or CSS. Links may be relative, `http`, `https`, `mailto` or `tel`. Images may be relative, `http` or `https`. External links get `rel="noopener noreferrer"`. The public API serves the validated HTML. Media and embed blocks are data, not players. If you build your own renderer, draw them with `renderRichDocument` or your own renderer of the same document. ### Plugin blocks An active plugin can declare rich text block types in its manifest (`rich_blocks`, see [plugins/authoring.md](/plugins/authoring/#rich-text-blocks)). The editor lists them under **Blocks**, draws their fields with its own controls and previews each block from the plugin's declared render tree. Nothing from the plugin runs in the editor or on the site. - Stored form: `
`, with one empty `` per media field. The values are strings, numbers and booleans as sorted JSON. Media fields are pinned, copied and remapped like any library media. - A save that adds or changes a block is checked against the site's active plugins: an unknown type is refused with `rich_block_unregistered`, a version newer than declared with `rich_block_version_unknown`, and values the declared fields refuse with `rich_block_values_invalid` (each names the field and language). A block saved under an older declared version is accepted when its migrated values pass, and is stored as sent. - A block already in the same field and language keeps saving when its plugin is disabled or removed. The editor shows it as kept and not shown on the site. Imports and site copies carry blocks with their versions, whatever is installed on the destination. - Older blocks move to the current version only when an author asks: opening a block's form or **Update** on the block, or **Blocks → Update blocks ({count})**. Published content is never rewritten in bulk. - Renderers read the definitions from `GET /v1/_rich-blocks` only for a page that holds a plugin block, and draw each block with the host adapter: a fixed element list, every value escaped, every address checked. When no adapter draws a block (plugin not active, no render tree, or a version newer than the definition), a public page shows nothing for it and a preview shows a placeholder naming it. - Search text, search excerpts and feed summaries include a block's text and textarea values and the labels of its select values, read through the active plugin's definition at save time. A block with no active definition adds no words. Private fields add none either way. - Snippets follow the same save rules: a snippet that adds or changes a plugin block is checked against the active plugins; an unchanged block is kept. - The editor lists, under the field, every block the site will not show and why (plugin not installed, plugin turned off, newer version, no layout). Each entry selects its block. - Managed and generated sites read the definitions live rather than from the generation payload: their blog posts and lists are read live too, so a pinned copy of the definitions would go stale as soon as a plugin is installed, upgraded or disabled. Region summaries pinned into a generated site at build time show nothing for a plugin block. ### Embed origins **Settings → Integrations → Embeds** lists the built-in YouTube and Vimeo players and the origins from which this site may embed content in frames. Only people with `site:infra` see this screen and change the list. The editor reads the list for every member of the site, so anyone who may edit a post can paste an allowed link. - An origin is `https://` and one exact host, with an optional port. No path, query, fragment, password, wildcard, IP address or local name. A YouTube or Vimeo domain is refused: they are built in. - A site holds at most 20 origins, each with a name of 1 to 60 characters. - A YouTube watch, share, Shorts, live or player link becomes the privacy-enhanced `youtube-nocookie.com/embed/…` address. A Vimeo link becomes `player.vimeo.com/video/…?dnt=1`. A link on an allowed origin is framed as given. Anything else is refused by name: `embed_url_malformed`, `embed_url_not_https`, `embed_url_credentials`, `embed_url_ambiguous` (a provider link that names no single video) or `embed_provider_unknown`. - The editor never loads the provider. It shows a placeholder, and **Preview** opens the address in a new tab. - Both renderers draw the same frame: `sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox"`, `allow="encrypted-media; picture-in-picture; fullscreen"`, `referrerpolicy="strict-origin-when-cross-origin"`, `loading="lazy"` and a 16:9 box. No entry changes it. - Renderers read the allowed origins from `GET /v1/_embeds` only for a document that holds such an embed. A managed page's `frame-src` names the built-in players and those origins only. - A save that adds or changes an embed from an origin not on the list is refused with `rich_embed_origin_not_allowed`, naming the field and language. This holds for the editor, the management API, plugins and spreadsheet import. An embed already in the same field and language keeps saving after its origin is removed, and restoring an older version is not refused: those embeds draw as plain links until the origin is added again. A site copy or Strapi import carries the embeds its source held, the same way. Nothing embeds content from an origin that is not on the list. - Only this screen changes the list. The generic settings API refuses to create, change or delete the `embed_providers` row (`setting_server_owned`). ## When setup does not finish The site is kept whatever happens during setup, and Posts shows what to do: - **Blog setup did not finish** (for example a network failure): press **Try again**. Nothing was marked ready. - **Blog setup is blocked by an existing “post” model**: the site already has a model with the slug `post` that does not match the starter. Dee Wan leaves it unchanged. Press **Open Content Builder**, rename or remove that model or make it match, then press **Try again**. The state is stored with the site, so a reload shows the same screen. A Blog is not rendered publicly until setup has finished. ## Latest posts **Latest posts** is a section for any page. Add it from **Add section → Patterns → Latest posts**. It lists the six newest published posts with their cover, title and publication date. It is a preset of the **Latest entries** section, which can list any public collection. In the inspector, choose: - **Collection**: the model to list. It must be public. - **Title field**, and optionally **Image field**, **Summary field** and **Date field**. - **How many**: 1 to 12. - **Text when nothing is published**: shown only when the collection has no published entries. When a date field is chosen, entries are newest first by that date. Otherwise they are newest published first. The page stores only these choices, never the entries. If a chosen model or field no longer exists, the inspector names it and the section shows an error instead of an empty list. ### Example: a project list on a Website A Website with an ordinary public model `project` (fields `name` text, `photo` media, `intro` rich text, `completed_on` date) can show its newest projects with: | Choice | Value | | ------------- | -------------- | | Collection | `project` | | Title field | `name` | | Image field | `photo` | | Summary field | `intro` | | Date field | `completed_on` | | How many | 3 | The projects are created, reviewed and published in Content like other content items. Nothing about the `project` model changes. ## Generated source Whole-site generation records Website or Blog, and a Blog's sorted language list, in the generation source identity and the site manifest. Adding, removing or changing a Blog language changes the public `/blog` routes, so the deployed source reports as out of date until it is regenerated. Reordering the same languages changes nothing. `npm run backend:generate -- --model post` is not a Blog export. Single-model generation emits that model's API only: no site pages, no `/blog` routes and no Blog frontend. Generate the whole site instead. The generated website reads published posts from `/v1` on the site's own host when a Blog page or a page with Latest entries is requested. Latest entries in a shared section shown in a Site Chrome region are read at generation and pinned in the site, so they never make a page render on each request. Its Worker needs the `global_fetch_strictly_public` compatibility flag for that. New projects get it in `apps/site/wrangler.jsonc`. That file belongs to you once it exists: Dee Wan never rewrites it. If your existing file lacks the flag, generation and deployment refuse with `public_fetch_compatibility_flag_required` and name the flag to add. See [Detach and verify your standalone site](/guides/detachment/#site-worker-compatibility-flag). ## Renderers The built-in managed template and the generated website draw Latest entries and serve the RSS feeds. A Blog site is not renderer-ready while setup is unfinished, the `post` model is missing or changed in a way the Blog cannot render (including a `categories`, `tags` or `authors` field that is not the add-on's relation), or a Blog route collides. ## Next step Define or extend your content types in [models and fields](/reference/models-and-fields/). # Content planner Source: https://dee-wan-docs.pages.dev/guides/content-planner/ The planner finds topics, researches them, writes ordinary draft entries and lists the work in a queue. It never publishes. Open **Planner** in the sidebar. It needs the `ai:generate` permission on the site. Everything a plan writes is a normal draft. It goes through the same editor, workflow, versions, history and audit record as a draft you write yourself. ## Plans Leaving an edited plan asks whether to discard unsaved changes, including Back and Queue. Stay to keep editing, or leave to discard them. Switching plans ignores late responses for the previous selection. A plan targets one content model and one or more of the site's languages. It holds: - a goal, an audience and a list of exclusions; - the field mapping; - the sources; - an optional list of topics; - the number of items per run (1 to 10) and per day (1 to 50, never less than the per-run number); - an optional cadence; - the final action: **Save drafts** (default) or **Schedule passed drafts**. The accepted Site Brief is always part of the context. Saving changes to a plan creates a new immutable version of that plan. A run keeps the version it started with, so later edits never change a run in progress. A site can have up to 20 active plans, up to 10 languages per plan, up to 30 topics and up to 10 sources per plan. ### Field mapping A plan names which model field takes the headline, the body and, optionally, the summary, web address, search title, search description, a main image field and up to 5 relation fields (the plan form's **Where it saves** section). - The starter blog model has a preset: its title is the **Headline** and its body is the **Body**. The preset is offered only while the model still has those fields. - A custom model has no preset. You map each field once, in the plan. The planner never guesses a field. - The headline must be a text field. The body must be a rich text or long text field. A field can be used once. - A required model field that the mapping does not cover, and that has no default, makes the mapping invalid. - The mapping is checked when you save the plan, before an automatic run starts, before every stage of a run and before every draft is saved. If someone changes the model so the mapping no longer fits, the run stops before more AI work, its running items wait in **Needs review** (`profile_invalid`), and a due run waits with the reason until you edit the plan. With **Create an image with AI** ticked on the **Main image** field, the planner generates a picture (see Main image). A relation field offers only published entries of the model it points to, the 50 most recently changed. ## Sources Turn on any of these source modes on a plan. | Mode | What it reads | | --- | --- | | Topics | The topics you list. Each has a label, an optional angle and optional included and excluded terms. | | Entries on this site | Entries you select. The current published text is used. | | Web pages | Manual HTTPS addresses. | | News feeds | RSS or Atom feeds. The 5 newest entries are used. Their own text is used when present, otherwise the linked page. | There is no built-in search provider and no built-in list of topics, industries or countries. ### What a fetch can do Page and feed fetches are limited: - HTTPS only, without credentials or a fragment in the address; - no private, local or reserved addresses, and no names without a dot; - at most 3 redirects, and every redirect is checked again; - at most 1 MiB per source and 10 seconds per fetch; - page types `text/html`, `application/xhtml+xml` and `text/plain`; feed types `application/rss+xml`, `application/atom+xml`, `application/xml` and `text/xml`; - at most 50 entries are read from a feed. A refused source is marked `source_refused`. A source that does not answer is marked `source_unavailable`. Timeouts, 408, 429 and 5xx answers are retried. An address that fails the policy is refused when you save the plan. The check inspects the address itself. It cannot determine which address a public hostname resolves to. ## Coverage A plan may name a source model and a rule. Set them under **More options** at the end of the plan form: **Cover entries from** and **May propose updates to drafts it created**. The planner then proposes one new item per language for each published source entry that has no item yet. When the rule allows revisions, it proposes a revision when the source entry changed since the item was made, and only for the entry an earlier item of the same plan saved. It records the source entry and version that caused the proposal. A proposal for a new entry follows the same duplicate rule as a topic idea (see Duplicates): an exact match stops it by name, and a near match is re-angled at most twice before it waits in **Needs review** with the compared entries of each language. The languages of one proposal stay together. A proposal that cannot run is kept in **Needs review**, never dropped. The planner never makes an item for every row without the rule, never changes an entry silently, and never infers ownership. Revisions need permission to update entries of the target model, not only to create them. The same is true of a plan with more than one language or a generated main image, because adding a language and attaching an image are updates. ## Topics and rotation A topic has a name, an optional angle, optional included and excluded terms, and a switch, **Use this topic**. Runs use only the topics that are switched on. A topic that is off stays in the plan with its history and is never used. **Remove** takes a topic out for good: it is deleted, or, when items already name it, it is hidden from the plan and the rotation while those items keep showing its name. The plan screen shows, for each saved topic, when it was last tried and when it last made an item. An automatic run writes only about the selected topics. For each topic the planner proposes one idea, written in every language of the plan. - The topic tried least recently goes first, then the one that made an item least recently, then the order in the list. Every selected topic is tried once before any is tried again, across runs too. - A topic with no useful idea records "no candidate" (`no_candidate`) and the run moves on to the next topic in the same step budget. The planner does not widen a topic or invent another one to reach the requested number. - A topic that came up empty in a run, or whose idea was an exact duplicate, is skipped for the rest of that run. It goes to the back of the order for the next run. - An idea that uses an excluded term, or none of the included terms when you set some, is not used. - When no selected topic can give another idea, the run ends as partial with the reason "The selected topics had no more grounded, new ideas." A topic counts as tried after every attempt to find an idea in it. It counts as having made an item when the attempt produced one, and again when that item becomes a draft or is scheduled. ## Duplicates The planner compares a proposal, from a topic or from coverage, with its other items, with drafts and with published entries of the target model in the same language. It checks the 500 most recent items and the 500 most recent entries. - An exact match of title or intent stops the idea. No item is made for a topic idea; a coverage proposal is kept in **Needs review** so the source entry is not lost. - A near match (word overlap of 0.6 or more) is re-angled, at most twice. If it is still near, the item is made in **Needs review** with the compared entries shown. - Checks run again before a draft is scheduled. An exact match with a published entry stops there. The comparison is lexical. It works without any embedding model. ## Research, evidence and claims Before writing, the planner reads the sources and extracts claims. It keeps a claim only when its excerpt is an exact quote of the source text, or an exact fact from the accepted Site Brief. Excerpts are at most 1,000 characters, and an item keeps at most 40. - If no claim is supported, or claims conflict, the item goes to **Needs review** with named issues. - A draft may leave a source claim out. It may not invent a fact. - Required wording from the Site Brief must appear word for word. - The item shows its sources, claims and issues next to the draft. ## Draft, checks and repair One model call writes the mapped fields. The body uses the rich document format, never executable HTML. Internal links go only to current published pages of the site. External links go only to recorded evidence addresses or to the Site Brief's valid destinations. Checks run on the server and do not depend on a model's opinion. Each issue has a code, a field and a plain sentence. | Code | Meaning | | --- | --- | | `schema_invalid` | A value does not fit its field. | | `required_missing` | A required field is empty. | | `length_bounds` | A field is too long or too short. | | `rich_invalid` | The body is not a valid rich document. | | `required_wording_missing` | Required wording is absent. | | `forbidden_claim` | The draft makes a claim the Site Brief forbids. | | `claim_unsupported` | The draft relies on a claim without evidence. | | `duplicate_intent` | The draft repeats existing content (a near match is a warning). | | `link_not_allowed` | A link or picture is not allowed. | | `source_rule` | The draft uses a term the plan excludes. | | `relation_invalid` | A chosen related entry is not allowed. | A body may not contain pictures or embeds; media comes only through the media library. A number in the draft that no supported claim or Site Brief fact states is `claim_unsupported`. Errors start a repair. A repair rewrites only the failing fields or passages and runs at most twice. If errors remain, the item goes to **Needs review** with the best result so far kept. A score never publishes, schedules or hides a failed check. ## Saving The draft is saved once through the normal content lifecycle, as the plan owner. The key comes from the plan version, the source and the language, so a replay returns the same entry instead of a second one. A revision pins the version it read. If the entry moved, the item stops as a conflict. Before the save the planner rechecks the owner's permission, the field mapping and duplicates. The languages of one idea are saved as translations of one entry. The first language creates the entry. Each later language is added to it through the lifecycle's add-language path, pinned to the entry's current version. Later languages write only translatable fields. Relation fields stay with the first language. If a language already exists on the entry, for example one a person added, the item stops as a conflict (`target_conflict`) and nothing is overwritten. ### Main image When the mapping says **Create an image with AI**, the planner generates one image per idea after the first draft is saved. It spends site AI credit, stores the picture in the media library through the normal media upload path, and attaches it with a second save of the draft. It never replaces an image already in the field, and it does not attach the image if a person edited the draft meanwhile (the image stays in the library). A retry does not pay for a second image. If the image fails, the item gets the warning `image_failed`, the draft is kept and the item still becomes ready. A required main image field cannot use generation, because the image arrives after the first save. The plan refuses this setup. ## Final action: schedule **Schedule passed drafts** is offered only when the Scheduling plugin is installed, active and granted for the target model. You choose the publish transition, a timezone and a delay from 5 minutes to 30 days after the draft is ready. The planner saves the draft first. Then it asks Scheduling to schedule exactly the version it saved, as the plan owner, with the owner's live permissions. It uses Scheduling's `schedule-draft` operation, which only the planner calls: it is not an operation people or agents run from an entry, Settings, the management API, the CLI or MCP. Core keeps no copy of the schedule; the item keeps Scheduling's reference and the queue shows when it publishes. The planner never publishes. An existing active publication schedule on the entry is never replaced. The due time is the moment the draft was ready plus the delay. If Scheduling is missing, refuses or fails, the draft stays as it is and the item goes to **Needs review** with **Retry scheduling**. Temporary Scheduling failures, such as a timeout or an unreachable service, are retried automatically first. **Retry scheduling** asks Scheduling again with a new request; when the planned time has already passed, the delay counts from the retry. If an answer was lost after Scheduling accepted it, the planner asks again with the same request and gets the same schedule back, so a draft is never scheduled twice. If a person changed the draft after the planner saved it, nothing is scheduled. The planner checks before it asks, and Scheduling refuses (`draft_changed`) when the entry's current draft is not the saved version. The draft stays as the person left it and the item waits in **Needs review** with "The draft changed after it was saved. Schedule it from the entry." Open the draft and schedule it there. **Retry scheduling** schedules only while the current draft is still a version the planner saved. ## Runs **Run now** starts a separate run at once. It does not move the automatic schedule. A plan has one live run at a time, and Run now is refused while one is working. An automatic run that falls due while a Run now run works waits; when that run ends, one automatic run starts. Each run keeps the plan version, the accepted Site Brief version and the AI model it started with. A Site Brief accepted while a run works does not stop that run: it finishes with the version it started with, and the next run uses the new one. Work is stored before each model call, and the state survives restarts. The plan screen shows the next run before you turn it on: when it would start, how many items it makes, what happens to a draft, and the topics in the order it will try them. ### The background step Dee Wan uses the admin Worker's existing every-minute trigger. There is no separate cron per plan or topic. Each minute it: - visits up to 8 sites, starting at a different site every minute so none is always last; - on each site, finds up to 5 plans whose next run is due and starts their automatic runs, after the checks in AI credit; - advances each active run by one stage under a 5-minute lease, at most 8 stages in total per minute; - re-reads the owner's permission, the content model and the field mapping before each stage; - picks up a stage whose worker stopped once its lease runs out, from the last saved checkpoint. The plans list shows each plan as **Not scheduled**, **Active**, **Due now**, **Running** or **Paused**, with the reason a plan waits and the result of its last run. ### Automatic cadence Enabling a cadence is one explicit action. Later runs need no new confirmation. Choose daily, weekly or monthly, a time and a timezone. - Weekly runs on a weekday. Monthly runs on a day from 1 to 28. - A time that does not exist on a day the plan fires is refused, so a daylight-saving gap never skips a run silently. - A time that happens twice needs your choice: **The first time** or **The second time**. - A missed run becomes one due run. The planner never makes up every missed occurrence. - The next run is always the next occurrence after now. - **Pause plan** stops new automatic runs and stops active runs of that plan from advancing. A stage already working when you pause finishes and is kept; nothing after it starts. Pause keeps the setup, history and drafts. **Resume plan** continues the paused run where it stopped and works out the next occurrence from now. The person who enables a cadence becomes the owner of the plan. The plan displays its owner. ## AI credit A run spends the site's hourly AI credit, 250 calls an hour unless the installation sets another limit. One model call spends one unit. A retry spends again. One item uses up to 8 calls: one to find the idea (up to 2 more when re-angling), then research, outline and draft, and up to 2 repairs. The planner reads the remaining credit without spending it. Before an automatic run starts, the planner checks, in this order: no other run of the plan is working, the content model and field mapping still fit, the owner's permission, the daily item limit, the site's active-run limit (2), a provider wait, and at least 8 units of credit left. If any check fails, no run or item is created, and the plan shows a plain reason. When the owner lacks permission, the plan waits for its next occurrence. For the other checks, the planner tries again the next minute. **Run now** applies the same checks and answers with the reason. You see the per-run and per-day limits and the credit before you turn a cadence on. ## Retries, cancel and recovery - A temporary provider or fetch failure is retried at the same stage up to 3 times, with increasing delays. A rate-limit answer also holds back the site's runs. - A stage that crashed is picked up again after 5 minutes. - Invalid output, failed checks, exhausted repair attempts, a conflict, lost permission or a refused source stop that item with a specific reason. The run goes on with the others and ends as partial. - **Retry** continues from the last saved stage in the same run and topic. If the run had finished, it is reopened, never replaced. It is refused while another run of the plan is live. - A retried save returns the entry saved before, and a retried hand-off returns the schedule made before, so retries never make a second draft or schedule. - **Cancel** stops an item. Work in flight is thrown away. A draft already saved is kept and stays linked. A scheduled item cannot be cancelled here, because only Scheduling can withdraw its schedule. ## Permission loss The plan owner is checked again at every stage, before every write and every outside call: the account exists, belongs to the site and is allowed `ai:generate`, and can create entries of the target model (and update them when the plan revises, has more than one language or creates images). If the owner loses any of this during a run, running items become **Blocked** with the reason and the run stops. If it is lost before a due run, no run starts, the plan shows the reason and moves to its next occurrence. The cadence stays on in both cases. Once the owner's permission is back, the next due run starts by itself; nobody has to turn the cadence on again. Drafts and evidence stay. **Pause plan** is the way to stop automatic runs. There is no hidden automation account. When you act on an item yourself, you act as yourself, and the owner does not change. ## Queue The Queue groups items by what you need to do. | Group | States | | --- | --- | | Needs you | Proposed, Needs review, Blocked | | In progress | Approved, Running | | Ready | Draft ready, Scheduled | | Done | Done, Cancelled | An item becomes Done when its own language is live. The queue and the background step both notice this. Each row shows the title, state, stage (for example **Writing**, **Checking**, **Saving draft**, **Scheduling**), the content type it saves to, the language, the plan, the topic, whether the run was **Automatic** or **Manual**, when Scheduling publishes it, the reason it stopped, the next step, and a link to the draft once one is saved. An item shows its stage, target, language, sources, compared duplicates, issues, attempts, and its draft or schedule. Actions: - **Approve** a proposed item. - **Edit** the title, angle, intent or keyword of a proposed or review item. - **Run** a proposed or approved item. It joins the plan's active run, or gets a run of its own with no discovery. - **Retry** a review or blocked item. After a failed hand-off it reads **Retry scheduling**. - **Cancel item** an item that is not scheduled or finished. - **Open draft** for the saved entry. - **Pause plan** and **Resume plan** on the plan, and **Turn on automatic runs** to enable the cadence. **Generate ideas** asks for 5 to 7 ideas at once, one per language, as proposed items. It writes no draft. Repeating the same request returns the same items. ## What is kept The planner keeps plans and their versions, topics, sources, runs, items, the claims, evidence excerpts, issues, comparisons and attempts of each item (the last 20 attempts), and a stage checkpoint of at most 64 KiB. Planner records belong to one site. It does not keep the full text of fetched pages or the prompts it sent. Planner records are editorial records of the site, not telemetry. Settings → History lists each planner save as "Draft saved by the Planner". Site copy and site history transfer do not carry them. # Website forms Source: https://dee-wan-docs.pages.dev/guides/website-forms/ A **Form** section on a page collects what visitors type. This guide covers where those submissions go: turning intake on, adding destinations, reading delivery outcomes, erasing values and receiving the signed `form.submitted` webhook. To add the form itself to a page, see [Add a section](/guides/page-builder/#add-a-section). ## Before you start - You need the `site:infra` permission. The built-in `admin` role has it, and so does a superadmin. Without it, the Integrations section is not shown. - The form intake Worker must be deployed, with a route for `/v1/interactions/*` on the site's host. Without it, forms take no submissions. See [Deploy changes](/guides/installation/#deploy-changes). - Email destinations need mail set up for the installation (`RESEND_API_KEY` and `MAIL_FROM`, or your own [mail transport](/guides/people-and-access/#mail-transport)). See [Add secrets for optional features](/guides/installation/#add-secrets-for-optional-features). ## Turn on submissions 1. Open **Settings → Integrations**. The **Website forms** card is below the outbound webhooks. 2. Switch on **Take submissions**. The label changes to **Forms take submissions**. 3. Set **Keep what visitors send for (days)**: 1 to 365. The default is 30. 4. Add destinations if you want site-wide operator delivery, then press **Save form settings**. Destinations are optional when you only want to store submissions. While the switch is off, every form on the site answers that it is not taking submissions, and nothing is stored. Switching it off later does not cancel deliveries already queued. Each site has its own settings. Forms on one site never send to another site's destinations. ## Add destinations A site sends each submission to at most 5 operator destinations. A form can override these defaults and send a separate acknowledgement email. Visitors never choose an operator destination, and nothing on the page names one. - **Add email** adds an **Email address**. Enter one address. - **Add webhook** adds a **Webhook URL** and a **Signing secret**. The URL must start with `https://` and must not contain a username or password. The secret needs at least 16 characters (at most 256). Each destination has its own **Send to this destination** switch: **On** or **Paused**. A paused destination gets no new submissions, and deliveries already queued for it end as **Not delivered**. **Remove** asks first. Submissions already stored are kept, and their queued deliveries to that destination end as **Not delivered**. The secret is write-only. After saving, the field says **A secret is stored. Type a new one to rotate it.** Leave it empty to keep the stored secret. Each accepted submission keeps its destination URL, recipient and signing secret. Changes apply only to new submissions; retries keep the accepted settings. Keep the old secret available at your receiver while older deliveries remain queued. With no site-wide destination, the card says **No default destination.** Submissions are still stored and listed. Form-specific destinations and acknowledgement emails can still deliver. If mail is not set up, the card shows **Email is not set up for this installation**. Email deliveries then fail and are retried, like any unreachable destination, and end as **Not delivered**. After mail is set up, use **Send again** on each one. ## Recent submissions If recent submissions cannot be loaded, choose **Try again** in that section. This reloads the history only and keeps unsaved form settings. “No submissions yet” appears only after a successful read finds none. The **Recent submissions** card lists the 50 newest receipts. The list itself does not show what a visitor typed; open a receipt to read retained values. Each receipt shows: - the receipt id and when it arrived - the page id, the language and when its values are erased (or **Values erased**) - one line per delivery, with its channel, accepted recipient or webhook address, state and a short detail such as `HTTP 503` Recipient addresses appear only while the submission values are retained. After expiry or erasure, the delivery line keeps its channel and state but withholds the target. Webhook addresses show only the origin and path, never query parameters or signing secrets. Legacy receipts without an accepted target snapshot show only the channel. | State | Meaning | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | **Waiting to send** | Queued. The next delivery run sends it. | | **Will try again** | The last attempt failed for a potentially temporary reason. Another attempt is scheduled. | | **Delivered** | Email: the mail provider accepted it. Webhook: the receiver answered 2xx. | | **Not delivered** | Stopped. The receiver refused it, every attempt failed, the destination was removed or paused, or the values were erased first. | Deliveries run once a minute, at most 10 per site per run. A run claims each delivery just before it sends that one, and only one run can win a claim. A claim lasts 5 minutes from that moment. Each send gives up after 10 seconds, for email and for webhooks, and then counts as a failed attempt that is retried. A run slowed down on one delivery never sends a later delivery that another run claimed or finished meanwhile. Before authorizing each send, it checks that the values have not expired or been erased. If a send is still unfinished when its claim ends, a later run can claim and send that delivery again; the late answer of the first run is ignored. A delivery can therefore be sent more than once: see [Deduplicate](#deduplicate). ### Send again **Send again** appears on **Will try again** and **Not delivered** lines. It resets the attempt count and queues the delivery now. The toast says **It will be sent again within a minute.** It reuses the same delivery, so the receiver sees the same `receipt` and the same `X-Deewan-Delivery` value. While a delivery run is sending that delivery, **Send again** is refused with `409 delivery_in_progress` and the message _This delivery is being sent right now. Check it again in a few minutes._ Nothing changes. Try again after the run finishes. Sending again does not help when the values expired or were erased, or the destination was removed or paused. The delivery ends as **Not delivered** again. Retrying keeps the accepted recipient, URL, template and signing secret; it does not pick up later settings. ### Erase values **Erase values** deletes what the visitor sent, now, after a confirmation. The receipt stays, so a repeated send of the same submission is still recognised and nothing is stored twice. Deliveries not yet sent end as **Not delivered**. ## File attachments In the page editor, choose **File upload** as the field kind. Set **File types** to comma-separated extensions or exact MIME types, for example `.pdf, .png` or `application/pdf`. Leave it empty to allow the supported image and document formats. Wildcards such as `image/*` are not accepted. Set **Maximum files** (1–5, default 1) and **Maximum size per file** (up to 5 MiB, default 5 MiB). The whole submission may contain at most 5 files totalling 10 MiB. **Required** also applies to uploads. Supported formats are PNG, JPEG, WebP, AVIF, GIF, PDF, DOCX, XLSX, PPTX, ODT, ODS, ODP, RTF, TXT and CSV. The intake checks the actual file bytes, not just the filename or browser's reported type. Empty files, unsupported files and files over a configured limit are refused with a field error. The published form supports file selection and submission with or without JavaScript. Without JavaScript, a validation refusal reloads the page; the browser clears the file selection, so choose the files again before resending. Open a retained receipt in **Recent submissions** to see each file's name, type and size, then choose **Download**. Downloads need the same `site:infra` permission as the submission details and use the currently selected site. Visitor uploads stay in private submission storage; they are not public Media library assets and do not get public URLs. The installation needs its private storage bucket configured to accept attachments. Bind the same private R2 bucket as `PRIVATE_MEDIA_BUCKET` on the intake Worker and the CMS Worker that handles downloads and scheduled cleanup. Generated intake configuration copies the existing private bucket binding. Attachments have the submission's retention date. **Erase values** immediately removes their details and download access; expired submissions also refuse downloads before background cleanup runs. The retention job deletes their stored files and retries storage failures. The receipt stays. Already downloaded files and files copied by a recipient cannot be recalled. Default operator emails list filenames and sizes, not file bytes. Webhooks include an optional `attachments` map from field name to arrays of `{ id, name, type, size }`; these are retained-file references, not download URLs. Custom email template variables remain scalar field values. ## Retention Each submission gets its erase date when it arrives: the arrival time plus the retention days set then. Changing the retention later affects only new submissions. At expiry, values and attachment downloads become inaccessible immediately, even before cleanup runs. The cleanup job runs once a minute for active and inactive sites. It removes retained values and attachment files, retries storage failures, and keeps the receipt. Disabling a site stops delivery, not retention cleanup. ## What visitors meet The intake checks each submission against the page's published form, never a draft. A client may send 10 submissions per site in 10 minutes. A repeated send of the same submission is answered as already received without creating another receipt or delivery record. Use **Preview** on a selected form in the page editor to see each visitor state; see [Add a section](/guides/page-builder/#add-a-section). ## Email delivery The default email has the subject `New form submission` and one body line per field, in the form's order, as `label: value`. A checkbox shows `Yes` or `No` and an empty answer shows `—`. A given consent adds `Consent: given`. The last line names the receipt, the page, the language and the arrival time. The mail provider has 10 seconds to accept an email. No answer, a network failure or a refusal is retried like a webhook failure, up to 5 attempts. A mail the provider accepted but whose answer was lost is sent again, so a mailbox can get the same submission twice. The default message includes the receipt id in its last line. Use it to identify duplicate messages. Custom templates contain the text you configured and do not automatically append receipt details. ## Webhook contract These destinations are separate from outbound webhooks. They are set only in **Website forms**, and they send only `form.submitted`. The outbound webhook contract in [Outbound webhooks](/reference/outbound-webhooks/) does not apply to them. ### Request Dee Wan sends `POST` to the destination URL: | Header | Value | | -------------------- | ---------------------------------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | | `X-Deewan-Event` | `form.submitted` | | `X-Deewan-Delivery` | The delivery id: one submission to one destination | | `X-Deewan-Signature` | `sha256=` and the lowercase hex HMAC-SHA256 of the exact body bytes, keyed with the destination's signing secret | The body (values are illustrative): ```json { "event": "form.submitted", "receipt": "", "page": "", "locale": "en", "section": "
", "submitted_at": "2026-10-13T09:30:00.000Z", "values": { "email": "visitor@example.com", "subscribe": true }, "labels": { "email": "Email", "subscribe": "Send me news" }, "consent": true, "attachments": { "brief": [ { "id": "", "name": "brief.pdf", "type": "application/pdf", "size": 1024 } ] } } ``` - `receipt` is the submission id, the same one **Recent submissions** shows. - `values` maps each field name to a string or a boolean. `labels` maps each field name to its label on the published form. - `submitted_at` is when the intake stored the submission, as ISO-8601 UTC. ### Answers and retries - Any 2xx is **Delivered**. - No answer within 10 seconds, a network failure, 408, 429 or any 5xx is retried. - Every other status is final and ends as **Not delivered**. Redirects are not followed, so a 3xx is final too. - At most 5 attempts in total. The wait starts at 1 minute and doubles, up to 1 hour, with random jitter of 50–100 %. A valid `Retry-After` (seconds or an HTTP date) can make the wait longer, up to 1 hour. - Each attempt uses the URL and signing secret accepted with that submission. Later edits apply only to new submissions. The destination's current disabled or removed state still stops queued delivery. ### Verify the signature Verify the raw body bytes before parsing them. Re-serialized JSON does not match the signature. The signature covers only the body; `submitted_at` is inside it. There is no separate timestamp header. ```js async function verifyFormWebhook(secret, rawBody, signatureHeader) { const hex = signatureHeader?.startsWith("sha256=") ? signatureHeader.slice(7) : ""; if (!/^[0-9a-f]{64}$/.test(hex)) return false; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const signature = new Uint8Array( hex.match(/../g).map((pair) => parseInt(pair, 16)), ); return crypto.subtle.verify( "HMAC", key, signature, new TextEncoder().encode(rawBody), ); } ``` ### Deduplicate Expect the same submission more than once. A lost 2xx is retried, **Send again** sends it again, and a send that outlives its 5-minute claim can be sent once more. Do not rely on exactly one request. Email can also repeat; the default message includes the receipt id in its last line. - Deduplicate on `receipt`. It is in the signed body and stays the same on every attempt, on **Send again** and for every destination of that submission. - `X-Deewan-Delivery` also stays the same across attempts and **Send again**, but it differs per destination and is not signed. Use it to tell two destinations apart, not as the only key. - Store the `receipt` and apply your application's change in a single atomic operation, before you answer 2xx. Then a repeat does nothing. ## Next step Add the form to a page in the [page builder](/guides/page-builder/#add-a-section), or read the [outbound webhook contract](/reference/outbound-webhooks/) for content events. ## Submission details and delivery options In **Settings → Integrations → Website forms**, open a receipt to read its retained values. Only operators with `site:infra` can read settings, receipt details or delivery history. Expired or erased values cannot be read or retried, even before scheduled cleanup runs. Erasing values also removes the queued delivery snapshots that contain them. **Delivery options** lets you select an actual published page form and override its destinations. Forms without an override use the site defaults. An empty override sends no operator deliveries for that form; acknowledgement email is configured separately. Email destinations can use a plain-text **Subject** and **Text template · {{field}}**; **Insert field** inserts a declared form field. Templates accept `{{field}}` placeholders only, without executable expressions. Webhook secrets and destination configuration remain server-only and are excluded from public page data and site transfers. **Acknowledgement email** sends a plain-text email to a chosen email field only when the published form has a consent control and the visitor checks it. It uses the same retained submission, queue, retries and erasure rules as operator delivery. It does not change the form's success message. A delivery keeps the recipient or webhook URL, template and signing secret accepted with the submission. Editing settings never redirects an old retry. Disabling or removing its destination stops queued delivery; disabling acknowledgement stops queued acknowledgements. Erasure and expiry are checked again immediately before starting transport. A provider request already in flight cannot be recalled. An exact retry of an accepted request returns its existing receipt, even if forms were disabled, the published form changed or disappeared, the client rate limit was reached, or retained values were erased. It creates no new submission or delivery. A changed payload with the same key is refused. The final live check authorizes one transport attempt; erasure cannot atomically recall a provider request already authorized or in flight. # Sites Source: https://dee-wan-docs.pages.dev/guides/sites/ One installation can hold many sites. Each site has its own content, models, media, settings, roles and plugins. Content stays isolated unless its source explicitly grants a published public shared reference. An account belongs to the installation, and a grant gives it a role on one site. See [People and access](/guides/people-and-access/). ## Create a site Only a superadmin can create a site. 1. Open **Settings → Site**. Under **Create a site**, press **New site**. 2. Enter a **Name**. The **Slug** fills in from the name; you can edit it before you create the site. 3. Enter the **Domain**: the hostname the public API answers on for this site, such as `www.example.com`. 4. Under **What is this site for?**, choose **Website** or **Blog**. This cannot be changed later. See [Blog sites](/guides/blog/). 5. Press **Create site**. Name, slug and domain are all required. Each slug and each domain can belong to only one site, even when that site is inactive. A taken slug or domain is refused. The **Slug** cannot be changed after creation. The name and the domain can. Creating a site writes the site record only. Dee Wan does not set up DNS or a Worker route. Until the domain routes to the public Worker, the site is not served there. The screen says so after creation. See [First site to published content](/guides/first-site/). A new site starts with the default editorial workflow, one default language and the built-in roles. ## Switch sites The site switcher in the sidebar shows the site you are working on. Open it and choose from **Switch site**. It lists active sites. **Manage sites & access** opens Settings. The admin reloads after a switch, because every screen belongs to one site. When there is more than one site, **Settings → Site** also shows **Current site** with a button per site. ## Jump to a screen Press Ctrl/Cmd+K, or choose **Search** in the top bar, then type a screen name and press Enter. It lists only the screens and Settings sections your role can open. Escape closes it and returns focus. In the page editor, Ctrl/Cmd+K opens the section gallery instead; use **Search** there. ## Change the name or domain Open **Settings → Site**, edit **Name** or **Domain**, and press **Save changes**. You need the site settings permission (`site:settings`). The built-in `admin` role has it, and so does a superadmin. A new domain moves the public API to that hostname in one step. Dee Wan does not set up DNS or a Worker route for the new name. Do that yourself, as at creation. A site served by its managed design changes its address elsewhere: **Change address** in **Settings → Source & delivery → Site template**. See [Change address](/guides/managed-templates/#change-address). ## Deactivate a site 1. Open **Settings → Site** on the site you want to stop. 2. Press **Deactivate site** at the bottom of the site details. 3. Read the confirmation and press **Deactivate site** again. You need `site:settings`, or must be a superadmin. When this is the only active site you can reach, the confirmation says that you can reactivate it from the start screen. What stops at once: - The public API on the site's domain answers 404. Anything that reads this site through the CMS public API, such as a renderer, gets no content. - Website forms on the site take no submissions. - Nobody can work on its content or settings: not in the admin, not through the management API or MCP, and not through plugins. This includes superadmins. - The site leaves **Switch site** for everyone. Only people who can reactivate it still see it, in **Settings → Site** and on the start screen. Everyone else no longer sees it at all. What waits until the site is active again: - queued website form deliveries and the erasing of form values past their retention date - outbound webhook retries - import jobs, content planner runs and the Trash purge Because erasure is paused, a deactivated site keeps submitted form values past their erase date until it is active again. What fails: plugin work that comes due while the site is inactive is refused. For example, a Scheduling schedule that comes due is refused and not retried. Nothing is deleted. Content, media, settings, grants and plugin installations stay as they were. The slug and domain stay reserved for this site. ## Reactivate a site Whoever may deactivate a site may reactivate it: a superadmin, or a person whose role on that site has `site:settings`, such as the built-in `admin` role. `GET /api/sites` lists an inactive site only to these people. People whose role on that site lacks `site:settings`, such as the built-in `editor` and `viewer` roles, do not see it, and their grants stay. When a grant is removed, the inactive site leaves that person's list too. Seeing an inactive site does not open it. **Switch site** lists active sites only, and nobody can work on the site's content until it is active again. From Settings, while at least one other site is active: 1. Open **Settings → Site** on an active site. The page is there for anyone who can reactivate a listed site, even if their role on the current site is only `editor` or `viewer`. 2. Under **Current site**, an inactive site has a greyed-out button with an **inactive** badge. Press **Reactivate** next to it. 3. The site is active again. Choose it under **Current site** or in **Switch site** to work on it. When every site you can see is inactive, the admin opens a start screen that says **Every site is inactive.** It lists each site you can reactivate, with its domain and a **Reactivate** button. Pressing it reactivates that site and opens it. If the server refuses, for example because your grant was removed in the meantime, the reason shows under the list. A person whose every site is inactive, and who cannot reactivate any of them, sees **No sites yet.** and is asked to contact an administrator. A superadmin or an administrator of that site can bring it back. Content was kept. Media, settings, grants and plugin installations are as they were. The waiting work listed above resumes, including the erasure of form values past their erase date. Plugin work that was refused while the site was inactive is not retried. ## Next step Give people access to the site in [People and access](/guides/people-and-access/). ## Shared content Open **Settings → Site → Shared content** on the source site. Choose **Content type**, **Entry**, **Destination site**, **Languages**, then **Share**. This action requires `site:settings` on both sites and permission to update the source entry. No destination membership or source editing access is granted to destination editors. Enable **Shared references** (`sharedReferences`) on the destination model-level relation field. Component fields cannot enable it (`shared_reference_component_unavailable`). Only nonhierarchical `manyToOne` and `manyToMany` fields accept shared targets. Ordinary relation targets remain in their own site. Choose an explicit shared target in the relation picker. Delivery reads the source site's current published public projection. Private fields and private models are excluded. **Revoke**, unpublishing, a disallowed language or an obsolete grant revision makes the reference unresolved; no old title is returned. The source remains its owner, including when sites use separate databases. Cross-database validation is rechecked on delivery; it does not claim one atomic transaction across sites. Shared relations cannot combine `sharedReferences` with contextual target `filterChoices`; definition and runtime checks return `shared_reference_choice_unavailable`. Select-option conditions may still inspect whether a shared relation has a value. A response resolves at most 128 distinct shared grant/language identities. Repeated references, including sync `data` and `changes`, reuse one current projection. Reduce the list or sync page size after `shared_reference_limit`. Native bundle review checks the destination's compatible current grants and apply rechecks them. Another destination or an unavailable grant returns `shared_reference_destination_mismatch`; references are never silently removed. A detached backend cannot enforce live cross-site grant custody, so generation refuses effective shared references with `shared_references_require_live_delivery`. Remove or replace those references before generating it. # Models and fields reference Source: https://dee-wan-docs.pages.dev/reference/models-and-fields/ A model defines a site's content structure. A field's `key` is its API identifier. `label` is its display name. Keep existing field IDs when editing a definition: changing an identity is not a safe rename. Changing a label never renames the key. A new field takes its key from its label until you type one. An existing key changes only through **Field key**, which keeps the field's ID, and the rename is reviewed before saving. Apply model changes only after reviewing their compatibility result. If an existing model cannot load, choose **Try again**. Its editor stays unavailable until the definition loads. Saving locks the editor while compatibility checks and the save finish. ![The Content Builder showing the site's Page and Article models](/assets/screenshots/model-builder.webp) ![The Article model editor with its field list and model settings](/assets/screenshots/model-editor.webp) ## Field types | Type | Configuration to review | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `text` | Single or multiline, with rows, a placeholder and a default. Supports length and pattern validation. | | `rich` | Rich-text editor with configurable toolbar. Stores one rich document as canonical HTML, up to 256 KiB: text blocks, tables, image galleries, audio, video and file blocks, and embeds of a YouTube, Vimeo or allowed https link ([embed origins](/guides/blog/#embed-origins)). A save that holds anything else is refused, naming the field, language and element. The public API serves the checked HTML; render it with the page contract's `renderRichDocument`, as Dee Wan's own renderers do for Blog posts and Latest entries summaries ([Blog sites](/guides/blog/#rich-text-safety)). | | `number` | Step, default and display format: `integer`, `decimal`, `currency` or `percentage`. Supports numeric bounds. | | `boolean` | Optional default boolean. | | `select` | Required option list of strings or `{value, label}`. Supports single or multiple values and a default. Custom values are available only for single selection. | | `date` | Format `date`, `datetime` or `time`. Supports a default and date bounds. There is no separate `datetime` field type. | | `point` | Latitude and longitude stored together. See [timezone-aware dates and points](#timezone-aware-dates-and-points). | | `media` | Kinds of file (`image`, `document`, `audio`, `video`, `file`; images when none is chosen), maximum bytes, single/multiple files, and crop rules (aspect ratio, minimum size). A save that places a file of another kind is refused (`media_kind_not_accepted`), and so is one past the maximum bytes (`media_too_large_for_field`). Only images are framed: a crop or focal point on another file is refused (`media_framing_not_image`). On a top-level media field an editor sets a focal point per image. With an aspect rule the crop follows it. Framing is stored beside the reference as fractions of the image and never changes the file. Nothing resizes or crops the stored bytes; a generated site can deliver resized, cropped copies (see [preview and client](/guides/preview-and-client/#images-on-a-generated-site)). An image can have a default focal point (Media center → Edit image). It only starts a new use of that image; existing uses keep their own framing. A site copy carries it. Inside a component the framing travels in the value: a framed use is `{ media_id, crop?, focal? }`, an unframed one stays the bare id. A page section's image is framed per slot in the page inspector (page contract version 4). Which kinds and formats the library takes at all is a site setting (Settings → Site → Media files). An image can be cropped and rotated into a new copy (Media center → Edit → Crop and rotate); uses of the original are not changed. | | `json` | Accepts any JSON value, array or object, with optional JSON Schema text. A page's `slides` document has the additional page contract. | | `relation` | Target model slug (several models: see [A relation to several models](#a-relation-to-several-models)), display field and cardinality: one-to-one, many-to-one, one-to-many or many-to-many. In one-to-one and one-to-many each linked item belongs to only one entry; another entry pointing at it in the same field is refused with `relation_target_taken`. Every linked item shows the entries that point at it under **Related from**, by source model and field, for all four kinds. An editor who may read the item and update the source can add the item to the source or remove it. That change is the source's own save: a new draft version of the source, live after the source is published, and it applies to every language. When a one-to-one or many-to-one source already points at another item, the editor first confirms a replacement that names both items. Making an existing field one-to-one or one-to-many is blocked while two entries share a target (`relation_targets_shared`). Component fields do not offer one-to-many. Deleting either entry removes the link and keeps the other entry. Ordinary relations stay within the site. **Shared references** is available on model-level relation fields only; component fields cannot enable it (`shared_reference_component_unavailable`). A nonhierarchical many-to-one or many-to-many field can opt into explicit shared references (`sharedReferences: true`), requiring a grant from the source and destination administrators; these references read published public source entries and grant no source editing access. Shared-reference fields cannot use contextual target filters (`shared_reference_choice_unavailable`). A one-to-one or many-to-one relation to its own model can be marked **Hierarchy (parent/child)** (`"hierarchical": true`). Then a save or restore that makes an item its own parent, or puts it under one of its own children, is refused with `relation_hierarchy_cycle`. It cannot be turned on while stored links already loop; the model save lists them. The generated API refuses the same loops with the same code, inside the database write as well (see [relation and translation writes](/reference/standalone-backend-api/#relation-and-translation-writes)). | | `component` | Allowed components (`components`: one slug, or several for a list of mixed blocks), repeatability, folding and per-use display overrides. Site-owned definitions can explicitly override shared definitions. See [mixed component fields](#mixed-component-fields). | ## Rich-text snippets In a rich-text field of an item, select text and choose **Snippets** → **Save selection as snippet** to keep it under a name. **Snippets** → **Insert a snippet** searches the site's snippets and inserts one at the cursor. Insert makes a copy: renaming, changing or deleting a snippet later never changes text already inserted. Anyone who may create or change items of the model can use snippets in its rich-text fields. You can rename, replace or delete the snippets you saved; people with `site:settings` can manage all of them. A site keeps at most 200 snippets, each up to 64 KiB. A name has 1 to 80 characters and is unique in the site, ignoring case. A snippet holds what a rich-text field accepts; raw HTML, styles and scripts are refused. Snippets are private: they never appear in the public API or on the website. A site copy (format 1.12) carries the snippets and the images they show. Undo of its import removes only snippets the import added that nobody has changed since; a changed one is kept and listed. ## Shared field settings Use required and translatable settings deliberately. Validation supports text length/pattern, numeric bounds, selection counts, repeatable component counts and date bounds. A minimum above its maximum, and a pattern that does not compile, are refused when the model is saved. The server checks content saves against the same rules as the editor. UI settings include hidden, grouping, ordering and help text. Hiding a field is not an authorization policy. Patterns support Unicode property escapes such as `\p{L}` (letters) and `\p{N}` (numbers). Custom ASCII-only patterns remain restrictive. The generated standalone API retains declared text `minLength`, `maxLength` and `pattern` rules, including Unicode property escapes and deliberately ASCII-only patterns. After changing these rules, regenerate and redeploy the backend. See the [API reference](/reference/standalone-backend-api/) for generation refusals. ### Show only when A model or component field can depend on sibling Boolean, Select, text, number or date fields. In **UI**, turn on **Show only when…**. Choose a controller, comparison and value. Combine up to four comparisons with **All conditions** (AND) or **Any condition** (OR); groups nest up to three levels. - Boolean and Select keep the existing `{ "field": "featured", "in": [true] }` shape. - Typed comparisons use `{ "field": "price", "op": "gte", "values": ["100"] }`. Text supports equality and contains; numbers and calendar dates support equality and ranges. Empty and not-empty take no values. - Groups use `{ "all": [condition, condition] }` or `{ "any": [condition, condition] }`. Every controller must be a sibling. Invalid values, unavailable options, unsupported comparisons and dependency cycles are refused before a definition is saved. - Hiding keeps the stored value. Required and other validation rules apply only while shown. Visibility is not access control. - The editor, API, management tools and import checks use the same condition reader. Copies carry the condition; generated conditionally required columns accept empty values. Derived-field configuration requires a predefined computation or AI configuration. Its presence does not prove an external AI provider is configured. Do not treat advanced schema options as evidence that every corresponding product capability is released. ## Models, locales and pages The site chooses languages, a default language and a strict or fallback policy. Under fallback, each language can list fallback languages in order under Settings → Languages; a visitor sees the first one that has the item, then the default. The entry editor shows which language visitors see while a translation is not live. Check the returned item's locale rather than assuming it matches the requested one. A private model needs appropriate API access. Moving content into a public model exposes it to public readers. A generated page model needs the documented `title` and `slides` structure. See the [page contract](/reference/page-contract/). Other models remain API data, not automatic pages. Shared component slugs are stable. A site fork replaces the shared definition for that site's references only. ![Per-model standalone API access, operation overrides and response-cache settings](/assets/screenshots/model-standalone-api.webp) Changing a component’s field list retains the previous definition automatically. Existing entries keep their values and pinned version; the change creates no content revision and publishes nothing. New instances use the new definition. Use **Update component** on an existing instance when you are ready to migrate it. The write barrier briefly refuses concurrent content writes with `component_migration_in_progress`; repeat the save after it completes. ## Component definition versions In the component editor, **Retired** removes the type from Add and Duplicate. Existing instances stay readable and editable. Retirement does not delete content. Removing the type from a mixed field’s allowed list still requires migrating or removing its instances first. Every field-list change automatically keeps the previous definition. Existing ordinary and mixed instances retain their earlier fields and definition; new instances use the current version. **Update component** on an instance deliberately adopts the current definition after confirmation. Compatible fields keep their values; removed or changed-type fields are discarded and new fields start with defaults. Review and save the entry, then publish when ready. The library keeps up to 64 retained definitions and 2 MiB of definition JSON. The current fields, retained definitions and metadata must also fit one stored row: at most 1,900,000 UTF-8 bytes including reserved overhead. A save or import that exceeds either bound is refused as `component_retention_limit` before the component is written. Retention and retirement hold affected writers through the existing component migration barrier. Shared components on split tenant databases keep the existing cross-database mutation refusal. Site copies and generated artifacts carry retirement, retained definitions and instance pins. Saving a component definition never deletes its retained definitions. ## Mixed component fields A component field that allows several components is a list of mixed blocks, like a dynamic zone. In the field's Configuration, pick each component it allows; the order you pick is the order editors are offered. Two or more make the field a list, so it is always repeatable (`mixed_component_not_list` otherwise). Each component may be allowed once. - The config is `{ "type": "component", "components": ["hero", "quote"], "repeatable": true }`. A field allowing one component uses the same key: `"components": ["seo"]`. - The stored value is an ordered list. Each block carries `_id` (its identity), `_component` (its component), `_version` (the version of that component it was last saved against), `enabled` (`false` hides the block) and its component's fields. Every save checks each block against its own component; a retained instance keeps its `_version` until an explicit update. - A component's version starts at 1 and goes up by one each time its field list changes. Existing instances keep their definition and `_version` until an explicit update. - In the editor, **Add block** offers the allowed components. Each block's ⋯ holds Move up, Move down, Duplicate, Hide or Show, and Remove. Drag the handle, or focus it and press the arrow keys, to reorder. - A hidden block stays in the stored list with `enabled: false` for editors, history and copies. Public reads, the managed renderer, a generated site and a generated backend's anonymous and read-token answers leave it out. An A/B variant's `enabled` on a single-component field is not affected. - Adding a component to the allowed list is always safe. Removing one is refused while stored items still hold its blocks (`component_type_removed`). - Allowing a second component on a field of one component converts its stored values into blocks (`_id` `block-1`, `block-2`, …, the component, its current version, shown). Going back to the one component that remains converts blocks into instances. Each item gets a new current version labelled "Component field changed shape"; older versions keep the old shape. Going back to a field that holds one instance is refused while an item holds more than one block (`storage_shape_changed`). A conversion is refused when it would rewrite more than 200 stored values (`component_migration_too_large`). - History, restore, export, site copy, import and generated backends keep the list as stored. CSV/XLSX cells carry the blocks as JSON and can be imported through Collection files. Media inside blocks is tracked per component, so a key of another block type is never read as media. - A relation inside a component value (any component, any depth, any block) names its targets by id. A public read and published generated output drop a target that is gone, in Trash, unpublished or not readable by that reader: a single link becomes `null`, a list keeps the rest in order. The stored version is not changed. - Editorial previews resolve the latest authorized saved target drafts, including never-published entries, under the same field, locale and privacy rules as ordinary relationships. New shared preview links also recheck their creator’s current rights. See [draft previews](/guides/preview-and-client/#preview-unsaved-changes). - A site copy carries the components this site owns that its models use (`components.json`, with each one's version) and writes them before the models, reusing a component the destination owns with the same fields and refusing a different one under the slug (`component_slug_taken`, `component_shadows_shared`, `component_name_taken`). Each relation inside a component value is mapped to the entry the copy made; one the copy does not carry blocks the check (`unresolved_relation`). Undoing an import removes the components it wrote once no field uses them. - A mixed field has no AI generation or A/B variants (`mixed_component_context`). A/B variants are the instances of one repeatable single-component field, one shown at random; they are not set per block. ## Item address Each language of an item has its own address, its `slug`: the public API serves the French language at `/v1//?locale=fr` under the French slug, and a language answers only at its own slug. A single-item read lists every live language and its slug in `alternates`. A model can also have a text field keyed `slug`. When the field is translatable, each language's value is that language's address. When it is shared, it holds the primary language's address. The editor, the content API, the management API, plugins, spreadsheet import, bundle import and Strapi import all follow these rules. The spreadsheet check reports `slug_invalid`, `slug_cleared` and `slug_reserved` before apply, including two rows of one file that name the same address in the same language. The Strapi import check reports `slug_invalid`, `slug_conflict` and `slug_taken` before the run. | Save | Address of a language comes from | Refused | | ------ | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Create | `slugs[lang]`, else that language's `slug` field, else its own title, else (a translation) the primary's address. | A named slug with no letters or digits (`slug_invalid`). A named slug another item holds in that language (`slug_reserved`, `slug_taken`). `slugs[lang]` and the `slug` field naming different addresses (`slug_conflict`). | | Update | `slugs[lang]`, or that language's `slug` field value that differs from the stored one. Anything else keeps the address. | The same refusals. Clearing an address, or a `slug` field that held a value (`slug_cleared`). | - Every refusal names the field and the language: `{ reason, field: "slug", locale, slug, held_by?, version? }`. The editor shows it under that language's address, never as a toast. - A named slug is normalized: Unicode letters, numbers and combining marks joined by single hyphens, normalized with NFKC and lowercased. `Harbour Wall` becomes `harbour-wall`. The normalized address is written into that language's `slug` field, and the save response returns every language's address as `slugs`. - A title is not an address, so it never refuses. A taken title slug gets `-2`, `-3` and so on, in that language only. A slug is taken when another item's draft, current or published version holds it in the same language. Thai, Cyrillic, Japanese and other native-script titles produce native-script addresses. A title with no letters or numbers makes no address; a translation then takes the primary's address. On create, `slugs[lang]: null` means no address in that language. An address made from a title is cut to 180 characters before any `-2` suffix. - Adding a language without a slug gives it the primary's address, suffixed when that language already holds it. - An older item whose `slug` field already differs from its address keeps both through unrelated saves. Editing the field repairs it. - Renaming a draft does not move the published address until that language is published. - Each language keeps the address of its own published version. Publishing one language under a new address moves and redirects that language only. - Publishing claims each moving language's address. A page language without an address cannot be published (`page_address_required`, with its `locale`). An address another item already holds in that language is refused (`slug_reserved`), and nothing is published. - A managed route without `{locale}` gives an address only to the language a read with no locale serves: the default language, else the site's fallback order, else the primary. - Restore puts back each language's address the version recorded. A bundle import, site copy and history transfer carry each language's address in its versions. ## Different links in each language A relation field of a model with translations can hold **Different links in each language**: in the field's Configuration, turn the switch on. It is stored as `config.localizedMembership: true`. Without it, every language shows one shared list. - Each language keeps its own list and order. The editor shows and saves the list of the language you are editing, on every language, including translations. - The save API takes these lists under `locale_relations` (language → field → list, `{ set }` or `{ connect, disconnect, move }`, where `connect` items may carry a position). Sending such a field under `relations` is refused with `relation_localized_field`; sending a shared field per language is refused with `relation_not_localized`. A language the item does not have is refused with `relation_locale_missing`. - Every rule applies to one language's list at a time: to-one cardinality, item limits, one owner per target in one-to-one and one-to-many (`relation_target_taken` only when another entry holds the target in the same language), and **Hierarchy (parent/child)** loops. - A version stores these lists under `locale_relations`. Publish, preview, restore, Trash, a bundle import, a site copy and a history transfer keep each language's list. Deleting a language for good deletes its lists. A new language starts with empty lists. - **Related from** changes the list of the language that is open. Its view merges an entry's languages into one row (the primary language when it links the item); removing there changes that row's language list only. - The bulk **Categories and tags** action changes the list of each selected language. Several selected languages of one entry change in one save against one version; the other rows answer `covered`. - Turning the option on copies each item's current list to every language. Turning it off keeps each item's primary-language list and removes the others; the model review warns about it first. Turning it off on a one-to-one or one-to-many field is blocked while two items' primary-language lists would share a target (`relation_targets_shared`), and on a **Hierarchy (parent/child)** field while those lists would put an item under itself (`hierarchy_cycles_shared`). A restored version from before the change follows the same rule. - The option is refused on a model without translations and in a component (`localized_membership_unsupported`, `localized_membership_not_in_component`). - A generated backend stores such a field, to-one included, in a join table whose rows carry `locale`; every uniqueness rule holds per language. The migration loads each live language's list from the version serving it. `PUT /api//relations/` takes `locale` and writes that language's list with the same item limits, one-owner and parent-loop rules (`unknown_locale` for a missing or unknown language). The read-only join table route serves one language's list with `where[locale][equals]=`; a language with no list reads as empty, never another language's. A shared list keeps its join table without `locale`, because SQLite treats NULLs as distinct in a unique index. ## A relation to several models A relation field on a model or inside a reusable component can accept items of more than one model. In the field's Configuration, keep the **Target Model** and tick the models under **Also accept**. It is stored as `config.allowedModels`, a list that starts with the target model. Without it, the field accepts its target model alone. - A list can mix models, in any order. The picker searches every accepted model together, newest first, and each row names its model. Narrow it to one model with the buttons above the list. **Create new** asks which model to create when the field accepts several. - A reference names its target's model. The save API accepts either the instance id or `{ "model": "", "id": "" }` wherever it takes an id in a relation write (`relations`, `locale_relations`, in a list, `set`, `connect`, `disconnect` and `move`). When `model` is given, it must be the target's own model. - A save is refused with `relation_target_not_found` when a target does not exist in this site, is of a model the field does not accept, names a different model than it has, or is one the person saving may not read. The answer does not tell these apart. A duplicate is refused with `relation_duplicate_target` and a second target on a to-one field with `relation_cardinality`. - A version, a published version and a restore store each target's model beside its id. A restore is validated against the models the field currently accepts: a version that links a model the field no longer accepts is refused with `relation_target_not_found`. - Publishing, unpublishing and Trash treat an item the same whichever model points at it: unpublishing an item that live entries link to through a field lists them, and **Related from** shows the entry from every model the field accepts. - A public read serves each target as `{ instance_id, model, slug, display }`; see [Public read API](/reference/public-api/). A target of a model the field no longer accepts is not served. `relation`, `tag` and `expand` over a field that accepts several models are refused. - A site copy and a bundle import keep the list's order and each target's model. An entry whose reference names the wrong model, or a model the destination field does not accept, is not imported. - Removing an accepted model while items link to it is blocked in the model review (`relation_model_removed`), which names the items. Adding a model, or changing the order of the list, needs no review. Moving the **Target Model** to another model follows the same rule. - A **Hierarchy (parent/child)** link still accepts one model (`mixed_relation_hierarchical`). The label shown for a target is the field's **Display Field**; a target whose model has no such field shows a short form of its id. ## Unique values A `text` or `number` field of a model can be **Unique**: in the field's Properties, turn on Unique. A translated field also chooses **Unique in each language** (`locale`: no two items hold one value in the same language) or **Unique across all languages** (`model`: a value appears once in the whole model, including one item's own languages). A shared field is unique across all items. The setting is stored as `config.unique`. Component fields, other types and a per-language scope on a shared field are refused (`unique_unsupported`). - Values compare exactly as stored: text is case-sensitive and not trimmed, numbers compare by value. An empty value never collides. Unique text is at most 255 characters. - An item's current and published versions both hold their values, like an address. One item's own versions never collide. An item in the Trash keeps its values until it is purged. - A save, restore, import, spreadsheet apply or publish of an older version that would repeat a value is refused with `field_value_taken` (409). The editor shows it under the field and keeps the edit. The other item is named only to someone who may read it. The spreadsheet check reports it before apply. The import check reports it before the run, including entries of one import that give the same value, and the run blocks such an entry with a problem row. - Turning Unique on, or changing its scope, is reviewed first. The review lists items that share a value (`unique_duplicates`, with sample values) and items holding a value over 255 characters (`unique_value_too_long`). Apply is refused with `model_change_blocked` (409), naming each, until every one is resolved. A change made while Apply runs is refused (`model_change_stale`). - A generated backend gets a unique column: `@unique` on the field's column, or `@@unique([field, lang])` for per-language scope. A one-to-one relation's column is unique too. Generation readiness and the build refuse published items that share a unique value (`unique_value_shared`) or a one-to-one target (`one_to_one_target_shared`) before anything is generated. Empty unique text is written as `null`. Number inputs use decimal notation, including decimal exponents; hexadecimal, binary and octal strings are refused. ### Unique combinations In model settings, **Unique combinations** defines up to eight named groups of two to four text or number fields. Choose **Across all languages** or **In each language**. All fields in a group must use the same shared or translated storage; language scope needs translated fields. The setting is `settings.unique_groups`: `[{ "name": "catalog_sku", "fields": ["catalog", "sku"], "scope": "model" }]`. A combination participates only when every member has a value. Text compares exactly, numbers by value. Text members are bounded to 255 characters. Current and published versions reserve the tuple; save, restore, import and publication use the same atomic claim guard. Turning a group on checks existing duplicates before applying it. Generated schemas get a compound unique index, with `lang` included for language scope; readiness refuses duplicate published tuples. ## Single-entry models A model is a **collection** (any number of entries) or **single** (one entry, such as a home page or site settings). Turn on **Single entry** in the model's general settings. The kind is stored as `content_model.kind` and travels in the model apply body as `model.kind`, through the Model Builder, the management API (REST, CLI and MCP) and a site copy. Omitting `kind` on an update keeps the stored kind. - A single model holds one entry, whatever languages it has. An entry in the Trash still holds the place. Restore it, or delete it permanently to make another. - A second create is refused with `single_entry_exists` (409), naming the existing entry and whether it is in the Trash. Saves, imports, site copies, spreadsheet applies, plugins and the management API all create through the same path. Two creates at once cannot both win: the check runs again inside the write. - A site copy that carries more than one entry for a single model is blocked before the run (`single_entry_conflict`). So is one whose single model already holds an entry on this site, in the Trash too (`single_entry_taken`), unless that entry came from the same source entry, as on a re-import. - A collection becomes single only while it holds at most one entry, counting the Trash. Otherwise the change is refused with `model_change_blocked` and a `single_entry_conflict` change. A single model can always become a collection; nothing is lost. - In **Content**, choosing a single model opens its entry. When there is none, the list offers one **Create entry** action. When the entry is in the Trash, it offers **Restore**. - The public API is unchanged: `GET /v1/` answers a list of at most one item, and `GET /v1//` answers it by address. See [public API](/reference/public-api/#single-entry-models). - A generated backend holds a single model's table to one row. A unique index refuses a second row whatever writes it, and the create route answers `single_entry_exists` (409). ## Content folders A model can sit in a **folder** in the Content list, so a site with many models stays easy to scan. Set it in the model's general settings under **Folder**. It is 1 to 40 characters after trimming, and `Blog` and `blog` are the same folder. Clear the field to take the model out. - A folder exists while a model uses it. There is no folder list to manage, and folders do not nest. - Models without a folder are listed first. Folders follow by name, each opens and closes, and the browser remembers which are closed. A folder holding the open model opens itself. - The folder saves on its own when the field loses focus, not with **Save**. It needs the permission that applies model changes (`model:apply`). - It is organization for editors only. It does not change the model's revision, so it never makes an open editor stale or triggers a breaking-change review. It is not part of generation input, a generated backend or any public answer. - A site copy carries the folder with each model it creates. A model the destination already has keeps its own folder. The management API sets it with `setModelFolder` (`PUT /models/{id}/folder`, `schema:write`) and lists it as `folder` on each model. ## Private fields Any field of a model or of a component can be **Private**: in the field's Properties, turn on Private. The setting is stored as `custom_field.private`, or as `private: true` in a component's field list. - Editors see and edit private values. History, restore, export, site copy, import and authorized management reads keep them. - Every public read leaves them out: `GET /v1` items, expanded relations, related pages, the Blog feed, site search, the generated public TypeScript client, generated sites, and a generated backend's anonymous and read-token answers. A private relation or media field leaves `relations` and `media` too. Inside a component value, each private component field is removed wherever the value is served, generated sites included. - A public query that names a private field in a filter, `sort`, `fields`, `relation`, `expand` or a related-page cursor is refused exactly as if the field did not exist. - Renaming or deleting a private field never exposes its value. Published versions keep the old key in storage, but public reads, site search and generated sites serve only the fields the model declares now. - A model field cannot take a key that saved versions still hold under an earlier field. This covers new model fields, renames, and changes that reinterpret a model’s component field. A model save returns `409 model_change_blocked` with `retained_key_reuse`. Choose another key while any retained version holds that value. Component field-list edits instead keep the old definition and pins; they do not reinterpret old values. Each retained component definition keeps its own privacy plus the current definition’s private flags, matching field identity across renames and using its key when identity is absent. Other historical versions do not change that pin’s privacy. An old private value never becomes public because a later definition changes. Imports compare carried values with the recorded source and reviewed destination definitions (`unmapped_source_key`, `source_private_value`, `import_definition_changed`). See [Import](/guides/import-migration/#check-run-and-resume). - A component created or forked under a slug a site already reads is a new definition for that site's stored values. It is compared with the definition the slug read before. A key that definition did not have counts as a new field. So does a key it had as private that the new one would serve. A refusal is `409 retained_key_reuse`, and nothing is created. Confirming the override (`acknowledge_shadow`) does not lift it. A fork copies the shared definition exactly, so it changes no reading. Making a field of the site's own component public later is an ordinary privacy change. - On a deployment with separate site databases, changing a shared component’s field list or retirement state is refused with `409 split_topology_migration_unsupported`. Name-only edits remain available. Fork the component for the site before editing its fields. Creating a new shared definition or an owned shadow still refuses a reading that could expose values already stored (`retained_key_reuse` or `split_topology_reinterpretation_unsupported`); an unavailable site database returns `503 site_database_unavailable`. An owned component holds affected content types during its change. Concurrent save, restore, carried history and language restoration return `component_migration_in_progress`; an earlier planned writer returns `model_contract_changed`. Retry after the definition change and run an import check again when its reviewed definition changed. - Renaming, deleting or retyping a field also aborts any save planned against the old definition (`model_contract_changed`); retry it. If a version holding the key is saved while the check runs, the change is refused (`model_change_stale`, or `component_changed` for a component); try it again. - Turning a field private, or public again, re-indexes the model's site search and purges its cached public reads. Cached reads are also keyed by the model's private fields, so a field made private is never served from a response cached before the change. The change also aborts any save planned against the old definition (`model_contract_changed`); retry it. - A generated backend keeps private columns for editors. Anonymous reads, read tokens and every write response select only public columns. A component column holding a private field anywhere below is left out of those answers whole: the generated routes return the guard's own select, and a partial JSON projection would need a second response rewrite over every read and write route. A private list relation's join table is not served to readers. - A saved query bound to a trusted route whose operation is served anonymously (`public` in the backend policy) may not select, filter or sort by a private field. Binding such a query, or editing a bound one to name one, is refused (`private_field_on_public_route`, 422), and generation refuses the set (`saved_query_private_field`) when a policy or a field changed since. A component field holding a private field at any depth counts as private here too, as it does for the generated reader; when the component definitions cannot be read, every component field does. ## Visitor comments Any model can take visitor comments on its published entries. Turn on **Visitor comments** in the model's general settings. The setting is the model's own `comments` key in its settings (`{"comments": true}`); nothing else stores it. A Blog's post model is one such model: **Posts → Comments → Accept new comments** changes the same key. A model that is not public never shows comments. - A comment belongs to the model, the entry and one language, never to an address. Renaming an entry keeps its comments. Each language has its own thread. A reply answers an approved top-level comment of the same entry and language; there is one reply level. - Comments show only on an entry that is live now. Unpublishing or Trash hides them. Turning the setting off hides every comment and refuses new comments and new reactions; nothing is deleted. **Delete permanently** in Trash deletes the entry's comments and their reactions. - New comments wait: **Waiting**, **Approved**, **Spam** or **Refused**. Moderate them in **Content → Comments**, which covers every model and filters by model, or on a Blog in **Posts → Comments**. The rules are the Blog's: see [Blog comments](/guides/blog/#comments). | Action | Permission | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Read and moderate | `content:publish` | | Delete permanently | `content:publish` and delete on the comment's model | | Turn a model's comments on or off | `site:settings` (**Posts → Comments**, `PUT /api/comments/settings`), or `model:apply` (the model's **Visitor comments** switch, saved with the model in **Content Builder**) | | Mark a comment Helpful as yourself | read on the comment's model | ### Reactions A comment takes one kind of reaction: **Helpful**. - One reaction per visitor or person, comment and kind. Sending it again counts once. It can be taken back. Only an approved comment takes a reaction. - A visitor is told apart per comment by a digest of the site, the comment and the client address, keyed with the intake Worker's `FORM_INTAKE_HMAC_KEY`. Setup generates that key once and never replaces it. The address is never stored, and one visitor's reactions on two comments cannot be linked. Visitors on one shared address count as one. - An intake Worker without the key refuses visitor reactions (`not_configured`, 503) and says **Reactions are not set up on this site yet.** Comments keep working. Run setup again to add it. - A signed-in person marks a comment Helpful in the moderation list, as themself. - The count shows on the comment. Dee Wan keeps no reaction statistics. - One client may send 60 reactions every 10 minutes per site, counted apart from comments and forms. ### Order Visitors choose **Oldest** (the default), **Newest** or **Most helpful** (`?comments_order=`), and the public read takes `order=oldest|newest|best`. - `best`: most Helpful reactions first; equal counts oldest first; then by comment id. It reads only stored reactions and times, never the clock, so the same comments always come in the same order. - Replies always read oldest first under their comment. ### Where comments show - The managed renderer draws comments under every Blog post and every entry page a route serves. A page whose model takes no comments draws none. - An exported site draws them on Blog posts and on the entry pages of every model that takes comments. Those pages are served at request time, like Blog posts, and read the comments live; other pages stay pre-rendered. Which models take comments is fixed when the site is generated: after turning a model's comments on or off, generate the site again. - Renderers read `GET /v1/_comments?entry=&order=`; visitors post to `/v1/interactions/comment.submit` and `/v1/interactions/comment.react` on the site's host. Both work without JavaScript. See [the public API](/reference/public-api/). - With the public edge cache on, a reaction refreshes cached comments at once only when the intake Worker has the purge settings (`PUBLIC_CACHE_MAX_AGE`, `CF_PURGE_ZONE_ID`, `CF_PURGE_API_TOKEN`). Otherwise counts refresh within five minutes. Moderation and the model setting purge from the admin. ### Copies and the management API - A site copy and a site history transfer carry every comment of every exported entry, with its state, private email and reaction counts. Who reacted does not travel: the counts arrive as the comment's imported reactions and add to new ones, so a copied comment keeps its place in `best`. - The management API lists, reads, moderates and deletes comments and turns a model's comments on or off, over REST, the CLI and MCP alike. See [the management API](/reference/management-api/#visitor-comments). ## Plugin content checks An installed plugin may check content before it is saved or published (**Settings → Plugins**, under the plugin). Dee Wan's own rules run first; a check never replaces them. - A check that refuses stops the save, restore or publication. The editor names the plugin and the check, shows its message on the fields it names, and keeps your edit. Nothing is written. - A check that warns lets the change through and shows the warning. You do not save twice. - When a check does not answer, the plugin's setting decides. By default the change goes through with a note. With **Block changes if it does not answer** turned on, nothing changes and the editor says the plugin did not answer. - Saves, restores, publications, bulk actions, spreadsheet applies, imports, the management API and other plugins' writes are all checked. A plugin that is off, or not granted the content type, is not asked. ## Change safely Review the model-change preview before applying. Preserve a backup before destructive or incompatible changes. An accepted model change is not a backup. Regenerate the [public TypeScript client](/guides/preview-and-client/) after model changes and rebuild the consuming site. The field configuration authority is `frontend/src/lib/eav/field-config-schema.ts`. Generated-backend supported field types are enumerated in `backend/src/lib/graduate/input.ts`. The [standalone backend API reference](/reference/standalone-backend-api/) and [implementation details](/reference/standalone-backend-internals/#content-tables-and-mappings) describe the exported API and database rather than the CMS storage layout. ## Translated labels In the model settings or a field's **Properties**, open **Translated labels** and choose an admin language. Translate the model name and description, field label, description, help and placeholder, group labels/help, and select-option labels. Group translations appear in model settings; option translations appear in the option editor. Labels follow the person's admin language, independently of the entry's content language. Fallback is the exact admin locale, its base language, the site's default language, then the canonical label. Empty translations use the fallback. API keys, model slugs, group keys and option values never change. Model definitions, management operations and site copies retain these translations; public content values remain unchanged. ```json { "model": { "settings": { "translations": { "ru": { "name": "Статьи", "groups": { "seo": { "label": "Поиск" } } } } } }, "field": { "ui": { "group": "seo", "translations": { "ru": { "label": "Заголовок", "placeholder": "Название статьи" } } }, "config": { "type": "select", "options": [ { "value": "gold", "label": "Gold", "translations": { "ru": "Золотой" } } ] } } } ``` ## Draft validation **Draft validation** defaults to **Allow incomplete drafts**. Missing required values may be saved, including required members inside components and groups. Provided values still have to satisfy their type, range and structure. Publishing checks all required fields. Choose **Require complete fields** to apply required-field checks to draft saves too. The setting is `model.settings.validation_mode`: `draft` or `strict`. ## Timezone-aware dates and points For a `date` field with format `datetime`, turn on **Retain timezone** (`withTimezone: true`). The editor keeps the local date/time and chosen IANA timezone together. A nonexistent daylight-saving time is refused; a repeated local time requires an explicit offset choice. Changing the timezone preserves the entered local time and resolves its instant again. The offset choice stays available for repeated times, and seconds and milliseconds are preserved. Consumers receive both values: ```json { "datetime": "2026-10-18T05:30:00.000Z", "timezone": "Asia/Bangkok" } ``` Date-only, time-only and existing datetime fields keep their string representation. Timezone storage cannot be switched while saved values exist: their original intended timezone is unknown. Use an explicitly populated new field. Sorting and comparisons use the instant; the timezone remains part of the stored value. A native `point` field stores finite latitude from −90 to 90 and longitude from −180 to 180. Zero is a valid coordinate. Its two inputs save one value: ```json { "latitude": 13.7563, "longitude": 100.5018 } ``` Point filters support a radius in metres or a bounding box, including boxes across the antimeridian. Nearest sorting uses exact great-circle distance with a stable entry-ID tie-break. Counts, pagination and filtered selections use the same query. Points and zoned datetimes retain their object shape through history, imports, public responses and generated backends. ## Next step Invite your team and set their roles in [people, accounts and access](/guides/people-and-access/). ### References inside components **Also accept** is available for relation fields in reusable components, including nested components, repeatable components and mixed component lists. Each selected item retains its `{ model, id }` pair and its position. The server checks the actual target model, site and current read access when links change. Existing cardinality and field validation still apply. History and restore preserve these values. Native site copy and import map the destination entry ID and model together; an unresolved target blocks import. Public reads and generated output omit unpublished, deleted or unreadable targets without changing the stored version. ### Published ancestor chains A public hierarchical field exposes its localized published ancestors under `ancestors[field]`. Each entry carries its ID, served locale, current published display label and real site address. Each chain contains at most 32 nearest ancestors, shown from the oldest retained ancestor to the parent. At most eight public hierarchical fields are returned, in field-key order. Longer chains stop at that bound. An unavailable ancestor, a private display field or a model without a configured public address stops the chain; no URL or label is guessed. The shared SEO renderer emits `BreadcrumbList` when a valid chain and the current item's label and address are available. Changing a draft leaves live breadcrumbs alone. Publishing a renamed or moved ancestor updates the next read and invalidates the model's dependent cached output. Generated sites carry the accepted published chain from their generation input; regenerate after changing that input. # People, accounts, roles and access Source: https://dee-wan-docs.pages.dev/guides/people-and-access/ An account belongs to the installation. A grant gives that account a role on one site. Selecting a site does not grant access to it. Review the selected site before adding, changing or revoking a grant. ## Add a person Open **Settings → People** with permission to grant site access. Under **Add person**, enter the email, select the role and use **Add**. The role cannot be raised above your own. - **The address already has an account**: it gets the role at once. No email is sent. - **No account yet**: no account is created. An invitation is made instead. Nobody gets access until the person accepts. How the invitation travels: - **Mail configured** (`RESEND_API_KEY` and `MAIL_FROM`, or your own [mail transport](#mail-transport)): the link is emailed. The screen says so only when the provider accepted the message. - **Mail not configured**: nothing is emailed. Copy the link and send it privately. The link is shown once. The person opens the link and types the address it was sent to. With mail configured, a code is emailed to that address and must be entered: the code, not the link, proves the address. Without mail, the typed address is the only check, and a new account is recorded as unverified. A person accepting an invitation for a new address chooses their own password. An existing account gets the role and keeps its password. An existing account with no way to sign in gets a password only after the code proves the address; without mail it gets the role, and a superadmin issues a password link. Links work once and expire after 7 days. **Resend the invitation for {email}** issues a new link and the old one stops working. Resend is refused with `escalation_refused` when the invitation's role is above your own; the old link keeps working. **Cancel the invitation for {email}** asks for confirmation and invalidates the link. Pending and expired invitations are listed under **Invitations**, below the people, with their actions. Accepted and cancelled ones move under **Earlier invitations ({count})**. An expired, cancelled or used link shows the same refusal, whichever it was. If a role or your own access changes while you add someone, invite or resend, nothing is granted, created or sent, and a resend keeps the old link working. When you may no longer grant site access, or the role is now above your own, the refusal says so (`missing_permission` or `escalation_refused`). Otherwise the screen says a role or your own access changed and nothing was saved: try again. ![Settings, People: the current access list and the Add person controls](/assets/screenshots/people-access.webp) Each row shows where the person stands: - **Can sign in** (a password, a linked provider or a verified address) - **Link pending** with its expiry - **Link expired** - **Needs setup link** - **Cloudflare Access** (Access is configured and the product cannot tell whether the person uses it) - **Deactivated** After **Add** grants an existing account that cannot sign in yet, a superadmin is offered **Create link**. Anyone else is told to ask a superadmin. Share that link privately. Never put it in content or screenshots. A superadmin can revoke a live link from the row, including a recovery link for someone who can already sign in. Revoking also removes a pending sign-in link the person requested. A deactivated account gets no link: reactivate it first. Neither a grant nor an invitation is public self-registration. ## Mail transport Resend is the default. To send through your own service instead, set `MAIL_TRANSPORT_URL` (a var), `MAIL_TRANSPORT_TOKEN` (a secret) and `MAIL_FROM`. Dee Wan has no mail server and no SMTP client; your service does the delivery. - **When it is used.** With `MAIL_TRANSPORT_URL` set, every message goes to your service and Resend is not used. If the token or `MAIL_FROM` is missing, or the address is refused (it must be `https://`, with no credentials and a public address), mail is **not configured**: nothing is sent and the invitation or password link is shown to you to pass on. - **The request.** One `POST` per message, with `Authorization: Bearer `, an `Idempotency-Key` header and JSON `{ "message_id", "from", "to", "subject", "text" }`. `message_id` equals the key. The same message sent again carries the same id. - **The answer.** Your service answers HTTP 200 with JSON: `{ "status": "sent" }` (optionally with an `id`), `{ "status": "sent", "duplicate": true }` when it already delivered that `message_id`, or `{ "status": "failed" }`. Any other status, body or redirect, or no answer within 10 seconds, counts as failed. A retry of one message id therefore delivers it once and is never reported as a second delivery. - **What you see.** Sent, failed or not configured, as your service reported it. A failed or missing delivery never undoes what was created: the invitation or password link exists and is shown to you. - Plugins never receive the token, and never see a message. ## Choose the smallest sufficient role | Built-in role | Scope | | --- | --- | | `admin` | Full site administration. This does not make the account a deployment superadmin. | | `editor` | Content creation, editing, publishing, unpublishing, approval and export; full media access. No model or site administration. | | `author` | Read and create content. Edit owned entries only. Add media files, not change or delete them. No publishing, unpublishing, approval or restore. | | `viewer` | Read-only access. | Custom site roles can differ: inspect their definitions under Settings → Roles rather than inferring permissions from a name. Author ownership is checked on content, including both translation and primary where an operation affects both. A shared draft and the editing lease follow ownership of the row in that language: an own-only role (`read:own`, `update:own`) reaches them only on a language row it owns, and is otherwise refused with `not_owner`. A superadmin has deployment-level authority across sites. In Settings → Roles, **All models** grants apply to every model, including models with their own row. Buttons edit direct grants; the ↳ indicator shows inherited grants. Access combines both: removing a direct grant does not remove inherited access. Editing a `*` all-actions grant replaces that token with individual actions; an unchanged save preserves it. ## Media permissions Files in the [media library](/guides/media/) have three separate permissions, so you can grant the right to add, change or delete them independently: | Permission | Allows | | --- | --- | | `media:upload` | Add files, including private files. | | `media:update` | Change alt text, focal points, replace a file, folders, tags and public or private visibility. | | `media:delete` | Delete a file. Deletion is refused if content or retained history uses the file, regardless of who requests it. | `admin` and `editor` hold all three. `author` holds `media:upload` only, so an author can add files and cannot change or delete one. `viewer` holds none. A management API credential with the `media:write` scope carries all three, narrowed to what its creator holds when it is used. An import needs `media:upload` for the files it adds and `media:update` for the folders it creates, and undoing one needs `media:delete` and `media:update`. [Rich-text snippets](/reference/models-and-fields/#rich-text-snippets) follow content access: anyone who may create or change items of a model can use them there and manage the ones they saved. `site:settings` manages every snippet. [Workflows](/guides/content-editing/#workflows) are administered with `site:settings`: creating, editing and deleting a named workflow, choosing the workflow of a model and reviewing the move of items in review. [Feedback](/guides/content-editing/#feedback-and-notifications) on an item follows access to that item: reading it needs read access, writing and resolving need update access. A notification is shown only while its recipient can read the item. ## Limit a role to some fields or languages By default, a role can access every field and language of the models it may use. No additional configuration is needed. To narrow a custom or built-in role, open **Settings → Roles**, edit the role and open **Limit fields and languages**. - **Limit fields** lists the model's fields, plus **Search and sharing** and **Address**. Tap a field to cycle it through **Edit**, **View** and **Hidden**. - **Limit languages** picks the languages the role works in. The choice applies to every action the role holds: create, update, delete, restore, review, publish and unpublish. **Can read every language** keeps reading open, so a translator can see the source while editing only their own language. - The **All other models** row limits languages for every model the role reaches through its default grants. A model's own setting replaces it. What a limit does: - A hidden field is left out of the editor, version history and comparisons, the content list search, the spreadsheet export and the management API. A view-only field is shown disabled. - A save that names a field the role may not change is refused with `field_denied` and names the field. Nothing is dropped silently, and what is stored in fields the role cannot see is kept. - An action in a language outside the role is refused with `locale_denied` and names the language. An operation that spans languages, such as a whole-entry publish or a bulk action, is refused in its entirety. - A shared (non-translatable) field, a shared relation list and media live on the primary language. Changing them needs the role to update the primary language. Sending a shared value unchanged with a translation is not a change. - **Restore French only** in version history (the button names the language) restores one language: its fields, its own relation lists and its address. Shared fields, shared relations, media and every other language stay as they are. It needs the restore action in that language only, so a translator can recover their own language. - **Restore this version** restores the whole entry: every language and every field. It needs the restore action in every language of the entry. - Either way, a field-limited role may restore only where the fields it cannot change already match the version. - A shared draft follows the same limits. Every draft answer, conflicts included, carries only the fields the role can read. A page draft shows an empty title without read on the title and is refused with `field_denied` without read on the sections. Joining or taking over the editing of a language needs the role's change grant there (create, when that language has no row yet); otherwise it is refused with `locale_denied`. - Feedback follows the same limits. A note belongs to the language it was written in. A role sees only the notes of languages it may read, writing a note needs the update action in that language, and resolving or reopening a note needs both read and update in the note's language. Otherwise the request is refused with `locale_denied`. - Languages the role may not read are hidden from the editor's language list and from content lists. A preview link serves only what its maker may read and never falls back to a language outside the role. - The update-only **Export spreadsheet** → **Import spreadsheet** round trip needs read and update on the model. An own-only grant (`read:own` or `update:own`) exports and accepts only the person’s own items; a row for anyone else’s item answers `spreadsheet_row_not_found`. Columns include only fields the role may both read and change. View-only fields are left out; stored hidden values are preserved on apply. Sheets include only readable languages. Changing a language without update access is blocked with `locale_denied`. CSV/XLSX create and upsert use the collection path below instead. - **Collection files** exports JSON with `read` on the model and only the rows, fields, related items and languages that read grant reaches. Its JSON, CSV and XLSX imports check permissions per row: creating needs `create`; updating needs `read` and `update`, and an own-only grant reaches only that person’s items. Upsert needs the grant for each row’s actual action, not both actions for every row. Adding a translation to an existing item also needs create access in that language. Every changed field and language needs its write grant; unchanged values stay untouched. See [content editing](/guides/content-editing/). - A whole-site export copies every field and language, so a role that limits reading cannot start one. - When someone holds several roles, each field and language keeps the strongest grant that covers it. A role with no limit for an action makes that action unlimited. `update:own` in one language beside `update` in another stays owner-restricted in the first: the merged set records it as an `update:own` language list. - You can hand out only limits within your own. Saving a role that reaches a field or language you cannot is refused with `escalation_refused`, and the editor says so before Save. - Limits name fields by key. Renaming a field key in the model builder rewrites every role's limits to the new key once the rename is saved. - The page composer opens a page only in a language the role reads and refuses a page whose sections the role may not read. The page library hides titles and languages the role may not read. - Roles, limits included, can also be managed through the [management API](/reference/management-api/#roles) with the `roles:write` scope. ## Change or revoke access Change a grant's role in the People list or revoke that site's grant. Revocation is not account deletion. A superadmin can deactivate an account and revoke its sessions. Reactivation requires a fresh sign-in. Self-deactivation and removal of the last active superadmin are refused. Site access also protects the last administrator. ## Sign in through Cloudflare Access This needs an installation with Cloudflare Access set up. Open **Settings → People → Cloudflare Access sign-in** with permission to grant site access. A rule maps one exact email domain, such as `example.com`, or one Access group to one role of this site. When the switch is on and a person verified by Access signs in with a matching domain or group, they get an account and that role in one step. Subdomains do not match. A group name must match your identity provider's exactly, including case. Groups are read from Cloudflare Access (`get-identity` on your team domain), not from the sign-in token, which can drop groups. They are read only when some site has a group rule, and once per Access sign-in session. - **Later sign-ins** update the role to match the rules. Removing a rule removes the role it gave. - **People added by hand or by invitation keep their role.** No rule changes or removes it. People shows **Access** beside a role a rule gave. - **Several rules match one person** (a domain and groups): the role that includes the others applies. If none includes the others, that site gives no role. Two rules for the same domain or group that cannot combine are refused when saved. One that becomes ambiguous after a role edit shows **Conflict**. - **Groups cannot be read**: no account or role comes from a group rule, and roles a group rule gave are not changed or removed. Domain rules still apply. A first sign-in that only a group rule could admit is refused and can be retried. - **The last administrator** of a site is never removed or downgraded by a rule. The screen names who is kept. - **Off** stops every automatic change: no new accounts, no role changes, no removals. Existing accounts and roles stay. - A deactivated account stays refused. No rule makes anyone a superadmin. Password, email link, Google and GitHub sign-in still create no accounts. - You can only hand out a role within your own. Changing a rule, even only its domain or group, is refused with `escalation_refused` when the rule's role is above your own. Deleting such a rule, or changing it to a role within your own, is allowed. - If a role or your own access changes while you save a rule, nothing is saved. When the rule's role is now above your own, or you lost permission to grant site access, the save is refused with `escalation_refused` or `missing_permission`. Otherwise the screen shows the latest rules; try again. - A role a rule uses cannot be deleted. Change the rule first. A site holds at most 100 rules. Every change to the switch or the rules, and every role a rule gives or removes, is recorded in the site's administrative history. ## Replace a superadmin Follow [Replace a superadmin](/guides/first-administrator/#replace-a-superadmin). A replacement person uses a separate account promoted with **Make superadmin**. To change only your own email, use the verified Account security flow below. ## Passkeys A superadmin switches passkeys on from the **Installation** page, under **Sign-in methods**. They are off by default. Passkeys require the admin interface to be served from a domain name or `localhost`. On an IP address such as `127.0.0.1` browsers refuse them, so the switch stays unavailable and says why. Each person then manages their own passkeys under Account security. See [Passkeys](/guides/first-administrator/#passkeys). **Lost device.** Sign in another way (password, email link, Google or GitHub) and remove the lost passkey under Account security. With configured mail, use an enabled email sign-in link or the password-reset request on the sign-in screen. Without mail, a superadmin issues a password link from the People list, as described below. The person sets a password and signs in with it, then removes the lost passkey. ## Password ownership and recovery Claim and reset links expire after 15 minutes and are single-use. Issuing a new one invalidates the previous link. The password must have at least 12 characters. Public signup is unavailable. With mail configured, sign-in offers a self-service password-reset request; without mail, an authorized issuer provides the recovery link. For the existing sole active superadmin who is locked out, the deployment owner may use `npm run claim:link -- --remote --email ` from the CMS repository. It refuses to create a new account or reset an arbitrary user. First installation uses the installer claim instead. See [installation](/guides/installation/) for prerequisites. Authorities: `backend/src/api/sites.ts`, `backend/src/api/users.ts`, `backend/src/lib/invitation.ts`, `backend/src/lib/access-provisioning.ts`, `backend/src/lib/access-identity.ts`, `backend/src/api/access-provisioning.ts`, `frontend/src/routes/settings/components/tabs/People.svelte` and `scripts/claim-link.ts`. Access in a standalone backend is separate: see [optional artifact authentication and access](/reference/standalone-backend-api/#optional-artifact-authentication-and-access). CMS accounts and roles are not exported into it. ## Next step Build with Dee Wan, starting with [preview and the generated TypeScript client](/guides/preview-and-client/). ## Change your email or recover your password Open **Account security → Change email address** and enter your new address. Enter the code sent to the new mailbox; it expires after 15 minutes. The new address must be unused. Your account ID, site access, credentials and content history stay unchanged. All sessions end after confirmation, so sign in with the new address. This requires mail delivery. Google and GitHub callbacks never change the account email. On the sign-in screen, choose **Forgot password?**, then enter your email and request a link. The option is available when mail is configured; no email is needed to open it. The response is the same for known and unknown addresses. An active account receives a single-use link, valid for 15 minutes. Open the link and choose a new password. Without mail, ask a superadmin for a reset link from People. The last superadmin uses `npm run claim:link -- --remote --email
` with deployment credentials. ## Entry rules in custom roles Open **Settings → Roles**, edit a custom role, and open **Limit access by entry**. Choose a model and action, then **Add entry rule**. Match the creator or a field value; choose an operator and a fixed value, current user ID, email or roles. Combine conditions with **All conditions (AND)** or **Any condition (OR)** groups. The builder allows four conditions and three group levels; incomplete rules disable Save. Server refusals remain visible when a field or permission changed. Model permissions still decide which actions the role grants. Entry rules narrow those actions using current entry values. Field, language and ownership limits still apply. A scoped update must satisfy both read and update rules before and after the change; changing a department cannot move an entry outside your rule. Create checks the proposed values. Delete and workflow actions check the current values. Saving or restoring one language checks the primary entry and each language changed. Unchanged sibling languages add no permission requirement. ```json {"article":{"read":{"field":"department","op":"in","values":[{"user":"roles"}]},"update":{"field":"assigned_editor","op":"eq","values":[{"user":"id"}]}}} ``` Each model key is its slug. Actions are `create`, `read`, `update` and `delete`. A condition uses a field key, `op` (`eq`, `in`, `contains`, `empty`, `not_empty`) and `values`. Values are strings or `{"user":"id"}`, `{"user":"email"}`, `{"user":"roles"}`. `{"owner":true}` means an entry created by the current person. Combine conditions as `{"join":"and","conditions":[...]}` or `{"join":"or","conditions":[...]}`. A rule has at most four conditions and three group levels. Shared fields use the primary language's values. Roles add authority: a person holding an unrestricted role can still read its permitted entries. Ownership and entry rules from one role stay together when several roles are combined. Invalid rules cause that role's grant to be refused. A delegated role cannot relax your entry-rule ceiling. Rules do not change public publication or URLs. Existing saved links keep their IDs when their targets become unavailable to your role; hiding a name does not disconnect the link. Revision history shows target names and addresses only when your current access permits them. The stored revision is unchanged. Value rules support text, select, boolean, number and date fields. Select `eq` and `in` match exact choice IDs, including values in a multi-select; `[]` counts as empty. `contains` works only on text. Number comparisons use numeric values, boolean comparisons use `true` or `false`, and dates use `YYYY-MM-DD`. Missing or renamed fields deny access, including an `empty` rule. Use `empty` or `not_empty` with `values: []`; blank comparison values are refused. ## Administrative history Open **Settings → History** with `audit:read` permission to review changes to this site’s models, roles, access, tokens, settings, hostname and deployments. Filter by action, outcome, person, target or dates; **Download CSV** exports the filtered results. An **Unknown** outcome does not confirm success or failure. Rows cannot be edited or deleted here. Content changes have their own [version history](/guides/content-editing/#version-history). # Preview and the generated TypeScript client Source: https://dee-wan-docs.pages.dev/guides/preview-and-client/ The generated public client talks to the CMS public `/v1` API. It is not a client for the detached backend's different API. The [standalone backend API reference](/reference/standalone-backend-api/) describes that API. ## Generate the client Open **Settings → API → TypeScript client**. The card shows the schema identity of the client for the models your role can read and lets you copy or download the file. The file starts with a `@dee-wan/public-client` comment that records the site, public URL, contract version, schema identity and the contract itself. The schema identity is a SHA-256 digest of the locales, the locale policy, and each model's visibility and field types. Labels, names and field order do not change it. To refresh the client from your repository, download `dee-wan-client.mjs` from the same card and commit it. It needs Node 18 or later and no packages. It reads a site API token from `DEE_WAN_SITE_TOKEN`. Create the token under Settings → API and keep it in a server or CI secret. Private models must be in its scope. Substitute your site's public origin; the values below are placeholders. ```sh DEE_WAN_SITE_TOKEN=dw_site_… node dee-wan-client.mjs pull --url https://www.example.com --out src/dee-wan-public.ts ``` The command reads `GET /v1/_client` on the public origin. It writes the file only when the response is complete and its header matches the served identity. The pulled client covers public models and the private models in the token's scope. Its identity matches the one in Settings only when both cover the same models. Before each deploy, for example in CI, check the committed file: ```sh DEE_WAN_SITE_TOKEN=dw_site_… node dee-wan-client.mjs check --file src/dee-wan-public.ts ``` `check` exits 0 when the identity matches, or when every change is compatible: an added model, field or locale, a field made required, a model made public, or a removed select value. It lists those changes. It exits 1 on a breaking change: a removed model, field or locale, a changed field type or list shape, a field made optional, a new select value, a model made private, or a changed site, default locale or locale policy. It exits 2 when it cannot run: bad arguments, no token, a network or HTTP error, or a file without the header. After a change, run `pull`, fix what no longer compiles, and commit. An admin session can still fetch the same file by hand: ```sh curl --fail --silent --show-error -H "X-Site-Id: $DEE_WAN_SITE_ID" -b "$DEE_WAN_SESSION_COOKIE" 'https://admin.example.com/api/models/public-client.ts' -o /tmp/dee-wan-public.ts ``` The file contains exact model fields, locale and query types, response envelopes and model methods. It is dependency-free TypeScript, not a package to install. Your application never imports the command, and Dee Wan never changes your repository. ## Use a model method This example assumes you actually created a public `article` model and an `en` locale. Use your site's public hostname, not the admin origin and not a base ending in `/v1`. ```ts import { createClient } from './dee-wan-public'; const client = createClient({ baseUrl: 'https://www.example.com' }); const first = await client.article.list({ locale: 'en', limit: 20 }); const item = await client.article.get('hello', { locale: 'en' }); ``` Server-side and build-time reads need no CORS setup. For a browser app on another origin, allow it under **Settings → API → Browser origins**; see [browser origins](/reference/public-api/#browser-origins). Private models require `token` in `createClient` options, kept in a server secret. Never publish it in a browser bundle. Inspect `data` and the list's `page` metadata. Forward opaque cursors unchanged and check the returned locale under fallback. See the [public API](/reference/public-api/) for error and pagination contracts. ## Activate a reported Astro origin After managed source delivery, Source & delivery shows the repository owner and name, exact deployed commit and reported Astro origin. Completion labels that origin **Preview unverified** and leaves the existing preview target active. While signed in with `site:settings`, choose a readable saved item and locale and select **Verify and activate preview**. The server loads the recorded origin and its renderer contract, then runs the existing draft preview proof. The browser cannot substitute another origin in this action. A successful proof marks that candidate active. A failed proof, unreachable origin, wrong-site contract or concurrent settings change leaves the previous active target unchanged and displays an actionable refusal. No build callback or system principal activates a renderer. The **Advanced — custom renderer** flow remains available for explicitly configured renderers. ## Open an actual rendered draft preview The editor's preview action creates a one-item capability valid for 30 minutes. It reads that draft and its authorized related saved drafts, not the whole site, and does not bypass private-model access. New links recheck the creator’s current account, site membership and read permissions on every request. The root fields and languages remain limited by the permissions recorded when the link was created. Older links without this related-draft grant keep published-only relationships. Never log, index or share the capability as a public URL. Invalid capabilities are refused. Request a new preview rather than reusing an expired link. Configure the site's preview renderer origin and per-model route template under **Settings → Source & delivery → Advanced — custom renderer**. The contract uses an HTTPS origin (plain HTTP only for a loopback address: localhost, 127.0.0.1 or [::1]). It uses route placeholders `{slug}` and `{locale}`, and a token query-parameter name. The renderer must pass verification for that site before the CMS reports `rendered: true`. Configuration alone is insufficient. Without a verified renderer, Preview opens the public API JSON and reports `rendered: false`. For a generated Astro site, explicitly set `previewApiOrigin` in `apps/site/src/owned/extensions.ts` to the CMS public API origin and verify the renderer contract. It is `null` by default. Only `/preview` uses that runtime bridge. Normal published pages use their generated snapshot, except pages with a Latest entries section and a Blog's `/blog` routes: those read published entries at request time from `/v1` on the site's own host (the Dee Wan public Worker in the reference topology), or from the owned `publicApiOrigin` in local development, and answer `no-store`. Latest entries in a shared section shown in a region are pinned at generation instead. Preview responses are `no-store` and must not enter search or public caches. A detached site should disable this bridge when it no longer uses the CMS. ## Edit from the preview In the entry editor, **Preview here** opens the saved render beside the fields. It is offered when the site's verified renderer routes the model and speaks an editor bridge. Wide screens (1280 pixels and up) show fields and render side by side. Narrower screens show one pane at a time, with **Fields** and **Preview** tabs. **Preview on site** still opens the render in a new tab. The render shows the saved version unless the site supports previewing unsaved changes (see Preview unsaved changes below). Without that support, unsaved changes do not appear until you save, and the pane says so. The current managed and generated Blog renderers let you edit ordinary text and rich text at the rendered value. Double-click the value, choose its **Edit** badge, or focus its field link and press **Enter** to open the normal field control over the preview; format rich text there, then choose **Done**. Changes remain in the editor draft until **Save**. Other fields and older renderers open the matching form field. A custom-model renderer can opt in with `data-dw-editor-inplace` on its bridge identity marker; it must draw field links and the verified draft. The frame only identifies the field and its position; the editor checks the current field and permissions and owns all changes. With a field-linked renderer (editor bridge 3, which generated sites declare): - Double-click a rendered value, Alt-, Ctrl- or Cmd-click it, press its **Edit** badge, or focus it and press Enter. Where in-place editing is unavailable, the editor switches to the fields, opens the component, block or panel that holds the field, and focuses it. - Focus a field in the editor. The render outlines every element that shows it and scrolls to the first. - On a page model the sections, the title and the facts are edited in the page editor. Selecting one of these values offers **Open page editor**. - A plain click in the render follows no link, so the preview stays open. With a renderer that speaks only editor bridge 1, the preview works and the editor says that field links are not available for this renderer. In the pages workspace, **Site preview** uses the same frame: selecting a value focuses the section field it names. The built-in renderers (a generated site and the managed renderer) link page models and Blog posts. On a post, the title, the date, the cover, the author, category and tag links, and each body block (including text in plugin blocks) link to their fields. Their renderer contracts route the `post` model, so verify and activate the renderer again after you regenerate or redeploy. Other ordinary models get field links only from a custom renderer that speaks bridge 3. See [the page-document contract](/reference/page-contract/#editor-bridge-and-field-links). A managed preview may be framed only by the editor origin its link names, and only with the editor's nonce. It then allows that one ancestor and the bridge script by hash. Any other preview or published page keeps `frame-ancestors 'none'`. ## Preview unsaved changes When the site's verified renderer declares that it draws a model from a signed draft, **Preview here** shows what you have typed and not saved. Text, components, relationships, the images you picked and the language-specific address update after you pause typing; one update is in flight at a time. The managed renderer declares this for the Blog `post` model. Any other model, and any renderer that declared nothing, shows the saved version and the pane says why. Verify and activate the renderer again after you update the site design. - Nothing is saved. The values undergo the same validation as a save and are then rendered; they never change the stored draft, the published version, a revision or a public read. The server does not log them. A request is refused when it is over 1 MiB. - Previewing unsaved changes requires permission to edit the entry in the language shown. A reader sees the saved version. - Relationships use each target’s latest saved draft, including entries never published. Your unsaved relationship choices are included; edits still unsaved in another entry are not. Ordinary and component relationships follow the same current item, field and language permissions and private-field projection. Expansion follows the site’s language fallback policy, stops after four levels or 128 targets, and truncates cycles to stable identities. Related items arrive under `item.expanded`; public reads stay published-only. - Images are the files you picked in each image field, with the crop and focal point you set, rendered as they would be in a saved entry. The server looks every file up by its id in this site's library with your current permissions; it never uses an address the browser sends. A file from another site, a file that no longer exists and a private file placed where a saved entry would refuse it (anything but a field of a private model) are refused, and the pane identifies the rejected file. A private file in a private model's field shows its facts and no address, as a saved entry does. The language's address is the one you typed, drawn at that address. Nothing is reserved: no address is claimed, no redirect is written, and the preview does not change the live site's published address. - The same field links apply: click a rendered value to open its field, and focus a field to outline its value. - A page model is previewed in the page editor, not here. - If the entry changed elsewhere, its language has no saved content, or the site's design cannot draw it, the pane keeps the last good render and says so. Retry after fixing the cause. ### Generated and owned renderers A generated Astro Blog site carries the verifier. It checks the draft with a **public** key: no render secret, account, session or provider credential is placed in the site, and signature verification does not call the CMS. The installation needs `MANAGED_RENDER_HMAC_KEY` and `FRONTEND_URL`; without them the key is not available and **Settings → Source & delivery → Advanced — custom renderer** says so. 1. Open **Settings → Source & delivery → Advanced — custom renderer** once. **Unsaved-entry preview key** records the site's public key for generation. 2. Generate the site again and deploy it. The generated site carries that public key, so you do not need to paste it. While no key is recorded the site declares nothing and its endpoint answers `501`. 3. Press **Verify and activate** in **Settings → Source & delivery → Advanced — custom renderer**. The site declares `entry_preview` with its Blog model and the key id in `/.well-known/dee-wan-renderer.json` only while a key is present. **Preview here** then shows unsaved changes. To override the generated key, press **Copy key** and paste the JSON into `apps/site/src/owned/entry-preview.config.ts` in place of `null`. The file is yours: regeneration never replaces it, and a value there wins over the generated key. - The signed draft names the site, the renderer's origin, the entry, the language, the version you loaded, the exact bytes and a 60-second expiry. The renderer refuses any other site, origin, entry, language, version, bytes, editor origin or expiry. The frame must be opened from the editor origin named in the signed draft. - The endpoint is `/.well-known/dee-wan-entry-preview`. It takes the editor's form post and writes, caches and logs nothing. - Astro refuses cross-origin form posts, and the editor's frame is on another origin. A generated Blog site therefore turns that check off and repeats it in `src/middleware.ts` for every address except this endpoint. - **Rotate key** starts a new key and is recorded in the audit ledger as a site settings change. The old key stops working at once: generate and deploy the site again (or update your override) and press **Verify and activate** again. Until then **Preview here** shows the saved version and says the site verifies an older key. - If the site declares no key, or an older one, the pane shows the saved version and says why. A renderer you own copies `apps/site/src/lib/entry-preview-verifier.ts` from a generated site. It has no imports and uses only Web Crypto Ed25519 (Node 24, Cloudflare Workers, current browsers). Call `verifyEntryPreview({ envelope, body }, config, context)` with the two form fields `envelope` and `body`, the pasted configuration, and a context of your renderer's `site_id`, own `origin`, release identity (`renderer_contract_digest`), the `models` it draws, `now` in Unix seconds and the `editor_origin` query parameter of the frame's address. On success it returns the verified `item` to draw with your own post view; otherwise a refusal with `status` (`403`, `409`, `413`, `422` or `501`) and `reason`. Declare support in `/.well-known/dee-wan-renderer.json` with `entry_preview: { "version": 1, "models": ["post"], "key_ids": [] }`, each model also routed; `entryPreviewDeclaration(config, models)` builds it, and returns `null` while there is no configuration. Do not declare it until the endpoint works. A renderer that shares the installation's render key (the managed renderer) declares no `key_ids`. ## Images on a generated site A generated Astro site draws every image through one rule, `deliverImage` in `apps/site/src/lib/media-presentation.ts`. The preview route and published pages use the same rule and the same settings, so they produce the same URLs. - Each image gets `width` and `height` from its recorded size, or from its crop when the use has one. An image with no recorded size gets neither and no `srcset`. - The crop and focal point of that one use are applied. The same image used twice keeps two framings. - The stored file never changes. Transformed delivery is off until you declare the media hosts whose Cloudflare zone has Image Transformations enabled. Set `imageTransformOrigins` in `apps/site/src/owned/extensions.ts`, for example `["https://media.example.com"]`. A wrong entry fails the build by name. An image on a declared host is then served as `https://media.example.com/cdn-cgi/image/width=640,quality=85,format=auto,fit=scale-down,trim=;;;/`, with a `srcset` of 320, 640, 960, 1280 and 1920 pixels capped at the framed width, and `sizes="100vw"`. `trim` cuts the crop in pixels before the resize. Every other image keeps its original URL; a crop is then shown by CSS (`aspect-ratio`, `object-fit: cover`, `object-position`), and a focal point by `object-position`. Deployed acceptance is a manual gate. Run it once per media host: 1. In the Cloudflare dashboard, open the zone that serves the media host, then **Images → Transformations**, and enable transformations for that zone. Expected: the zone shows as enabled. 2. Add the host to `imageTransformOrigins`, build and deploy the site. 3. Open a published page with a framed image. In the browser's network panel the image request goes to `/cdn-cgi/image/...` on the media host and answers `200` with an image content type. Expected: the picture shows the crop, and `width`/`height` on the `img` match the framed size. 4. Narrow the window to 390 pixels and reload. Expected: a smaller `srcset` candidate is fetched. 5. Request the original URL. Expected: the original bytes, unchanged. A `404` or `9xxx` error on `/cdn-cgi/image/` means transformations are not enabled for that zone: remove the host from `imageTransformOrigins` until they are. Authorities: `backend/src/api/models.ts`, `backend/src/lib/public-types/generate.ts`, `backend/src/lib/public-types/contract.ts`, `frontend/static/dee-wan-client.mjs`, `backend/src/api/content_lifecycle.ts`, `backend/src/lib/site-settings.ts`, `backend/src/lib/graduate/site-media-presentation.ts`, `backend/src/lib/graduate/site-emit.ts`, `backend/src/lib/field-link-runtime.ts` and `backend/src/lib/page-contract/field-links.ts`. ## Next step Read the [public read API](/reference/public-api/) for the full read contract, filters and refusals. # Public read API (v1) Source: https://dee-wan-docs.pages.dev/reference/public-api/ The public read API serves published content to your site. Every endpoint below resolves the site from the `Host` header and from nothing else. There is no site parameter or site header for selecting another site. Site tokens remain bound to the resolved site. An explicit shared-content grant can add a source-owned published public projection; it never permits arbitrary source-site queries or source editing. This API only reads. To create, edit, publish or upload from a script or an AI agent, use the [CMS management API](/reference/management-api/) with a management credential. ## Hostnames, CORS and rate limits The public API runs as its own Worker. It serves a site on the hostname stored as that site's domain: the `Host` header, lowercased and without a port, must equal it exactly. There is no `www` aliasing and no fallback to another site. ### Browser origins Under **Settings → API → Browser origins**, an infrastructure administrator can allow website origins to read this live API directly. Enter one complete HTTP(S) origin per line, without a trailing slash or path: `https://www.example.com` or `http://localhost:5173`. The default empty list allows no cross-origin browser reads. `*` allows public reads from every origin. It never enables browser cookies; no response grants credentialed CORS. Keep private site API tokens on your server. Allowed origins receive CORS headers on successful reads and errors. An `OPTIONS` preflight accepts only `GET`, `HEAD` and `OPTIONS`, with `Authorization`, `Content-Type`, `X-Site-Key`, `X-Site-Token` and `X-Dee-Wan-Preview-Challenge` headers. Unknown origins receive no allow-origin header. Every request still resolves the site by hostname and checks its normal publication and token rules. This setting affects only the live `/v1` reader; it does not change the admin, management or generated application API. Server-side and build-time calls do not need CORS configuration. The Worker applies no rate limit of its own. If you need one, configure it on the Cloudflare zone that serves the public hostname. ## GET /v1/_sync **Synchronize published content.** Read a bounded initial snapshot, then ordered entry and public-media changes with signed checkpoints. Existing scoped site tokens and CORS apply. | Parameter | Required | Meaning | | --- | --- | --- | | `cursor` | no | Unmodified next_cursor or checkpoint from the previous page. | | `limit` | no | Page size 1–100; default 50. | | `model` | no | Restrict entries to one authorized model slug. | | `locale` | no | Restrict entries to one locale. | | Status | Meaning | | --- | --- | | 200 | Snapshot or ordered changes, plus continuation or checkpoint. | | 400 | sync_query_invalid: invalid or unauthorized model/query. | | 404 | Unknown host or invalid site token. | | 409 | reset_required: start a new snapshot. | | 503 | sync_dependency_unavailable: retry the same checkpoint. | ```bash GET /v1/_sync?limit=50 ``` ```json { "data": [], "deleted": [], "changes": [], "phase": "entries", "next_cursor": "opaque", "checkpoint": null, "reset_reason": null } ``` ## GET /health **Check whether this host serves a site.** Answers only for a host this deployment knows. An unknown host is a 404 here, as it is on every other route, so this route cannot be used to discover which sites exist. | Status | Meaning | | --- | --- | | 200 | This host serves a site. The body carries the site's slug. | | 404 | `unknown_host`: no active site answers on this host. | ```bash curl https://example.com/health ``` ```json { "ok": true, "site": "acme" } ``` ## GET /v1/:model **List published items of one model.** Published items only, ordered by publication time and then by id unless `sort` names a field. Locale membership, filters and pagination are handled in the database. So a page is never shorter than `limit` because something was dropped afterwards. A page shorter than `limit` with `has_more: false` is the end of the list. | Parameter | Required | Meaning | | --- | --- | --- | | `locale` | no | Which language to serve, as a code matching `^[A-Za-z0-9_-]{1,64}$`. Defaults to the site's default language. What happens when an item has no content in that language is decided by the site's locale policy, not by this parameter. See Locales below. | | `origin` | no | Latitude,longitude for nearest point sorting; required with sort on a point field. | | `limit` | no | Page size, a whole number from 1 to 100. Default 20. Anything else, including a value over 100, is refused with 400 `public_filter_invalid`. It is never clamped. | | `cursor` | no | Opaque. Take it from `page.next_cursor` and send it back unchanged, with exactly the query it was issued under: the same model path, `locale`, `order` or `sort`, filters and their values, `fields` and `expand`. Only `limit` may change. A cursor sent with any other query is refused with 400 `public_cursor_mismatch`. One that does not decode, or one issued before cursors carried their query, is refused with 400 `public_filter_invalid`. Start again from the first page. Its contents are not a contract and will change. | | `order` | no | `asc`, `desc`, `manual` — Direction by publication time. `desc` (newest first) is the default. `manual` requests the collection-wide editorial order when enabled; it is shared across languages. Do not use `order` with `sort`. A manual-order cursor is refused after that order changes; restart from the first page. | | `filter` | no | `filter[]=` keeps items whose field equals the value in the served locale. Repeat it for alternatives: `filter[kind]=guide&filter[kind]=news` keeps items whose kind is either, at most 10 values per field. Text, slug and select fields compare text. A boolean field takes `true` or `false`. A number field takes a decimal number. A date field takes `YYYY-MM-DD` and compares the calendar day. On number and date fields, `filter[][gt]` and `[lt]` are exclusive bounds, `[gte]` and `[lte]` inclusive ones. A field takes one lower bound (`gt` or `gte`) and one upper bound (`lt` or `lte`), never both kinds of one side and never with equality. A range no value can satisfy, such as `[gt]=5&[lt]=5`, is `public_filter_invalid`. Different fields are ANDed, at most 4 fields per request. Each value is at most 255 characters. More fields is `public_filter_too_many`, more values `public_filter_too_many_values`, a field whose type cannot be filtered this way `public_filter_type_unsupported`, an unknown field or a malformed value `public_filter_invalid`. A request is never narrowed. A text value longer than 255 characters is never matched or sorted on. | | `sort` | no | One number, date, text or select field: `sort=` ascending, `sort=-` descending, then by id. Items without a value of that type come last in both directions. Do not use it with `order`. | | `fields` | no | Comma-separated top-level field keys to return, at most 32, no repeats. `id`, `slug` and `locale` are always returned. | | `relation` | no | Filter by a relation field, as `relation=:`. Keeps items whose relation field serves that target in `relations`. A target this request cannot read never matches. `` must be a relation field of this model that accepts one model; a field that accepts several is refused with `public_filter_type_unsupported`. You can repeat this parameter. Repeated parameters are ANDed. | | `tag` | no | Filter by a relation field, matched by the target's slug: `tag=:`. Only a target the item serves in `relations` matches, by the published address served there: in the language the item is served in, then along the site's fallback chain. Under `strict`, a translated target with no published version in that language does not match. An untranslated target matches in any language. There is no separate tag field type. `` must be a relation field of this model that accepts one model; a field that accepts several is refused with `public_filter_type_unsupported`. You can repeat this parameter. Repeated parameters are ANDed. At most 16 `relation` and `tag` pairs per request in total. More is refused with 400 `public_filter_too_many`, whose body carries `max`. A request is never silently narrowed to fit. | | `expand` | no | For ordinary reads and older preview links without the related-draft grant: Comma-separated relation fields of this model, at most 3, no repeats. Each item then carries `expanded.`: `{ data, page }`, where `data` holds the targets as items of the field's target model, in the field's order. Only published targets of this site that this request may read are served, in the requested locale under the site's locale policy, the same as a read of the target model. A private target model is served only on a private model's read whose token names it. At most 25 targets per field are inline; when there are more, `page.next_cursor` reads the rest from `/v1/_related/:model`. One level only: an expanded item carries `relations`, never `expanded`. A dotted path is refused with `public_expand_too_deep`, more fields with `public_expand_too_many`, and a field that is not a relation field of this model, or that accepts several models, with `public_expand_invalid`. New shared preview links automatically expand authorized saved drafts instead; see [draft previews](/guides/preview-and-client/#open-an-actual-rendered-draft-preview). | | Status | Meaning | | --- | --- | | 200 | A page of items in `data`, with `page.limit`, `page.has_more` and `page.next_cursor` beside it. `next_cursor` is `null` on the last page. | | 400 | `public_filter_invalid`: an unknown parameter, an empty value, a repeated parameter that is not `relation`, `tag` or `filter[]`, or a malformed value. This reason also covers a `relation` or `tag` field that is not a relation field of this model, or a cursor that does not decode. `public_filter_too_many`, `public_filter_too_many_values`: past a cap, which `max` names. `public_filter_type_unsupported`: a field filter on a type that cannot take it. `public_cursor_mismatch`: a cursor from another query. `public_expand_invalid`, `public_expand_too_many`, `public_expand_too_deep`: see `expand`. | | 404 | No site on this host (`unknown_host`), or no model with this slug, or a private model and no valid site API token. The last two give the same body with no `reason`, so a caller cannot tell a private model from a missing one. | | 500 | `site_locale_policy_invalid`: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text `Internal Server Error`. | ```bash curl 'https://example.com/v1/article?locale=th&limit=10&order=desc' ``` ```json { "contracts": { "page": { "served": 5 } }, "data": [ { "id": "ci_…", "slug": "hello", "model": "article", "locale": "th", "version": 3, "published_at": "2026-09-01T08:00:00.000Z", "fields": { "title": "สวัสดี" }, "relations": {}, "media": {} } ], "page": { "limit": 10, "has_more": true, "next_cursor": "…" } } ``` ## GET /v1/:model/:slug **Read one published item.** One item, by the slug of its published version. Each language has its own slug: `/v1/post/bonjour?locale=fr` serves the French language of the entry whose English slug is `hello`, and a language answers only at its own slug. A slug is unique within a model and a language on a site, never across sites. Renaming a draft does not move a published address until the rename is published. The item carries `alternates`: `[{ locale, slug }]`, every language the entry is live in with that language's own slug, the served one included. | Parameter | Required | Meaning | | --- | --- | --- | | `locale` | no | Which language to serve, as a code matching `^[A-Za-z0-9_-]{1,64}$`. Defaults to the site's default language. What happens when an item has no content in that language is decided by the site's locale policy, not by this parameter. See Locales below. | | `preview` | no | A preview token (`dw_prev_…`) minted by the CMS when an editor opens a preview. It serves the current DRAFT of ONE root item, matched against the draft's slug, for 30 minutes. A served preview is never cached. It does not make a private model readable. A token that does not verify answers 404, not 403. New shared links also read authorized related saved drafts and recheck their creator's current rights; the root field and locale scope stays within the recorded grant. See [draft previews](/guides/preview-and-client/#open-an-actual-rendered-draft-preview). | | `expand` | no | For ordinary reads and older preview links without the related-draft grant: Comma-separated relation fields of this model, at most 3, no repeats. Each item then carries `expanded.`: `{ data, page }`, where `data` holds the targets as items of the field's target model, in the field's order. Only published targets of this site that this request may read are served, in the requested locale under the site's locale policy, the same as a read of the target model. A private target model is served only on a private model's read whose token names it. At most 25 targets per field are inline; when there are more, `page.next_cursor` reads the rest from `/v1/_related/:model`. One level only: an expanded item carries `relations`, never `expanded`. A dotted path is refused with `public_expand_too_deep`, more fields with `public_expand_too_many`, and a field that is not a relation field of this model, or that accepts several models, with `public_expand_invalid`. New shared preview links automatically expand authorized saved drafts instead; see [draft previews](/guides/preview-and-client/#open-an-actual-rendered-draft-preview). | | Status | Meaning | | --- | --- | | 200 | The item, projected into one locale. | | 400 | `public_filter_invalid`: a parameter other than `locale`, `preview` and `expand`, a repeated or empty parameter, or a malformed `locale`. `public_expand_invalid`, `public_expand_too_many`, `public_expand_too_deep`: see `expand`. | | 404 | `unknown_host`: no site on this host. `locale_unavailable`: the item is published but has no content the site's locale policy will serve. `ambiguous_slug`: two published items claim this slug, so neither is served. No `reason`: no such model, no published item with this slug, or a preview token that did not verify. A private model without a valid token also returns this response. | | 422 | Preview of a page only: the draft page's media cannot be presented. `reason` is one of `unreadable_page`, `page_media_unavailable`, `media_presentation_unreadable`, `media_presentation_revision_unavailable`, `media_presentation_mismatch`, `media_size_missing`, and `error` carries a readable message. | | 500 | `site_locale_policy_invalid`: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text `Internal Server Error`. | ```bash curl 'https://example.com/v1/article/hello?locale=th' ``` ```json { "contracts": { "page": { "served": 5 } }, "data": { "id": "ci_…", "slug": "hello", "model": "article", "locale": "th", "version": 3, "published_at": "2026-09-01T08:00:00.000Z", "fields": { "title": "สวัสดี" }, "relations": {}, "media": {} } } ``` ## GET /v1/_related/:model **Read the rest of an expanded relation field.** The targets of one expanded relation field past the first page. `:model` is the model of the item that carried the field. The cursor names that item, its published version, the field and the locale. The same rules as `expand` decide what is served. The cursor works only on this site, for this model, and for a token with the same model scope as the request that minted it. A private model needs its token here too. | Parameter | Required | Meaning | | --- | --- | --- | | `cursor` | yes | The `expanded..page.next_cursor` of an item, or the `page.next_cursor` of a previous answer here. Send it back unchanged. One from another site, model or token scope is refused with `public_cursor_mismatch`. After the item is published again, it is refused with `public_cursor_stale`: read the item again. | | `limit` | no | Page size, a whole number from 1 to 100. Default 25. | | Status | Meaning | | --- | --- | | 200 | A page of related items in `data`, with `page.limit`, `page.has_more` and `page.next_cursor`. | | 400 | `public_filter_invalid`: no `cursor`, a parameter other than `cursor` and `limit`, a repeated or empty one, a bad `limit`, or a cursor that does not decode. `public_cursor_mismatch`: a cursor from another site, model or token scope. `public_cursor_stale`: the item was published again, or the field is no longer an expandable relation field. | | 404 | `unknown_host`: no site on this host. No `reason`: no such model, a private model without a valid token, or the item is no longer published in that locale. | | 500 | `site_locale_policy_invalid`: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text `Internal Server Error`. | ```bash curl 'https://example.com/v1/_related/article?cursor=…&limit=25' ``` ```json { "contracts": { "page": { "served": 5 } }, "data": [ { "id": "ci_…", "slug": "ana", "model": "author", "locale": "en", … } ], "page": { "limit": 25, "has_more": false, "next_cursor": null } } ``` ## GET /v1/_client **Download the typed TypeScript client.** The dependency-free client for this host's site, with the schema identity it was generated from. Needs a site API token, even when every model is public. The client covers every public model and the private models the token may read. `schema_identity` is a SHA-256 digest of `contract`: locales, locale policy, and each model's visibility and field types. Labels and field order do not change it. `source` starts with a comment recording the same values. The Settings → API command reads this route. The response is never cached. | Status | Meaning | | --- | --- | | 200 | `data` holds `contract_version` (1), `schema_identity`, `site`, `url`, `models`, `contract` and `source`. | | 400 | `public_filter_invalid`: the request carried a query string. | | 404 | No site on this host (`unknown_host`), or no valid site API token for this site. A missing, expired, revoked or other-site token gets the same body with no `reason`. | ```bash curl -H "Authorization: Bearer $DEE_WAN_SITE_TOKEN" https://example.com/v1/_client ``` ```json { "data": { "contract_version": 1, "schema_identity": "sha256:9f2c…", "site": "acme", "url": "https://example.com", "models": ["article"], "contract": { "site": "acme", "default_locale": "en", "locales": ["en", "th"], "locale_policy": "strict", "models": { "article": { "visibility": "public", "fields": { "title": { "type": "text", "required": true, "multiple": false, "options": null } } } } }, "source": "/* @dee-wan/public-client …" } } ``` ## GET /v1/_redirects **Read the accepted redirect list.** The redirect rules in the site's accepted Site Chrome: manual ones and the ones a publication added when it moved a live address. A renderer reads this when a path matches no page. `match: "exact"` names one path; `match: "pattern"` binds `{name}` segments and a final `{name*}` that `to` may reuse. An exact rule wins over a pattern, and the pattern with the most literal segments wins over the others. `status` is 301, 302, 307 or 308 with a `to` (a path on this site or an https address), or 410 (gone) or 451 (unavailable for legal reasons) with `to: null`. An unaccepted Site Chrome draft never appears here. The answer is the same with or without a token, is cached, and is purged when Site Chrome is accepted. | Status | Meaning | | --- | --- | | 200 | `data.redirects`: a list of `{ from, match, status, to }`, possibly empty. | | 400 | `public_filter_invalid`: the request carried a query string. | | 404 | `unknown_host`: no site on this host. | | 500 | `site_chrome_unreadable`: the accepted Site Chrome version no longer matches its recorded digest. An administrator has to accept a version again. | ```bash curl https://example.com/v1/_redirects ``` ```json { "data": { "redirects": [ { "from": "/tours-old", "match": "exact", "status": 301, "to": "/tours" }, { "from": "/news/{rest*}", "match": "pattern", "status": 308, "to": "/blog/{rest}" }, { "from": "/old-offer", "match": "exact", "status": 410, "to": null } ] } } ``` ## GET /v1/_chrome **Read the menus and regions of one language.** The menus, template regions and footer note of the site's accepted Site Chrome, resolved for one language. A menu item that points at a page, post, Blog list or archive carries that item's live address in this language today (the site's locale rule decides fallback); an item whose page is not live, or whose model is private, is left out, and a heading left with no items under it is left out too. Each language owns its own menu items; a language without its own version follows the site's strict or fallback rule. `placements` says which menu the header and the footer show in this language. `regions` holds each region the site's template declares that shows something in this language, with its items in order: a shared section's published sections, a menu, the search page, recent Blog posts, categories, tags or posts by month. A shared section carries the entries of each of its Latest entries sections, read as `GET /v1/:model` reads them: published only, in this language or its fallback, at most the section's limit. Posts by month links the 30 newest posts at most, grouped into 12 months at most; a month has no address of its own. A managed renderer reads this on every page, so menu and region changes and moved pages need no new release. The answer is the same with or without a token, is cached, and is purged when Site Chrome is accepted or a referenced model or shared section publishes. | Parameter | Required | Meaning | | --- | --- | --- | | `locale` | no | A language of this site. Absent means the default language. | | Status | Meaning | | --- | --- | | 200 | `data`: `{ locale, menus: { [key]: { lang, items: [{ id, label, href, children: [{ id, label, href }] }] } }, placements: { header, footer }, footer_note: { lang, text } | null, regions: { [key]: { lang, items } } }`. `lang` is the language actually served: the menu’s or region’s own version for `locale`, else, under the site’s fallback rule, the first along its chain. A strict site serves only `locale`; a menu with no version there is absent. A heading has `href: null`. A region item is `{ kind: "shared_section", entry, lang, document, collections? }`, `{ kind: "menu", menu, name, lang, items }`, `{ kind: "search", href }`, `{ kind: "recent_posts" | "categories" | "tags", links: [{ label, href }] }` or `{ kind: "monthly_archive", months: [{ month: "YYYY-MM", links }] }` (Posts by month). `collections` maps each Latest entries section id to `{ ok: true, entries }` or `{ ok: false, code }`; it is absent when the shared section holds none. | | 400 | `public_chrome_invalid`: `locale` is not a language of this site. `public_filter_invalid`: another query parameter, or `locale` twice. | | 404 | `unknown_host`: no site on this host. | | 500 | `site_chrome_unreadable`: the accepted Site Chrome version no longer matches its recorded digest. An administrator has to accept a version again. | ```bash curl https://example.com/v1/_chrome?locale=th ``` ```json { "data": { "locale": "th", "menus": { "main": { "lang": "th", "items": [ { "id": "mi_about", "label": "เกี่ยวกับ", "href": "/th/about", "children": [] }, { "id": "mi_help", "label": "Help", "href": null, "children": [ { "id": "mi_call", "label": "Call", "href": "tel:+6621234567" } ] } ] } }, "placements": { "header": "main", "footer": null }, "footer_note": null, "regions": { "page_end": { "lang": "th", "items": [ { "kind": "search", "href": "/th/search" } ] } } } } ``` ## GET /v1/_embeds **Read the embed origins.** The https origins an administrator added under Settings → Integrations, besides the built-in YouTube and Vimeo players. A renderer frames a rich-text embed from one of these origins only while it is listed here, and otherwise draws a plain link to the same address. Each `id` is the origin's host, and an embed block names it as `data-provider`. Dee Wan's renderers read this only for a document that holds such an embed. The answer is the same with or without a token, is cached, and is purged when the list is saved. | Status | Meaning | | --- | --- | | 200 | `data.providers`: a list of `{ id, origin }`, possibly empty. | | 400 | `public_filter_invalid`: the request carried a query string. | | 404 | `unknown_host`: no site on this host. | ```bash curl https://example.com/v1/_embeds ``` ```json { "data": { "providers": [ { "id": "maps.example.com", "origin": "https://maps.example.com" } ] } } ``` ## GET /v1/_rich-blocks **Read the plugin rich block definitions.** The rich-text block types the site's active plugins declare: fields, labels and a render tree built from a fixed element list, never markup. A stored block names its `type` and `version` in `data-type` and `data-version` on `
`. A renderer draws a block from its definition's `render` tree, escaping every value, and draws nothing for a block whose type is not listed, whose definition has no `render`, or whose version is newer than the listed one. Dee Wan's renderers read this only for a document that holds a plugin block. The answer is the same with or without a token, is cached, and is purged when a plugin is installed, upgraded, disabled, enabled or removed. | Status | Meaning | | --- | --- | | 200 | `data.blocks`: a list of definitions, possibly empty. | | 400 | `public_filter_invalid`: the request carried a query string. | | 404 | `unknown_host`: no site on this host. | ```bash curl https://example.com/v1/_rich-blocks ``` ```json { "data": { "blocks": [ { "type": "com.example.notes/callout", "plugin": "com.example.notes", "name": "callout", "version": 1, "label": "Callout", "fields": [ { "key": "title", "control": "text", "label": "Title" } ], "render": { "nodes": [ { "element": "strong", "children": [ { "field": "title" } ] } ] } } ] } } ``` ## GET /v1/_sitemap **Read the robots policy and the sitemap chunks.** What `/robots.txt` and `/sitemap.xml` say for this site. `robots` is the accepted Site Chrome policy. `sitemap.chunks` names every chunk of live addresses, per model, 5,000 addresses at most each; a renderer lists each at `/sitemap--.xml`. Empty when the sitemap is off. Only public models and live versions count; a page marked noindex is never listed. Cached, and purged when Site Chrome is accepted or a listed model publishes. | Status | Meaning | | --- | --- | | 200 | `data.robots`: `{ allow_indexing, disallow }`; `data.sitemap`: `{ enabled, change_frequency, chunks: [{ model, number, lastmod }] }`. | | 400 | `public_filter_invalid`: the request carried a query string. | | 404 | `unknown_host`: no site on this host. | ```bash curl https://example.com/v1/_sitemap ``` ```json { "data": { "robots": { "allow_indexing": true, "disallow": [] }, "sitemap": { "enabled": true, "change_frequency": "weekly", "chunks": [ { "model": "page", "number": 1, "lastmod": "2026-10-01T09:00:00.000Z" } ] } } } ``` ## GET /v1/_sitemap/:model/:number **Read one sitemap chunk.** The live addresses of one chunk `/v1/_sitemap` named: each language member at its own address, with its last publication and up to 20 images (the social image, then the images its live version shows). `model` and `number` are a chunk `/v1/_sitemap` listed. A Blog's list addresses lead the `post` chunks. | Status | Meaning | | --- | --- | | 200 | `data.entries`: a list of `{ path, lastmod, images }`. | | 400 | `public_filter_invalid`: the request carried a query string. | | 404 | `sitemap_chunk_missing`: no such chunk, or the sitemap is off. | ```bash curl https://example.com/v1/_sitemap/page/1 ``` ```json { "data": { "model": "page", "number": 1, "entries": [ { "path": "/tours", "lastmod": "2026-10-01T09:00:00.000Z", "images": ["https://media.example.com/boat.jpg"] } ] } } ``` ## GET /v1/_search **Search the live site.** Site search over what is live now: published pages, Blog posts on a Blog site, and the other public models the site search setting names. A draft, an unpublished language, an item in Trash, a shared section and a private model never appear, and a publication, withdrawal or address move shows up at once. Each result is served in the requested language or, under the fallback policy, the first available language in its fallback chain; `slug` is that language's live address. Every word of `q` must match, by the model's matching from the search setting: `word` (the default) whole words, `english_stem` English word forms (`tours` finds `tour`) in a result served in English (`en`, `en-*` or `en_*`) and whole words in a result served in any other language, `substring` anywhere inside a word. Words are letters, marks and digits; anything else only separates them. Results with every word in the title come first, then the newest. A model the site tunes under Settings → Source & delivery is searched by its chosen fields only and ranks by the highest weight, 1 to 10, among those fields that hold every word; an untuned title match counts as 10. A relation field matches by the labels of the targets a public read serves the item in `relations`, read when searching. A target's draft label never matches, nor does a target that is unpublished, in Trash or in a private model, and `summary` never holds a relation label. Without `q` the answer is empty and says only that search runs here. The same answer for every caller, cached at most five minutes and purged on every publication. | Parameter | Required | Meaning | | --- | --- | --- | | `q` | no | What to find: 1 to 200 characters and at most 6 words of at most 64 characters each, compared without case. | | `locale` | no | Which language to serve, as a code matching `^[A-Za-z0-9_-]{1,64}$`. Defaults to the site's default language. What happens when an item has no content in that language is decided by the site's locale policy, not by this parameter. See Locales below. | | `limit` | no | Results per page, 1 to 20. Defaults to 10. | | `cursor` | no | The `page.next_cursor` of the previous answer to the same `q` and `locale`, under the same search tuning, matching and language fallback. | | Status | Meaning | | --- | --- | | 200 | `query`, `data`: a list of `{ kind, id, model, slug, locale, title, summary, published_at }` (`kind` is `page`, `post` or `entry`), `page`: `{ limit, has_more, next_cursor }`, `locale`: `{ requested, default }`. | | 400 | `public_search_invalid`: an unknown or repeated key, an empty value, a query out of bounds, a bad `limit`, or a cursor from another query or from before the search tuning, matching or language fallback changed. | | 404 | `search_off`: site search is off. `locale_unknown`: the site has no such language. `unknown_host`: no site on this host. | | 500 | `site_locale_policy_invalid`: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text `Internal Server Error`. | ```bash curl "https://example.com/v1/_search?q=lighthouse&locale=en" ``` ```json { "query": "lighthouse", "data": [ { "kind": "page", "id": "ci_1", "model": "page", "slug": "tours", "locale": "en", "title": "Lighthouse tours", "summary": "Walk the cliffs at dawn.", "published_at": "2026-10-01T09:00:00.000Z" } ], "page": { "limit": 10, "has_more": false, "next_cursor": null }, "locale": { "requested": "en", "default": "en" } } ``` ## GET /v1/_search/suggest **Suggest while typing.** Up to `limit` live items for a query still being typed, from the same rows and rules as `/v1/_search`: published pages, Blog posts on a Blog site and the chosen public models, in the requested language or the first available language in its fallback chain; never a draft, an unpublished language, Trash, a shared section or a private model. Every word of `q` matches by the model's matching, as in `/v1/_search`, except the last word, which may be partial: it matches the start of a word (anywhere inside one under `substring`); a tuned model matches by its chosen fields only. Items whose title starts with `q` come first, then items whose title contains every word, then text-only matches; then the newest. `slug` is the served language's live address, as in `/v1/_search`. Nothing about the query is recorded. The same answer for every caller, cached at most five minutes and purged on every publication. Managed and generated sites ask it through their own `/search?suggest=&locale=`, which answers `{ data: [{ title, summary, href, kind }] }` with each address as that site links it. | Parameter | Required | Meaning | | --- | --- | --- | | `q` | yes | What is being typed: 2 to 100 characters and at most 6 words of at most 64 characters each, compared without case. | | `locale` | yes | Which language to serve, as a code matching `^[A-Za-z0-9_-]{1,64}$`. Fallback follows the site's locale policy, as in `/v1/_search`. | | `limit` | no | Suggestions to return, 1 to 10. Defaults to 5. | | Status | Meaning | | --- | --- | | 200 | `query`, `data`: a list of `{ kind, id, model, slug, locale, title, summary, published_at }` (`kind` is `page`, `post` or `entry`), `limit`, `locale`: `{ requested, default }`. | | 400 | `public_search_invalid`: a missing `q` or `locale`, an unknown or repeated key, an empty value, a query out of bounds or a bad `limit`. | | 404 | `search_off`: site search is off. `locale_unknown`: the site has no such language. `unknown_host`: no site on this host. | | 500 | `site_locale_policy_invalid`: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text `Internal Server Error`. | ```bash curl "https://example.com/v1/_search/suggest?q=light&locale=en" ``` ```json { "query": "light", "data": [ { "kind": "page", "id": "ci_1", "model": "page", "slug": "tours", "locale": "en", "title": "Lighthouse tours", "summary": "Walk the cliffs at dawn.", "published_at": "2026-10-01T09:00:00.000Z" } ], "limit": 5, "locale": { "requested": "en", "default": "en" } } ``` ## GET /v1/_feed **Read the Blog feed.** The newest 20 published Blog posts in one language, exactly as the Blog list serves them, for a renderer to write as RSS at `/rss.xml` (the default language) and `//rss.xml`. `summary` is the post's search description, or the start of its text. A post leaves the feed when it is unpublished or moved to Trash. Cached at most five minutes and purged on every publication. | Parameter | Required | Meaning | | --- | --- | --- | | `locale` | no | Which language to serve, as a code matching `^[A-Za-z0-9_-]{1,64}$`. Defaults to the site's default language. What happens when an item has no content in that language is decided by the site's locale policy, not by this parameter. See Locales below. | | Status | Meaning | | --- | --- | | 200 | `data`: `{ title, locale, default_locale, items }`, each item `{ id, slug, locale, title, summary, authors, date, published_at }`. | | 400 | `public_feed_invalid`: a key other than `locale`, repeated or empty. | | 404 | `feed_unavailable`: the site is not a Blog, or its posts are not public. `locale_unknown`: the site has no such language. | | 500 | `site_locale_policy_invalid`: the site has no usable locale policy stored. An administrator has to set one under Settings → Languages. An unexpected failure returns a plain-text `Internal Server Error`. | ```bash curl "https://example.com/v1/_feed?locale=en" ``` ```json { "data": { "title": "Harbour notes", "locale": "en", "default_locale": "en", "items": [ { "id": "ci_9", "slug": "first-light", "locale": "en", "title": "First light", "summary": "Fog lifted at six.", "authors": ["Ana Lima"], "date": "2026-10-01", "published_at": "2026-10-01T09:00:00.000Z" } ] } } ``` ## GET /v1/_comments **Read an entry's comments.** Approved visitor comments on one live entry of a model that takes comments (a Blog post, or any model whose comments setting is on), in the language of the item you name: top-level comments in the order you ask, each with at most 20 approved replies, oldest first. A comment carries only its id, display name, website, text, time and reaction counts. Email, moderation state, who reacted and anything used against abuse are never served. `count` counts approved comments and replies and stops at 999 (`count_capped`). Comments hide while the entry is unpublished or in Trash. While its model takes no comments the answer is a closed page (`open: false`, no threads). New comments are posted to `POST /v1/interactions/comment.submit` and reactions to `POST /v1/interactions/comment.react` on the site host, served by the intake Worker. Cached at most five minutes and purged on every moderation change, publication and change of the model's comments setting. | Parameter | Required | Meaning | | --- | --- | --- | | `entry` | yes | The `id` of a published entry item. | | `order` | no | `oldest` (default), `newest`, or `best`: most `helpful` reactions first, equal counts oldest first, then by id. | | `cursor` | no | The previous page's `next_cursor`. A cursor works only for the entry and order it came from. | | `limit` | no | Top-level comments per page, 1 to 50. Defaults to 20. | | Status | Meaning | | --- | --- | | 200 | `data`: `{ entry, locale, order, open, count, count_capped, threads, next_cursor }`, each thread `{ id, name, website, body, created_at, reactions, replies, more_replies }` and each reply `{ id, name, website, body, created_at, reactions }`. `reactions` is `{ helpful }`. | | 400 | `public_comments_invalid`: a missing or malformed `entry`, an unknown `order`, another key, a repeated or empty value, a bad `limit` or a cursor from another entry or order. | | 404 | `comments_unavailable`: the entry is not live in its language, or its model is private. Never cached. | ```bash curl "https://example.com/v1/_comments?entry=ci_9&order=best&limit=20" ``` ```json { "data": { "entry": "ci_9", "locale": "en", "order": "best", "open": true, "count": 2, "count_capped": false, "threads": [ { "id": "cm_1", "name": "Ana", "website": null, "body": "Lovely light.", "reactions": { "helpful": 3 }, "created_at": "2026-10-02T08:00:00.000Z", "more_replies": false, "replies": [ { "id": "cm_2", "name": "Bo", "website": "https://bo.example/", "body": "Agreed.", "reactions": { "helpful": 0 }, "created_at": "2026-10-02T09:00:00.000Z" } ] } ], "next_cursor": null } } ``` ## Items Both `/v1` routes return items of this shape: | Key | Type | Meaning | | --- | --- | --- | | `id` | string | The item's id. Stable across edits and renames. | | `slug` | string or null | The served version's slug; the draft's during preview. `null` when the item has none. | | `model` | string | The model's slug. | | `locale` | string | The locale the fields were served in. Under the fallback policy it can differ from the one you asked for. | | `version` | number | The served saved version number; the draft’s during preview. | | `published_at` | string or null | ISO 8601 publication time. | | `fields` | object | Field values for the served locale, keyed by field key. A field the model does not translate has one value, served in every locale and used by `sort` and `filter` in every locale. | | `relations` | object | For ordinary reads and older preview links without the related-draft grant: For each relation field, an ordered list of `{ instance_id, model, slug, display }` with only the targets this request can read: published, not in Trash, and in a public model, or in a private model only on a private model's read whose token names both. `slug` and `display` are the target's published address and label, read now. `display` is null when the label field is private. `model` is the slug of the target's model; a field that accepts several models serves targets of each in the list's order. Any other target is left out, as if it were not linked. A translated target is served in the item's language, then along the site's fallback chain. Under `strict`, a translated target with no published version in that language is left out. An untranslated target is served in any language. New shared preview links use each authorized target’s latest saved draft, including never-published entries; addresses and labels come from those drafts. The creator’s current item, field and locale rights apply. Private fields and unreadable or trashed targets are omitted. See [draft previews](/guides/preview-and-client/#open-an-actual-rendered-draft-preview). | | `expanded` | object | For ordinary reads and older preview links without the related-draft grant: Only with `expand`. For each named relation field, `{ data, page }`: up to 25 targets as items, and `page.next_cursor` for the rest at `/v1/_related/:model`. New shared preview links populate this automatically for ordinary and component relationships, up to four levels and 128 targets across one root response. Cycles stop at stable identities. Draft expansion does not use public relation cursors. | | `media` | object | For each media field, an ordered list of `{ media_id, name, mime_type, kind, size, url, thumbnail_url, alt, width, height }`. `kind` is `image`, `document`, `audio`, `video` or `file`; show an image with ``, audio and video with their elements and a download link, and a document as a link with its name, type and size. `width` and `height` are null for anything that is not an image. An entry also carries `crop` (`{ x, y, width, height }`) and `focal` (`{ x, y }`) when the editor set them, as fractions 0–1 of the stored image. They describe how to show an image and are never set on other files. The bytes and `url` are unchanged, and no resized or cropped copy is served. A file copied from a media source also carries a read-only `credit`: `{ author, source, license, url }` (`author` and `url` may be null), as it was when the entry was saved. Show it under the image. A private file of a private model (it may be used only there) carries `visibility: "private"` with an empty `url` and a null `thumbnail_url`: its address is withheld, and the bytes come from your application's own sign-in, never from this API. | Rich-text fields hold one rich document, stored as its canonical HTML: paragraphs, headings, lists, quotes, code blocks, rules, tables, image galleries, audio, video and file blocks, and embeds. Every write is checked, and markup outside that set is refused, so the API never serves scripts, styles, event attributes or frames. Blocks are written as data, not as players: a media block is `
` holding one link with `data-media-id`, `type` and `data-size`, and an embed is `
` with no `