Seeding and importing
Getting content into a project: seed sets (seed.yaml, documents per type and locale, assets, history and publishing states, references by seed key, verify, apply and reset), generated content, content create and update, and JSON export and import between environments.
There are four ways to put content into a project without typing it into the admin:
| Way | Use it for | Goes through |
|---|---|---|
A seed set (seed/) | Development and demonstration content kept in the repository, applied to an empty database | The same validation, revisions and publish checks as an editor |
engine-dmo seed generate | Many plausible documents of a type, for testing lists, search and performance | Each field’s validation |
engine-dmo content create and content update | A document or a change from a script | The admin’s save checks, and the publish checks with --publish |
engine-dmo content export and content import | Moving drafts between environments or through a spreadsheet-like round trip | The content store, as drafts, never published |
All of them write values in the stored shapes each field type’s page describes. For moving a whole existing site from another CMS, see Migration.
Seed sets
A seed set is a directory, seed/ by default, that describes content by key:
A project may hold several sets, as seed/<name>/, chosen with --set <name>.
seed.yaml
name: starter
description: The home page and three walks.
locales: [en-GB, cy-GB]
analytics:
months: 13
daily_views: 6
seed: 42
profiles:
core: [page/*, walk/cherwell-valley] | Key | What it does |
|---|---|
name, description | Shown by seed list. |
locales | The locales the generated analytics cover. It does not limit the documents: a document may hold any locale the project configures. Add a new locale here when you add it to the project, so its pages get analytics too. |
schema_digest | The schema digest the set was written against. When it differs from the current schema, apply warns and reconciles values as a restore would. Leave it out to accept any. |
analytics | months of generated page-view history, daily_views per document at weight 1, a seed for the generator, searches (visitor queries, each with site, locale, query, weight, results) and search_days (default 120). Leave it out for none. |
profiles | Named subsets: each a list of records as kind/key, with * patterns (walk/*, organisation/harbour-*). Applying a profile applies those records and everything they name. profiles/*.yaml add to the list. |
library | A shared media library to import photographs from by name: dir (relative to the set), and photos, categories and videos to import. |
A document
documents/<type>/<key>.yaml. The folder is the type and the file name the key, unless the file says type: or key:.
# seed/documents/walk/cherwell-valley.yaml
site: main
locales:
en-GB:
title: The Cherwell valley walk
slug: cherwell-valley-walk
summary: Eight miles along the river and the canal, with a pub at the halfway point.
difficulty: moderate
distanceKm: 12.8
startPoint: { lat: 52.0629, lon: -1.3398, precision: street }
heroImage: { asset: valley-view }
nearbyListings:
- { id: hook-norton-brewery-tours, type: listing }
stages:
- _key: s1
title: Banbury to Kings Sutton
directions:
- _type: block
_key: s1p1
style: normal
markDefs: []
children:
- { _type: span, _key: s1p1s0, text: "Leave the town by the towpath.", marks: [] }
cy-GB:
title: Taith dyffryn Cherwell
slug: taith-dyffryn-cherwell
summary: Wyth milltir ar hyd yr afon a'r gamlas.
stages:
- _key: s1
title: Banbury i Kings Sutton
publish: [en-GB, cy-GB]
tags: [walks.riverside]
history:
- locale: en-GB
note: First draft
fields:
title: Cherwell walk
summary: A riverside walk. | Key | What it does |
|---|---|
type, key | The type and the seed key; default the folder and file name. |
site | The owner site; the default site when left out. |
sites | Other sites the document is shared to. |
variant | The variant the document is created in. |
locales | The field values per locale code, by field name, in each field’s stored shape. The source locale is required (no en-GB content (the source locale) otherwise) and is written first. Every other locale starts from the source locale’s values and replaces the ones it gives, so shared values (translate: none, a container’s items) need only be written once; for a repeater or blocks field, a locale repeats the items with the same _keys and its own text. An asset, link or video field written in another locale replaces the shared choice for every locale, so repeat the same asset or leave the field out (Links, assets and videos). |
publish | The locales to publish, through every publish check. Left out, the document stays a draft. |
history | Earlier states, applied before locales as revisions with a note, so the document has a history to compare and restore. |
pending | A later working revision per locale, saved after publishing and not published: changes waiting. |
review | Locales whose working revision is sent to the review queue. |
schedule | Per locale, a time to publish at (the document waits until the scheduler runs). |
unpublish_at | Per locale, a time to take it down. |
trash | Puts the document in the trash once everything else is done. |
tags | Vocabulary tag keys applied to the document. |
weight | Popularity for the generated analytics, default 1. |
overrides | Per-site values of site_overridable fields: site, then field, then a locale map for a translated field or the value itself. |
organisation, landing, partner, partner_edits | engine-dmo: the organisation a listing belongs to, a landing page it is the content of, and partner-made changes (Partners and the portal). |
Dates and times in schedule, unpublish_at and date-valued fields may be absolute (2026-03-01, 2026-03-01T09:30) or relative to the day of applying (+21d, -6m, +3d 09:00), so a demonstration stays current.
A document of a singleton type fills in its site’s singleton rather than creating a second one.
The home page named by home: { type: page, slug: home } keeps the slug home in every locale: give the Welsh block slug: home, not a translated slug, or /cy/ answers 404 (Slugs and the home page).
References by key
A seed never contains a database id (the UUID a record receives when it is created). Records name each other by seed key, in the shapes the fields store. Configuration ids are not database ids and are allowed: a form field is seeded with its endpoint’s id from endpoints.yaml, contactForm: end_srhmvkf6j3.
| Field | In a seed |
|---|---|
relation to a document | { id: <seed key>, type: <type> } |
relation to a tag or smart list | { id: <tag or list key>, type: tag } (a vocabulary key, not a seed record) |
asset, a video’s poster | { asset: <seed key> }, or just <seed key> |
link to a document | { of: document, document: <type>/<seed key> } |
link to an asset | { of: asset, asset: <seed key> } |
| a rich text link | the same, as the link mark’s definition |
Apply creates records in dependency order and replaces each key with the id the record received. Two documents that name each other are both created, then completed.
Assets
Each asset is a file and a YAML file of the same name beside it under assets/, at any depth. Files may be .jpg, .jpeg, .png, .webp, .gif, .pdf, .svg or .gpx. They go through the same upload pipeline as an editor’s upload.
# seed/assets/valley-view.yaml
title: The Cherwell valley from Crouch Hill
alt: { en-GB: Fields and hedgerows falling to the river under a wide sky, cy-GB: Caeau a gwrychoedd yn disgyn i'r afon }
caption: { en-GB: Looking south from Crouch Hill }
credit: Photograph by A. Walker
focal: { x: 0.5, y: 0.4 }
tags: [walks.riverside]
rights:
holder: A. Walker
licence: CC BY 4.0
until: +2y | Key | What it does |
|---|---|
key | The seed key; default the file name. |
file | The file, relative to the YAML’s folder; default the file of the same name. |
title, alt, caption, decorative | The library’s title, alt text and caption per locale, and whether the image is decorative. |
credit, licence, licence_url, author, source, rights | The credit line and rights record (rights takes holder, licence, credit, source, licence_url, from, until, uses, territory). |
focal, hotspot, crop | The asset’s own framing, as fractions. |
sites | Per-site alt text and captions: site, then alt and caption maps. |
tags | Vocabulary tag keys. |
Other records
| Directory | Holds |
|---|---|
users/ | Development staff users: email, name, groups, password, totp_secret. Never created in production. |
navigation/ | Navigation sets: key, site, slot, name, assigned, and items, each with a label map and one of document (<type>/<key>), asset, url or index, an optional suffix, and children. |
organisations/, people/ | engine-dmo: CRM records with contacts, memberships and invitations (below). |
Modules add their own directories in engine-dmo (claims, promotions, affiliates and others); see each module’s page under DMO.
Organisations and people
engine-dmo
organisations/<key>.yaml is a business, and people/<key>.yaml someone in the CRM in their own right. The file name is the key a document names with its top-level organisation: <key>, unless the file says key:. Both go through the CRM store, so a seeded record is validated as a saved one is. An unknown key in these files is ignored, not refused, so check the result with seed apply’s summary (1 organisations, 2 people, 1 memberships).
# seed/organisations/old-bridge-inn.yaml
name: The Old Bridge Inn
legal_name: Old Bridge Inn Ltd
status: active
email: stay@oldbridgeinn.example
website: https://oldbridgeinn.example
phone: { raw: "01600 712345", region: GB }
address:
country: GB
line1: 4 Wye Bridge Street
locality: Monmouth
postal_code: NP25 3DY
tags: [stay.hotel]
contacts:
- name: Sam Owner
email: sam@oldbridgeinn.example
role: General manager
partner_role: owner
is_primary: true
started_on: -2y
- person: pat-morgan
role: Bookkeeper
memberships:
- site: main
tier: standard
joined_on: -2y
renews_on: +30d
amount: "120.00"
currency: GBP
history:
- { tier: gold, amount: "240.00", reason: Upgraded at the spring trade fair }
invitations:
- email: alex@oldbridgeinn.example
name: Alex Colleague
role: Duty manager
partner_role: contact
site: main
by: sam@oldbridgeinn.example # seed/people/pat-morgan.yaml
name: Pat Morgan
email: pat@example.org
marketing_consent: -1y
tags: [press] An organisation:
| Key | What it does |
|---|---|
key | The seed key; default the file name. |
name | Required. |
legal_name, email, website | As on the organisation’s page. |
status | prospect, active (the default), lapsed or archived. |
phone, address | Maps, stored as given. |
custom | The fields of config/crm/organisation.yaml, by handle. |
tags | Vocabulary tag keys. |
network | The network’s key. Default: the network of the first membership’s site, else the default site’s. |
contacts, memberships, invitations | The lists below. |
trash | Puts the record in the trash once everything else is done. |
A contact is a person linked to the organisation. Each needs person or name.
| Key | What it does |
|---|---|
person | A people/ key or the email of a contact seeded earlier, for someone linked to more than one organisation. Without it the person is created from name, email, phone and custom. |
name, email, phone, custom | The new person’s details. |
role | The job title, free text. |
partner_role | The partner role by key, such as owner or contact (the default). See partner roles. |
is_primary | The organisation’s primary contact. |
started_on, ended_on | Dates. ended_on makes a former contact. |
A membership is one organisation’s tier on one site. It needs the memberships module and tiers in config/memberships.yaml.
| Key | What it does |
|---|---|
site, tier | The site’s key (default the default site) and the tier’s key. |
status | pending, active (the default), lapsed or cancelled. |
joined_on, renews_on, ended_on | Dates. |
amount, currency, external_ref, notes | The fee, as a quoted string, its currency, your own reference and staff notes. |
history | Later changes, applied in order after the membership starts, which is what the churn and growth figures read. Each may set tier, status, renews_on, ended_on, amount and reason. So the membership’s own keys are its first state. |
An invitation is someone asked to join the portal, sent through the portal itself, after the documents.
| Key | What it does |
|---|---|
email, name, role, partner_role | Who is invited, their job title and their role. |
site | The site. Default the default site. |
by | Required: who invites them, by people/ key or a contact’s email. They must hold people.invite, as an owner does, or the apply stops. |
accepted | The invitation is accepted at once, which makes the person a contact too. |
A person:
| Key | What it does |
|---|---|
key | The seed key; default the file name. |
name, or given_name and family_name | The display name, or the two parts it is made from. |
email, phone, custom, tags | Their details, the fields of config/crm/person.yaml by handle, and vocabulary tag keys. |
marketing_consent | When they consented to marketing. Left out, they have not. |
network | The network’s key. Default the default site’s. |
trash | Puts the record in the trash once everything else is done. |
Dates may be absolute or relative to the day of applying, as for documents. seed verify checks that a person and a document’s organisation name a record in the set, that each date reads and that each site exists; it does not check tier or partner_role, which only seed apply does.
Commands
| Command | What it does | Needs a database |
|---|---|---|
engine-dmo seed list | The sets and what each holds. | no |
engine-dmo seed verify [--set] [--profile] | Checks the set loads and matches the current schema: every type, field, locale and site exists, every record a record names is in the set (navigation items included), every navigation slot is declared, every date reads. It does not check field values (below). The check to run in CI after a schema change. | no |
engine-dmo seed apply [--set] [--profile] [--scale small|medium|large] [--timeout 30m] | Applies the set through the same validation and revision machinery as an editor: vocabulary, assets, users, people, organisations, documents, navigation, then generated analytics. | yes |
engine-dmo seed reset --confirm | Removes all content, media and analytics, keeping users and configuration. Never runs in production. | yes |
engine-dmo seed write [--check] | Writes generated seed files from seed/generate.yaml; --check exits with code 3 when the files differ. | no |
seed verify and seed write never connect, but they load the configuration first, and every ${VAR} in engine.yaml without a default must then be set. With no database, set DATABASE_URL to any value. Once engine.yaml reads signing_key: ${SIGNING_KEY}, set SIGNING_KEY too, to a development value of at least 32 characters (server.security.signing_key must be at least 32 characters otherwise), and likewise any other secret such as an SMTP password. The engine does not read a .env file; export the variables in the shell or the CI job:
export DATABASE_URL=postgres://unused SIGNING_KEY=$(openssl rand -hex 32)
engine-dmo seed verify What seed verify does not check
seed verify checks names, not values. A select value that is not an option (difficulty: trivial) or a number below the field’s min passes it with Matches the schema. Values are checked when seed apply saves and publishes the document, through the field’s validation:
- A document with
publish:stops the apply at the first value that fails, naming the field:publish en refused: difficulty: "trivial" is not one of the options. - A document without
publish:is stored as a draft, and a value that would fail publishing is kept without an error. It shows up only when an editor orcontent update --publishpublishes it.
seed verify reports a problem by file, with no line or column, and reports an unknown field once for each locale block it appears in. Its summary counts documents, assets and users, not navigation sets, though it checks them; seed apply reports the navigation it wrote (navigation: 1 sets, 3 items).
seed apply is not an upsert. It refuses when the database already holds documents or assets (the database already holds N documents and M assets; run engine-dmo seed reset first), and in production it refuses unless the database is empty and --i-know-what-i-am-doing is given.
A value that fails validation stops the apply with the field named, but what was written before it stays: the records applied earlier, and the failing document itself as a draft. The next seed apply then refuses because the database is not empty. Fix the file, then run engine-dmo seed reset --confirm and engine-dmo seed apply again.
Apply works in this order, each step naming the records of the steps before it by the ids they received. In engine-dmo the modules’ own directories (claims, promotions and the rest) are applied after the step they depend on.
flowchart LR v["vocabulary"] --> a["assets"] --> u["users"] --> p["people and<br/>organisations<br/>(engine-dmo)"] --> d["documents<br/>history, publish,<br/>pending, schedule"] --> n["navigation"] --> t["invitations,<br/>then trash"] --> g["generated<br/>analytics"]
Example output:
seeded starter: 4 documents, 6 revisions, 4 published, 1 assets, 1 users, 1840 analytics events Generated documents
engine-dmo seed generate --type walk --count 200 --near 52.06,-1.34 --publish Writes documents of a type whose values pass each field’s validation, from built-in word lists and placeholder images. --seed makes the run repeatable over the same database; --locale and --site choose where; --near places generated points around a latitude and longitude where a field has no bounds. A relation field needs documents or tags to point at, so generate the targets first.
seed/generate.yaml describes generated seed files as recipes with placeholders ({pick LIST}, {int A B}, {point PLACE}, {relation RECIPE}, {pt NAME N} and more), written into ordinary seed files by seed write. engine-dmo seed write --help lists every placeholder.
Creating and updating from the command line
engine-dmo content create --type walk --locale en-GB --data '{
"title": "Bloxham loop",
"slug": "bloxham-loop",
"summary": "A short circuit from the village green.",
"difficulty": "easy",
"startPoint": { "lat": 52.02, "lon": -1.37 }
}'
engine-dmo content update <document id> --locale cy-GB --data '{ "title": "Cylch Bloxham" }' --publish | Command | Flags |
|---|---|
content create | --type (required), --data (a JSON object of values by field name, or - for standard input; the values may also be the first argument), --locale (default the source locale), --site (owner site, default the default site), --publish |
content update <id> | --data, --locale (default the source locale), --publish. Only the fields given change; null clears one. |
Both validate as the admin’s save does and refuse on an error. --publish then runs the publish checks for that locale. Relations and links name documents and assets by their database ids here. engine-dmo content get <id> shows a document’s working and published values by field name, and content list finds ids. All need a database.
Export and import between environments
engine-dmo content export --type walk --site main --out walks.json
engine-dmo content import --file walks.json --site main --dry-run
engine-dmo content import --file walks.json --site main --match slug An export writes a type’s documents on a site (or a selection with --id or --ids-from), with every locale’s working values (or those named with --locale), as JSON or, with --format csv, a CSV with one row per document and locale for reading in a spreadsheet. Only JSON can be imported. Exports are audited.
The JSON file:
{
"format": "meridian.content.v1",
"site": "main",
"type": "walk",
"exported_at": "2026-11-02T09:00:00Z",
"documents": [
{
"id": "1b9f...",
"type": "walk",
"variant": "",
"locales": {
"en-GB": { "status": "published", "values": { "title": "Bloxham loop", "slug": "bloxham-loop" } }
}
}
]
} | Rule | |
|---|---|
| Format | format must be meridian.content.v1; anything else is refused rather than half read. |
| Size | At most 2000 documents per file. |
| Matching | --match id (the default) updates the document with that id, or creates one under it, so a file moves between environments with its ids. --match slug matches the first locale’s slug among the type’s documents on the site, and creates a document under a new id when none matches. |
| Publishing | Never. Everything is imported as drafts; status in the file is information only. |
| Rich text | A portable_text value may be { "html": "<p>...</p>" }, converted by the same converter as the editor’s paste and normalised to the field; each adjustment is reported. An <img src="asset:<id>"> becomes the field’s image block pointing at that library asset; any other image is left out and counted, since an import never fetches a file. |
| Permissions | Every write goes through the content store with your grants; a locale you may not create or edit is rejected with the reason. |
| Dry run | --dry-run says what would happen to each locale and writes nothing. |
Schema changes
How a change to the type files reaches the database: schema plan and schema apply, what is destructive and how to approve it, renames by id with aliases, removed fields kept under _orphaned, retyping and coercion, reprojection, and what apply refuses.
Templates
How a request becomes a page, which template file renders each kind of page, how the template chain resolves names, and the commands that show what a template receives.