Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spoken CMS

The CMS you talk to. A client edits their live website by describing what they want, in plain English, and an AI agent makes the change. The interesting part is not that it works. It is that the agent is structurally incapable of breaking the site.

Built by Christian Mauerer, AI Solutions Architect, at Lovepixel Agency. Running in production for a real client.

The studio: chat on the left, live preview on the right

One sentence, and the heading changes. The preview is the client's real site, rebuilt and reloaded as they watch.


The problem this exists for

You build someone a beautiful site. Six months later they email you to change a headline, because learning an admin panel for one sentence a quarter is not a trade they want to make. That email costs them a day of waiting and costs you an interruption, on a task neither of you values.

Each of the usual answers moves the problem rather than removing it. A CMS gives them an interface to learn, and enough freedom to undo a layout they did not design. A page builder keeps them editing, at the cost of a design that drifts from the one you agreed. Doing it yourself indefinitely is the most reliable option and makes you a permanent dependency.

Those are trade-offs, not flaws. For a site with a real content cadence, a CMS is the right answer and we build plenty of them. This is for the other case: a client who wants a handful of changes a year, on a design that should stay exactly as delivered.

For that case, Spoken CMS is a fourth option. They say "make the hero heading say Welcome home" and it happens, and there is no combination of words they can type that takes the site down.

What it looks like

A change, then a refusal

The second answer is the product. Asked to rewrite the parallax engine in 3D, it declines like this:

Rewriting the parallax engine in 3D requires changing the site's animation code, which lives outside the page content and design settings I'm allowed to edit. A developer would need to build that engine before I could adjust anything about it.

Nothing was changed.

Specific about where the boundary is, honest about what would be required, and no jargon. Note the counter goes from 39 to 38: a refusal is a real decision by the agent, so it costs a change. Only a rate limit or an infrastructure fault is refunded, because those are not the client's fault.

Both screenshots are the running product, captured by driving the real UI. No mockups.

The safety model

The agent runs with real file-write tools against a real checkout of a live client site. Four independent layers stand between it and damage. Any one of them failing leaves the other three.

1. A path allowlist, enforced before the tool runs. Exactly two globs are writable: src/content/pages/**/*.json and src/config/site.config.json. Everything else in the repo is readable for context and rejected on write.

2. A PreToolUse hook, as a second gate. The allowlist is checked again by a different mechanism, because a single enforcement point is a single point of failure.

3. A full-repo SHA-256 scan, after the run. Every file is hashed before and after. Any change outside the safe surface, however it got there, is detected and the whole run is reverted. This catches what an allowlist cannot: a subprocess, a symlink, a path the check did not anticipate.

4. A build gate. npm run check and npm run build must both pass. If either fails, the edit is reverted in full. A schema violation, a broken reference or a type error means the change never existed.

Then, and only then, the diff is shown and the change is offered for saving.

The layer that turned out to matter most

A pre-launch security review found something none of the four layers caught, and the reason is the useful part.

The content schema accepts prose as a string. Strings can contain HTML. The renderer claimed, in a comment, that it escaped HTML:

// with `html: false` semantics enforced here, no content field can inject markup

html: false is a markdown-it option. This project uses marked, which has no such option and dropped its sanitize flag in v5. The comment described a control that had never existed. Every payload rendered live.

Nothing was bypassed. The agent wrote prose into its permitted surface, the schema accepted a valid string, and both gates passed because neither inspects the inside of a string. The sandbox worked exactly as designed and the escape route ran straight through it.

It is fixed in two places now, either sufficient alone: the schema refuses anything tag-shaped on the way in, and the renderer escapes it on the way out. Both are covered by tests that run in the same gate the agent must pass, so a payload now triggers an automatic revert.

The general lesson is in the repo because it was expensive: a comment is not a control. It read as reassuring for exactly as long as nobody executed it.

Architecture

Browser ──HTTPS──► Cloudflare ──tunnel──► Orchestrator ──HTTP──► Container
                   (Access +             (holds every           (holds one
                    WAF + TLS)            secret, owns           token, runs
                                          the only push)         the agent)
                                                │
                                                ▼
                                          sparse git clone
                                          scoped to one site

The orchestrator holds every credential, owns the session lifecycle and the three caps, proxies the preview, and is the only code permitted to push. Every response body and error string passes through a redactor on the way out, so a careless err.message cannot leak a token.

The container is a per-session sandbox: 2 GiB, 1.5 CPU, capabilities dropped to the five a bind-mount writer needs, and exactly one secret. It never sees the git token, the passphrase or the signing key.

Push scoping is verified by asking git, not by trusting the caller. The check runs over the base..HEAD range, so a commit touching another client's directory is refused even when it is hidden behind a clean tip. That case is covered by a test that constructs exactly that shape.

Three caps bound a session: 60 minutes wall clock, 20 minutes idle, 40 edits. They live in the orchestrator rather than the container, because a cap enforced by the thing being capped is a suggestion.

What broke, and what it taught

Portfolio repos usually show what went right. These are the failures that changed the design, because they are the part worth reading.

Session reuse is more expensive than starting fresh. The intuition is that keeping one agent session alive across edits saves money through prompt caching. A controlled A/B said otherwise: +127% cost. Cache reads grow linearly with turn count, so by edit five you are re-reading four edits of history to make one change. Every edit now gets a fresh session.

A 368-second edit proved the timeout rewrite necessary. /message originally held the HTTP connection for the whole agent run. A real edit measured 368 seconds. nginx defaults to a 60-second read timeout and Cloudflare's proxied ceiling is 100 seconds, not configurable below Enterprise. It is now asynchronous: 202 with a sequence number, and the UI polls. The sequence number matters, because a reload mid-edit would otherwise render the previous edit's outcome as the answer to this one.

A guard that refused only the configurations nobody runs. A dev-mode flag skipped authentication for loopback callers, fenced by two checks. The production shape is nginx in front of a loopback bind, which passed both and made every request on the internet look like loopback. Its own comment named the hazard precisely and the code failed to cover it. Now fenced by a positive assertion of dev instead: conditions that cannot both hold in production.

Mutation testing killed three tests written to catch specific bugs. All three passed against the broken code they were meant to catch. One flooded 3,000 keys when the cap was 4,096, so eviction never ran. One hammered a frozen clock, where re-arming a lockout computes the same instant. One asserted x || true. Every fix in this repo is now verified by reverting it and confirming a test fails — a test that cannot fail is worse than no test, because it is counted.

One filed finding turned out not to be a bug. A review flagged that a correct passphrase during a lockout counted as a failure. True, and unobservable: the counter is zeroed when the lockout lapses. The line was tightened anyway, and the comment says plainly that no test claims to catch it. Reporting a non-bug as fixed is how a test suite becomes theatre.

Tests

orchestrator   96 passing    sessions, caps, push scoping, auth, proxy
auth           43 passing    token format, timing safety, cookie handling
content        36 passing    the XSS boundary, both layers, in both directions
harness        self-test     path allowlist, revert, gate outcomes

The content tests are wired into npm run check, which the agent's own build gate runs. A payload therefore triggers an automatic revert rather than a review comment.

Running it

Requires Docker, Node 22+, and a Claude subscription token.

cp .env.example .env      # fill in four values
./scripts/testdrive.sh    # sandboxed: pushes to a throwaway local clone

testdrive.sh points the push target at a temporary directory, so nothing reaches a real repository. Read docs/contracts.md before pointing it anywhere that matters.

Status

Running in production for one client. The repo is a case study rather than a product: paths and the site schema are specific to the deployment it was built for, and generalising it was not the goal.

The client site itself is not included. example/ contains the schema, the renderer and the security tests, which is enough to see how the safe surface is defined without republishing work that belongs to the client.

Featured with the client's permission.

Licence

MIT. See LICENSE.

About

The CMS you talk to — a client edits their live website in plain English, and the AI agent is structurally incapable of breaking it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages