Workflow: from a brief to a running site
The order of work for a coding agent turning a written brief into a working, accessible, deployable Meridian site, step by step, with the exact command for each step and how to verify it before moving on.
Follow these steps in order. Each ends with a check; do not start the next step until it passes. The examples build a small walking-and-places site; replace the names with the brief’s.
flowchart TD brief["1. Brief and plan"] --> bundle["2. Choose the bundle"] bundle --> init["3. init the project"] init --> basics["4. Locales and site"] basics --> db["5. migrate, schema apply,<br/>seed apply"] db --> model["6. Types, fields, routes"] model --> seed["7. Seed content"] seed --> tpl["8. Templates"] tpl --> fe["9. Frontend"] fe --> checkall["10. check, check --strict,<br/>seed verify"] checkall --> renderall["11. a11y audit and render"] renderall --> a11y["12. Accessibility pass"] a11y --> deploy["13. Production check"] deploy --> report["Hand back"]
You need the engine binary on the PATH (engine-dmo version answers), a PostgreSQL database you may write to, and its URL in DATABASE_URL. There is no standalone download of the binary: a release publishes Linux container images of engine-dmo, and Install says how to run commands through one. The example projects are in the Meridian source repository only, so init --from works only on a project directory you have.
Without a database
You can still do steps 1 to 4, 6, 9 and 10 with engine-dmo check --no-db; say in your report that nothing was rendered. render, describe route, a11y audit, schema plan and seed apply need a real database; nothing renders without one.
Set every ${VAR} the configuration names without a default, even to a placeholder, before you run anything else. The starter needs only DATABASE_URL; once you add production secrets (step 13) they need values too, and SIGNING_KEY must be at least 32 characters. The engine does not read a .env file, so export them in the shell:
export DATABASE_URL='postgres://placeholder@localhost/placeholder'
export SIGNING_KEY="$(openssl rand -hex 32)" check --no-db and the dev verbs (except --scaffold) run with them unset; check, seed verify, new, config render and a11y statement do not. What each command needs has the full list.
Read the brief and write a plan
Before touching a file, turn the brief into a short plan, and keep it in your notes:
| Decide | Examples |
|---|---|
| Sites and domains | One site, or a county site and a town site sharing content |
| Locales | en-GB only, or en-GB and cy-GB; each locale’s time zone and currency |
| Content types and their fields | Walk: title, slug, summary, distance, difficulty, photo, route description |
| Routes | /walks/{slug} with an index at /walks, filtered by difficulty, 12 per page |
| Pages and navigation | Home, About, Contact; header and footer menus |
| Forms | A contact form, a newsletter sign-up, an enquiry to a business |
| Search | A search page, with or without a search service |
| DMO features | A directory of businesses, areas, landing pages, a member portal |
| Look and feel | Colours, type, logo; plain CSS or an existing frontend build |
Map every requirement to an engine feature using the handbook (https://docs.cherwell.studio/llms.txt lists its pages). Anything with no feature is a gap: write it down and tell the person, rather than inventing a workaround.
Verify: every item in the plan names the handbook page that covers it.
Choose the bundle
Use engine-dmo when the brief needs any of: a directory of businesses or places (listing, area), organisations and their people, membership tiers, a partner or member portal, ownership claims, paid promotions, landing pages for place and category, affiliate links, member reports. Otherwise use engine-cms. A project written for engine-cms runs on engine-dmo unchanged, so engine-dmo is the safe choice when unsure. Its DMO modules add nothing to the public site until you configure them, with one exception: the member portal is on by default at /members (describe project shows portal /members, both). On a site with no partner businesses, turn it off:
# config/portal/portal.yaml
portal:
mode: "off" describe project then shows portal /members, off.
engine-dmo version Verify: the bundle line lists the modules the plan needs.
Create the project
engine-dmo init my-site
cd my-site init writes the starter: one locale (en), one site (main) answering on localhost, the core page type routed at /{slug}, a home page in the seed and four templates (base.html, page/entry.html, errors/404.html and errors/500.html under templates/_shared/), plus AGENTS.md and two agent skills. If AGENTS.md or .claude/skills/ is missing (a project made with --agents=false, or by an engine that did not write them), write them with the three commands in The project skill and AGENTS.md. To start from an existing project instead (an agency starter, an example the person gave you), use engine-dmo init my-site --from <dir>; it copies the project and gives every element new ids.
Every later command reads the project in the current directory, or the one --project <dir> names.
Verify: engine-dmo check --no-db exits 0. It also prints warning: this head has no head() and its own tags miss a meta description for the starter’s base.html, above a summary that says 0 warnings. That is expected: the starter layout is a placeholder, and step 8 replaces it with one that prints head().
Set the basics
Edit config/locales.yaml so the source locale has the right code, time zone and currency (the starter has en, Etc/UTC and EUR):
locales:
available:
- code: en
id: loc_3990wq8rss # keep the id init wrote
label: English
source: true
default: true
timezone: Europe/London
currency: GBP Choose the locale code. init writes en, which names a language and no country. For a UK site use en-GB, because the code decides what a visitor sees:
en | en-GB | |
|---|---|---|
<html lang> and og:locale | en and en | en-GB and en_GB |
First day of openingHours.week(now) | Sunday | Monday |
A UK phone’s display | +44 1600 712345 | 01600 712345 |
| A date in mail | December 2, 2026 | 2 December 2026 |
To change it now, before step 5: edit code: in config/locales.yaml (keep the id), locales.enabled, locales.default and the key of site.name in site.yaml, and the locale key and publish: in seed/documents/page/home.yaml. On a project with labels, rename config/strings/en.yaml to en-GB.yaml and change the key in every label too. schema plan then shows rename locale.
The recipes’ examples use en, the code init writes. Replace it with your code wherever you paste one. The wrong code is not always refused: a label fails only check --strict (the label of field priceRange has no translation for en), and a landing’s slugs only warn, then the raw option value is used in the address.
Set the site’s name, domains and country in config/sites/main/site.yaml:
site:
id: site_0s14b7ndg9 # keep the id init wrote
name: { en: "Visit Banbury" }
domains: [localhost, "localhost:8080"]
default: true
country: GB country is what the admin starts from: the region a phone number typed without + is read in, and the country an address form offers first. It also sets the financial year in reports. Seed files and content create do not read it, so a seeded phone needs its own region ({ raw: "01600 712345", region: GB }) or region: GB on the field.
dev config set changes one value and checks the result:
engine-dmo dev config set sites/main/site.yaml site.name.en "Visit Banbury" Then the search engine settings, which all default to off. Nothing fails without them, so set them now:
# config/sites/main/site.yaml
seo:
title_template: "{{ title }} | {{ site.name }}"
sitemap: { enabled: true }
robots: { enabled: true } Without them, /sitemap.xml and /robots.txt answer 404 and page titles carry no site name.
Add more locales with engine-dmo dev locale add (Multilingual site) and more sites with engine-dmo dev site add <key> --name "..." --domain town.localhost.
Verify: engine-dmo check --no-db exits 0.
Put it in the database
export DATABASE_URL='postgres://user:password@localhost:5432/my_site?sslmode=disable'
engine-dmo migrate up
engine-dmo schema plan
engine-dmo schema apply
engine-dmo seed apply migrate up creates the engine’s tables, schema apply writes your configuration into the database, and seed apply loads seed/.
Verify: engine-dmo schema plan prints “No changes” and exits 0, and engine-dmo render / exits 0 and prints the home page.
Model the content
The core page type has only title and slug. Before seeding About or Contact pages, give it a summary (which head() uses as the meta description) and a body:
engine-dmo dev field add page summary --type text --param multiline=true --param max=280 --label Summary --apply
engine-dmo dev field add page body --type portable_text --label Body --apply Fields added to a core type always follow its core fields; --after can name only a field in your own config/schema/page.yaml. Core types lists the core fields of every core type.
For each type in the plan, add the type, its fields and its route with the dev verbs. --apply applies the schema plan when it is not destructive, so you need no long-running process:
engine-dmo dev type add walk --label Walk --plural Walks --apply
engine-dmo dev field add walk distanceKm --type number --label "Distance (km)" --param min=0 --apply
engine-dmo dev field add walk difficulty --type select --options easy,moderate,hard --label Difficulty --required --index --apply
engine-dmo dev field add walk photo --type asset --label Photo --apply
engine-dmo dev field add walk body --type portable_text --label "Route description" --apply
engine-dmo dev route add walk "/walks/{slug}" --index /walks --scaffold --apply
engine-dmo new template walk/index.html --scaffold loads the whole configuration to write walk/entry.html, so every ${VAR} must be set first. If one is unset, the verb has already written the route to routes.yaml when it exits 3, and running it again says nothing to change: write the template with engine-dmo new template walk/entry.html instead. --scaffold never writes index.html, which is why new template walk/index.html follows; that scaffold is an empty block with no h1, so it fails the accessibility checks until you write it (Listing page).
Mark --index every field a listing page will filter or sort on. Options given with --options have no labels, so templates print the raw value: label them as Add a field shows. Look up a field type’s parameters with engine-dmo describe fieldtypes --json before you use it. The recipes Add a content type, Add a field and Add a route cover every option.
If you can keep a background process running, engine-dmo dev up does the apply for you on every change and reloads the server; read what it found with engine-dmo dev status --json.
Verify: engine-dmo schema plan exits 0 with no changes, and engine-dmo describe type walk lists every field with an id. engine-dmo check exits 0 only once every routed type has its templates. A route written with --scaffold still lacks index.html, and a route written by hand lacks both, so check exits 3 with no template found for type "listing" on site main and type "listing" has an index route and no listing/index.html on site main until you write them (engine-dmo new template listing/entry.html, engine-dmo new template listing/index.html, or step 8). schema plan and schema apply do not need the templates, so carry on; check is the gate for step 8.
Seed realistic content
Write seed files so every template has something real to render: at least two documents of every routed type, enough to fill a listing page past its first page when it paginates, and every page the navigation links to.
# seed/documents/walk/cherwell-towpath.yaml
locales:
en:
title: Cherwell towpath
slug: cherwell-towpath
summary: A flat riverside walk along the canal towpath from the town centre.
distanceKm: 6.5
difficulty: easy
photo: { asset: towpath }
publish: [en] Photographs go under seed/assets/ with a YAML file of the same name holding the alt text and credit. Use the person’s own photographs; never present a generated or stock image as a photograph of a real place. For volume, engine-dmo seed generate --type walk --count 20 --publish writes valid documents with placeholder images.
engine-dmo seed verify
engine-dmo seed reset --confirm
engine-dmo seed apply seed apply refuses a database that already holds content, which is why seed reset --confirm runs first in development. See Seeding and importing.
seed verify checks that every file loads, that each field it names exists on the type, and that every document a navigation item names is in the seed. It does not check values: a select value that is not an option, or a number below its min, passes verify and is refused by seed apply. seed apply stops at the first refused document after writing the ones before it, so after a failure run seed reset --confirm, fix the file and apply again.
Verify: seed verify says “Matches the schema”, and seed apply exits 0 and reports the documents, assets and navigation you expected.
Write the templates
Start from the layout, templates/_shared/base.html. It must print head(), jsonld(), the language and direction, a skip link and the navigation:
<!doctype html>
<html lang="{{ locale.code }}" dir="{{ locale.direction }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{ head() }}
{{ jsonld() }}
<link rel="stylesheet" href="{{ assets.url('css/site.css') }}">
</head>
<body>
<a class="skip" href="#main">{{ t('a11y.skip') }}</a>
<header>
<a href="{{ path('/') }}">{{ site.name }}</a>
<nav aria-label="{{ t('nav.main') }}">
<ul>{% nav item in nav.main %}<li><a href="{{ item.url|url }}"{% if item.isCurrent %} aria-current="page"{% endif %}>{{ item.label }}</a>{% ifchildren %}<ul>{% children %}</ul>{% endifchildren %}</li>{% endnav %}</ul>
</nav>
{{ localeSwitcher() }}
</header>
<main id="main">{% block body %}{% endblock %}</main>
</body>
</html> head() prints the title, description, canonical, hreflang, robots and Open Graph tags. jsonld() prints the page’s structured data, without which a site ships none and every check still passes. A type adds its own entity to that data with a structured_data block in its schema file (SEO and structured data).
Then, for each route, write the type’s entry.html and, for an index, index.html. Before writing one, print the real context it will receive:
engine-dmo describe route /walks/cherwell-towpath --json Put every visible string in config/strings/<locale>.yaml and print it with t(). Make anything you would copy between templates a component (engine-dmo new component walk-card). Follow Listing page, Navigation menu and Responsive images.
check holds entry, the type’s own key (walk) and the context’s named variables to the schema, so {{ walk.subtitle }} fails. It does not check fields on a loop variable ({% for w in items %}{{ w.photo }}), on a related document, or on a component’s typed prop: a misspelt field there prints nothing. render --json is the only check for those.
Verify: engine-dmo check exits 0 and prints no warning: lines for templates (the summary does not count them), engine-dmo render <path> --json shows "errors": [] for each template you wrote, and engine-dmo render <path> | grep -c 'application/ld+json' prints 1.
Add the frontend
Without internet access, prefer plain CSS and small script files under public/, linked with assets.url(), which adds a version for cache busting:
<link rel="stylesheet" href="{{ assets.url('css/site.css') }}"> If the project already has a package.json and its node_modules/, engine-dmo build runs its build script and then check. Do not add a build that needs packages you cannot install. See Frontend build.
Verify: engine-dmo render / --json lists your stylesheet in assets.
Check everything
engine-dmo check
engine-dmo check --strict
engine-dmo seed verify --strict turns configuration warnings into errors (a missing translation of a label, a missing string). It does not count template warnings, which still pass under --strict: read the warning: lines check prints for templates (a missing head(), an uncredited image, two h1s) and fix them by hand.
Verify: all three exit 0.
Render every page
a11y audit --all renders through the real pipeline every page the site’s sitemap would list, plus the home page, and fails on any page that does not answer 200, as well as on accessibility findings. It builds that list whether or not seo.sitemap.enabled is on, so a clean audit says nothing about /sitemap.xml, which answers 404 until you enable it in step 4. Pages outside the list (the search page, filtered index pages, pages under a second site) need naming:
engine-dmo a11y audit --all
engine-dmo a11y audit --path /search --path '/search?q=towpath' --path '/walks?difficulty=easy'
engine-dmo render /walks --json Name a page’s own address. An address that redirects is reported as a page with no language or title (Troubleshooting). Run the audit once per site with --site <key>. Then render each kind of page with --json and read errors (template failures), warnings (template advisories), a11y (accessibility findings) and images (what each image downloads). An index page needs rendering on its last page too ('/walks?page=2').
Verify: the audit reports “0 failures” on every site, and every rendered page has empty errors and a11y.
Accessibility pass
Beyond the automated checks, go through the accessibility rules template by template: one h1, labelled controls and landmarks, alt text, focus order, the current page marked, and colour contrast in your CSS. Write the accessibility statement scaffold for the person to complete:
engine-dmo a11y statement --out accessibility-statement.html The statement’s enforcement section names the United Kingdom’s regulator and regulations whatever the site’s country: tell the person to replace it for a site outside the UK.
Verify: engine-dmo a11y audit --all reports no failures and no warnings, and you have listed for the person what still needs a manual check (keyboard, screen reader, contrast).
Prepare the deploy
Make the project pass the production rules without a database or secrets:
ENVIRONMENT=production engine-dmo check --no-db It reports the production requirements in groups, so run it again after each fix: server.security.signing_key: ${SIGNING_KEY}, server.trusted_proxies (or real_ip_header: "" when nothing sits in front of the engine), a real mail.driver with mail.from, and two-factor sign-in for staff.
Each secret you add this way (${SIGNING_KEY}, ${SMTP_PASSWORD}) must now be set in every shell that runs check, seed verify, new or dev up, in development too, or they stop with environment variable SIGNING_KEY is not set. Export a local value of 32 characters or more (export SIGNING_KEY="$(openssl rand -hex 32)") and a placeholder for each other secret. A secret cannot take a ${VAR:-default}. The mail rules are checked only by check: a production engine started on the log driver runs and writes every message to its log, so this check is the only guard. Then follow Deploying: the container image or the single-node setup, and the deploy sequence (back up, migrate up, schema apply, serve, health --readiness).
Verify: the production check exits 0, and the Checklists are complete.
When you hand back
Tell the person, in this order: what you built, how to run it locally (engine-dmo dev up and the URL), the staff sign-in (engine-dmo user create --email ... --name ... prints an enrolment link), what you verified and how, what you could not verify, the secrets they must set, and any decisions left to them (a destructive schema change, a missing feature).
Rules
Every rule a coding agent must follow in a Meridian project, why it exists and exactly what breaks when it is ignored, covering configuration, ids, secrets, schema changes, content, templates, accessibility, generated files and production.
Commands for agents
The engine commands a coding agent uses to read, change and verify a Meridian project (describe, guide, docs, help --json, the dev verbs, check, schema plan and apply, render --json, a11y audit, content, seed and mcp), what each returns, what it needs and when to use it.