ai-cms architecture

What the system has to guarantee

Four properties drive every decision below.

  1. An LLM is the primary editing surface. Editing happens in Claude Code, in the site's repo, in natural language. The agent must be able to make a content change without being able to make a markup, design, or accessibility mistake.
  2. The source stays human-editable. Pages are markdown. Layouts and chrome are HTML. A developer can open any file and change it without learning a framework.
  3. The rendered HTML is pristine. Semantic elements, a small closed set of classes, no inline styles, deterministic formatting.
  4. Every published page meets WCAG AAA on mobile first. Enforced by a gate that runs before anything deploys, not by discipline.

The engine earns properties 3 and 4 by owning the markup. The agent writes content; it never writes the HTML that content becomes.

Decisions

Decisions
Question Decision
Content source Markdown pages with typed blocks; HTML layouts and partials
Rendering Build step producing static HTML into `public/`
Editing surface Local Claude Code against the site repo
Store Git. Every accepted edit is a commit
Build and deploy `cms dev` locally; push to self-hosted GitLab, CI deploys to Cloudflare Workers static assets
Images Originals committed, variants generated in CI
Engine shape Versioned package installed into each site repo, upgraded per site
Tenancy Single-tenant now, seams kept for later
Translation A locale dimension in the model from the start, machinery later

The two repos

The engine, ai-cms

Published to the GitLab package registry as @fivepaths/ai-cms, exposing a cms binary.

ai-cms/
  src/
    parse/        frontmatter, block splitter, YAML block bodies, prose markdown
    schema/       block schemas, field types, validation
    render/       Nunjucks environment, hast pipeline, serializer, normalizer
    media/        sharp variant generation, sizes derivation, manifest
    check/        markup, class allowlist, a11y, links, budgets, conformance
    cli/          the cms command
    sync/         prompt composition, skill and hook generation
  components/     default component schemas, templates, and prop modules
  layouts/        default layouts
  partials/       default header, footer, head, theme toggle
  prompts/        the generic prompt layer
  scaffold/       new-site template
  docs/

Sites pin an exact version. cms upgrade bumps it, regenerates the derived files, rebuilds, and runs the gate, so an engine change that would move a site's markup shows up as a reviewable diff before it ships.

cms is a dev dependency of each site, resolved from node_modules/.bin, not installed globally. A global binary would pin every site on a machine to one engine version and let CI drift from local, which is the failure the per-site pin exists to prevent.

A site, for example gtfs-media

Content, media, site-only CSS and components, prompt overrides, and the generated Claude Code files. Nothing about the engine is vendored in.

The engine reaches a site repo through three channels and no others: the package in node_modules, the prompt and hook files cms sync generates, and the component reference cms docs generates. Only the last two are committed, and both are text rather than code, so a git diff after an upgrade shows everything the engine changed about how this site behaves.

Content model

Detailed in CONTENT-MODEL.md. The shape in one page:

A page is frontmatter plus a flat list of blocks. A block is a :::type container whose body is YAML. Bare markdown between containers is a prose block.

---
title: "Every rider screen, straight from your GTFS feeds"
description: gtfs.media turns your agency's GTFS feeds into live displays,
  vehicle maps, timetables, and JSON APIs.
layout: landing
---

:::hero
heading: Every rider screen, straight from your GTFS feeds.
lead: |
  gtfs.media turns the [GTFS](https://gtfs.org/) and
  [GTFS-Realtime](https://gtfs.org/documentation/overview/) feeds you already
  publish into live departure displays, vehicle maps, timetables, and clean
  JSON APIs.
ctas:
  - { label: Get in touch, href: "mailto:hello@gtfs.media", style: primary }
  - { label: For transit agencies, href: /agencies/, style: ghost }
:::

:::plain
body: |
  Transit agencies publish their timetables in a standard file. It is called
  GTFS. This software reads that file and turns it into things riders can use.
:::

:::cards
heading: One platform, three layers
lead: A structured data core, a realtime pipeline, and rider-facing surfaces.
items:
  - title: Data platform
    href: /developers/
    icon: database
    body: Import any GTFS feed into structured, fieldable, editable content.
:::

Three properties of that format matter architecturally.

Pages are flat. There is no block nesting, so the agent cannot produce mismatched structure and reordering is a list operation. The renderer owns section wrappers and the light/dark band alternation the design system calls for.

Block bodies are YAML, always. One rule covers every component, so the agent never has to guess a syntax. Fields declared markdown in the schema get inline or block markdown rendering; everything else is a plain scalar.

Blocks are typed. Each component ships a schema listing its fields, their types, whether they are required, and any count limits. The same schemas generate the component reference handed to the agent, so the documentation the LLM reads cannot drift from the validator it is held to.

Render pipeline

src/content/*.md
  ├─ frontmatter            → page metadata
  ├─ block splitter         → [{type, yaml}]  (raw, before any markdown parsing)
  ├─ schema validation      → typed block list, or a failure with file and line
  ├─ markdown fields        → remark → hast fragments
  ├─ component render       → Nunjucks template + optional props module → HTML
  ├─ layout                 → Nunjucks layout with partials, head, nav
  ├─ normalize              → rehype: class allowlist, attribute order, indent
  └─ public/<route>/index.html

The block splitter runs before markdown parsing and takes :::type regions as raw text. That keeps YAML bodies out of the markdown parser entirely, which is what makes the format predictable instead of dependent on directive-syntax edge cases.

Templates

Layouts, partials, and component templates are Nunjucks HTML files. A component that needs computation gets an optional props.js beside its template: responsive image markup, nav state, anchor generation. A person can restyle a component by editing HTML; nothing forces them into JavaScript.

A site overrides a component by putting a file of the same name in src/components/. Site files win over engine files. The schema still comes from the engine unless the site ships one too, so an override changes the markup without silently changing the field contract.

The normalizer, and what "pristine" means concretely

After rendering, the HTML is parsed and re-serialized under fixed rules:

  • Class allowlist. The engine parses base.css and the site's site.css and collects every class selector they define. Any class in the output that is not in that set fails the build. Markup can only use classes the CSS actually implements, and dead classes cannot accumulate.
  • No inline styles, no colour literals. A style attribute or a hex value anywhere in output is a build failure. Colour comes from --fp-* tokens.
  • Semantic element preference. A configurable rule set flags a div used where section, nav, figure, ul, or article fits.
  • Deterministic formatting. Two-space indent, stable attribute order, fixed entity handling. The same commit always produces byte-identical output, so git diff on a rebuild shows only real changes and CI can assert the build is clean.

Media pipeline

Originals live in src/media/ with a YAML sidecar carrying alt text and handling hints.

src/media/pylon-single.jpg
src/media/pylon-single.yml
  alt: Platform pylon display showing a route badge, a service alert, live
    departures, and route information
  focal: [0.5, 0.35]
  decorative: false
  contains_text: true      # triggers the WCAG 1.4.9 attestation

cms media add <file> copies the original in, reads its intrinsic size, writes the sidecar stub, and refuses to finish until alt text is present or decorative: true is set deliberately.

Variants are derived, not configured. Each image slot in a component schema declares the CSS width the image occupies at each breakpoint. The engine turns that into the sizes attribute and into the width set to generate, at 1x and 2x, deduplicated and capped at the original's width. Because the component set is closed and the CSS is known, sizes is computed rather than guessed, which is the single largest lever on mobile image weight.

Formats: AVIF, WebP, and a JPEG or PNG fallback, emitted as <picture> with width, height, decoding, and a loading value the renderer decides from the block's position. The first image on a page gets fetchpriority="high" and is never lazy; everything below the fold is.

Output paths are content-addressed:

public/assets/img/pylon-single.<hash>-{480,768,1200,1800}.{avif,webp,jpg}

.cms/media.lock.json records the original hash, the variants, and intrinsic dimensions. It is committed, so a build knows what should exist without running sharp. Binaries are not committed. CI restores them from an R2 cache keyed by content hash and generates only what is missing, which makes an unchanged image free across branches and runners.

The gate

cms check is the single entry point. Claude Code runs it, the developer runs it, CI runs it, and nothing deploys without it.

  1. Schema. Every block validates against its component schema.
  2. Build. The site renders. Any render error fails here.
  3. Markup. Class allowlist, no inline style or colour literal, semantic element rules, html-validate for structural validity.
  4. Content rules. Heading order with no skipped levels and one h1 per page; link text meaningful in isolation; external links carrying the visually-hidden new-tab note; every image with alt or an explicit decorative flag; lang present and correct.
  5. Automated accessibility. axe-core over every built page, in both colour schemes, at 320px, 768px, and 1280px, with the AA and AAA rule tags enabled.
  6. Links. The internal link graph resolves. A broken internal link fails. External links are checked on a schedule instead, because they are flaky.
  7. Budgets. Lighthouse mobile against the preview deployment, with per-page transfer and Core Web Vitals thresholds in .cms/budgets.json.
  8. Conformance ledger. Every AAA criterion that cannot be automated has a current attestation. See ACCESSIBILITY.md.

cms check --page <file> --fast runs 1 through 4 for one page in well under a second, which is what the Claude Code hook uses on every write. The full run is for the pre-commit and CI paths.

The Claude Code layer

Prompt layers

The engine ships a generic prompt set. A site overrides or extends any slot.

engine   ai-cms/prompts/<slot>.md
site     .cms/prompts/<slot>.md      (frontmatter: mode: append | prepend | replace)

Slots: voice (the FivePaths writing rules), design (the design system rules), components (generated from the schemas), accessibility, media, seo, workflow (how to use the CLI, the gate, and the commit convention), and site (what this product is, who reads it, terminology, claims that are off limits). Default mode is append, so a site adds its own voice notes without losing the shared rules; replace is available when a site genuinely differs.

cms sync composes the layers into .claude/skills/content/SKILL.md, writes the hook block into .claude/settings.json, and writes a managed block into AGENTS.md between markers, leaving the rest of that file to whoever maintains the repo. All three are committed, so an engine upgrade shows its prompt changes as a diff a person reviews rather than as a silent behaviour change.

Nothing in .claude/ is code. The skill is prose and the hooks are one-line commands invoking cms, so the logic they trigger lives in one place and gets versioned with the engine.

The components slot is generated from the same schemas the validator uses. The component reference the agent reads is therefore always accurate.

Hooks

The hooks turn the gate from a convention into a mechanism.

The Claude Code layer
Hook Matcher Action
PostToolUse `Edit`, `Write` on `src/content/**`, `src/data/**` `cms check --page $file --fast`; failures come back as feedback
PostToolUse `Write` on `src/media/**` `cms media verify $file`
PreToolUse `Write` on `public/**` Deny. Build output is never hand-edited
Stop `cms check` in full

CLI operations

Free-form editing of a markdown page is the normal path, and it produces the best diffs. The CLI exists for the operations that are error-prone because they touch many files at once or have side effects outside the content:

cms new page <route> --layout <layout>
cms move <route> <new-route>        rewrites inbound links, writes the redirect
cms rm <route>                      same, plus a 410 or a redirect
cms nav add|move|rm                 edits nav.yml, every page picks it up
cms media add <file>                import, sidecar, alt-text prompt
cms outline <route>                 numbered block list for addressing edits
cms block insert|move|rm <route> <n>
cms dev                             local server, live reload, fast check
cms build | check | sync | upgrade
cms docs                            regenerate the human component reference
cms explain <component>             print one component's reference
cms translate <route> --to <locale> draft a translation, field by field
cms import <html>                   one HTML file to blocks
cms ingest <url>                    a whole site to a site repo

cms outline gives the agent stable addresses to talk about ("block 4 on /displays/") without putting synthetic ids in the source.

Git and deploy

Git is the store. Every accepted edit is a commit, so undo, blame, and review come from the tool the developers already use, and the agent's work reads as a diff.

Branches. Content work happens on a branch. Pushing opens a merge request in the self-hosted GitLab.

Pipeline.

install → build → check → deploy
  • Merge request pipelines deploy a preview and post its URL back to the MR.
  • The default branch deploys production.
  • check gates deploy in both. A failing check means no deployment exists to look at, which is the intended pressure.

Cloudflare. Workers static assets, matching what gtfs-media and fivepaths-cdn already do. The default branch runs wrangler deploy; a branch pipeline runs wrangler versions upload, which returns a version preview URL the job posts back to the merge request. Preview URLs have to be enabled on the Worker for that to resolve, which is worth confirming against the existing Workers before the first branch pipeline runs.

CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are masked, protected CI variables. The account is pinned in wrangler.jsonc the same way the existing repos pin it, because more than one Cloudflare account exists on these machines and an unpinned deploy has landed in the wrong one before.

Caches. The GitLab runner keeps the node module cache; generated image variants come from R2 by content hash.

The local loop

cms dev is where the work actually happens: a local server on src/, with incremental rebuild and live reload, and the fast check running on every save. A check failure appears in the terminal and as an overlay in the page, so a broken schema or a missing alt text is visible in the same second it is written, by the person and by the agent.

Two things are deliberately different in dev. Images are resized on demand from the originals and cached locally, skipping AVIF entirely, because waiting on an encode is the fastest way to make people stop running the dev server. And the full check, with axe across three widths and two schemes, runs on demand and at commit rather than on every keystroke.

The whole workflow is therefore: cms dev while working, commit, push, and the pipeline deploys. Nothing else to learn.

Seams left for multi-tenancy

Nothing in the design assumes one operator, and four things are kept clean so tenancy is an addition later rather than a rewrite.

  • Site identity lives in src/data/site.yml. The engine holds no global state and reads no machine-level configuration.
  • The CLI is a pure function of the repo. Every command takes a repo root and touches nothing outside it, so it runs the same locally, in CI, or inside a server process handling one tenant's checkout.
  • Authorisation is external. Today it is GitLab project permissions plus branch protection. A hosted surface would sit in front of the same commits.
  • The gate is the only publish path. Adding a review requirement is a branch-protection setting, not new code.

A hosted editing surface, when it comes, is another client of the same CLI. It does not need a second implementation of the content rules.

Fonts

Overpass is the default, not a requirement. A site with its own brand face declares it, and the engine handles the rest.

# src/data/fonts.yml
sans:
  family: Söhne
  source: src/assets/fonts/soehne-var.woff2
  weights: 400 700
  licence: src/assets/fonts/soehne-licence.txt
  preload: true
mono: inherit          # keep Overpass Mono from the shared sheet

The build subsets each face to the ranges the site's locales actually use, converts to woff2, writes the @font-face rules and the --fp-sans or --fp-mono override into the site's stylesheet, and emits the preload link for the one face first paint needs. A variable font is preferred, for the reason the shared sheet already uses two of them: one file covers every weight the design system asks for.

Four rules hold.

Self-hosted, always. A face is served from the site's origin or from cdn.fivepaths.com, never linked from a third-party font host. That is a privacy position and a performance one at the same time, and it keeps the head of every page pointing at origins the project controls.

A licence travels with the file. No licence record, no build. The OFL file sitting beside Overpass on the CDN is the pattern, and the check enforces it rather than trusting that someone looked.

Shared faces go to the CDN, site faces stay local. The same rule the component catalogue follows: a face appearing on a second site belongs in fivepaths-cdn, under a version path, so both sites get the same bytes and the same cache entry.

Fonts count against the budget. .cms/budgets.json caps font files and their total bytes per page. The shared sheet spends two files; a site adding two more has to be able to say why, and the number is in the check output rather than in anyone's memory.

The check also verifies the declared face actually loaded rather than falling back silently, which is the failure mode that survives review because the page still looks fine to whoever has the font installed locally.

One-off pages, and generated ones

Some pages are neither prose nor a composition of catalogue blocks. gtfs.media's API reference is built from openapi.yaml, and its glossary is a data set. A CMS that can only hold hand-written pages pushes these back out into raw HTML, where none of the guarantees reach them.

A generator is a site-local module that produces blocks.

src/generators/api-reference.js     exports (data, ctx) => [ {type, fields}, ... ]
src/data/openapi.yaml               its input

site.yml binds one to a route:

generated:
  /api/:
    generator: api-reference
    data: src/data/openapi.yaml

A site with any generator declares "type": "module" in its package.json. Generators are ES modules, and without it Node reparses each one and warns on every build. The engine checks and says so, rather than leaving Node's warning as the only signal.

At build time the generator runs, returns blocks, and those blocks go through schema validation, component rendering, the normalizer, the class allowlist, and every accessibility check exactly as a hand-written page's blocks do. A generator that emits a heading level out of order fails the same check that a person would.

What the first one bought. gtfs.media's API reference, hand-migrated as prose, was 107 of the site's errors: 53 tables with no caption, and a 37-link contents list pointing at anchors that did not exist, because an id cannot be expressed inside a prose body. Regenerated from openapi.yaml it is 100 typed blocks, every table captioned, zero broken anchors, zero duplicate ids.

Two things the build caught while it was being written, both worth having: the generator produced ids of 66 characters against a 64-character limit, and it emitted a prose block with a heading and no body. The first was a real bug, fixed by hashing long ids. The second was the schema being wrong, since a heading that introduces the blocks after it is legitimate. Neither would have been visible if generators returned markup.

Returning blocks rather than markup is the whole point. It costs a generator author almost nothing, and it means a one-off page cannot become the place where the standards quietly stop applying.

Generators cover the repeatable one-offs. The two narrower escape hatches, a site-local component and a gated raw block, are described in CONTENT-MODEL.md, and the order of preference runs from generator to component to raw.

Ingesting an existing site

cms ingest <url> crawls a site of under fifty pages and produces a site repo in this format: markdown pages, nav and site data, imported media with sidecars, and a site.css that is as close to nothing but token overrides as the source allows. It ships as a skill, /ingest, because the mechanical stages belong in the CLI and the judgment stages belong to the agent.

Two parts of it matter to the architecture. Chrome is found by diffing the DOM across pages and becomes nav.yml and site.yml rather than markup, which is the same collapse the gtfs.media migration performs by hand. And the source site's colours are sampled, assigned to token roles, and repaired in OKLCH until every pairing clears 7:1, so a brand survives the move into an AAA palette while staying recognisable. .cms/budgets.json carries a line budget for the generated site.css, reported by cms check on every run.

The full design is in INGEST.md.

Migrating gtfs.media

cms import parses existing HTML and matches it against component templates in reverse, producing markdown blocks. The nine content pages plus the legal and glossary pages are the acceptance test for the component catalogue: if a page does not round-trip, the catalogue is missing a component or a field, and that is the signal to add one rather than to reach for raw HTML.

The site currently duplicates its header, footer, and theme script across thirteen files. Those become one partial each, which is the first visible payoff of the migration.

Open questions

  • Whether preview URLs are enabled on the existing Workers, which is what the branch pipeline needs to post a link back to a merge request.
  • Whether a stale translation blocks the build or warns, per site. Both are defensible and the setting is explicit; the default is the question.
  • Which locales gtfs.media actually needs. The pylon screens are already multilingual, so the site probably follows, and that set determines whether the design system needs a face beyond Overpass's coverage.

Next

Continue with content model.