Letting an agent write to my production CMS

The slowest job on the content platform I run is authoring a review end to end. Create the record, fill seventy-odd structured fields, write the narrative blocks, upload and wire the screenshots, publish. Every review follows the same shape, which is exactly what makes it miserable and exactly what makes it a good candidate to hand off.

The worry people lead with is quality. Will the agent write something embarrassing? That is the easy half. A draft is reviewable, and a bad draft costs nothing but the time it takes to read.

Authoring means writing to production, though, and that is a different animal. An agent with write access to a live CMS is not a drafting tool. It is a second admin who never sleeps, never gets bored of the tedious fields, and will work straight through the backlog without ever wondering whether the first record came out right.

So the question was never whether an agent could edit the site. It was: what does it authenticate as, what enforces the rules when it writes, and can I reconstruct afterwards what it did.

Why a tool server and not a script#

I weighed three shapes for the write path.

One-off REST scripts are the fastest thing to start and the worst thing to own. Each one re-implements whatever slice of the validation rules it happens to need, they do not compose, and nothing tells you later which of them check anything at all.

Browser automation is more tempting than it looks, because driving the real admin UI inherits every rule the UI enforces for free. It is also slow, brittle against any markup change, and hands you a screenshot where you wanted a result.

I built an MCP server instead. The tools are primitives (get a listing, update a listing, upload an asset, replace a page's blocks) and the agent decides how to sequence them. That was the part worth paying for: I did not have to anticipate the workflows, only the verbs. Roughly forty tools now cover the entity types the CMS manages, and none of them encode a workflow.

One server, two transports. Locally the agent host launches it as a child process over stdio. In production it runs as a persistent service behind the reverse proxy so a colleague's agent can reach it with their own key. Same tools, same code path, nothing to drift.

The agent is not a user#

The MCP server never holds a human's session token. It authenticates to the backend as a service identity with its own API key, minted per operator.

This looked like pointless ceremony until I thought about the audit log. If the agent borrows my token, every row it writes says I did it, and there is no way afterwards to separate "I changed this in the admin UI" from "an agent changed this at 3am." Per-operator keys let the trail answer whose agent performed a write without anyone sharing credentials. Revoking one operator's automation becomes deleting one row instead of rotating a secret that everything else depends on.

The rows themselves are boring. Actor, action, entity type, entity id, a metadata blob with the field and its old and new values, an IP, a timestamp, indexed by time and actor and entity. Boring is fine. The schema was never the hard part. Making sure the editorial writes actually reach it was.

One seam, or no seam#

Which is where the project had a real problem, and it predated agents entirely.

Writes that emit audit events lived in eighteen modules. Four were proper write services, each hand-implementing the same eight-step recipe: allowlist the incoming fields, strip the server-managed ones, default the required arrays, validate the author attribution that regulated-topic pages require, check for conflicts, diff to decide whether the edit counts as a content update, stamp the first-publish timestamp, write, re-read, log.

The other fourteen were route handlers calling the audit function straight from the HTTP handler body, skipping most of that.

The recipe was asserted in a docstring that called itself the single policy owner, and enforced by copy-paste. With a handful of people editing at human speed the drift stayed survivable: fields quietly not bumping editorial freshness, audit metadata inconsistent between entity types, the occasional edit that should have demanded a named author and did not.

An agent does not create that problem. It accelerates it. A rule maintained by remembering to do the same thing in seventeen other places is not what you want standing between a machine and your production database.

So the eighteen modules collapsed into a single write-verb runner before the MCP server got a single write tool. Named verbs stay as the public interface, but each verb body is now a short forwarder into one runner that owns the whole sequence, with each step optional per policy.

Consolidation alone would have decayed the same way the docstring did. What holds it is a test file. It asserts the structural invariants on every registered field policy (content-bump fields are a subset of the editable profile fields, required arrays likewise, server-managed fields never intersect the editable set) and it greps the source to assert that nothing outside the audit module and the runner imports the audit function at all.

That grep is the invariant. Checking it by hand takes one command:

$ grep -rn "audit/audit.service" backend/src | grep -v test
modules/audit/index.ts:4       export { auditLog, auditLogAsync, ... } from './audit.service.js'
modules/write-verb/runner.ts:31  import { auditLogAsync } from '../audit/audit.service.js'
modules/write-verb/runner.ts:32  import type { AuditLogParams } from '../audit/audit.service.js'

The audit module re-exporting itself, and the runner. In CI it is a failing test rather than a convention anyone has to remember.

It is worth being precise about what that buys, because I was sloppy about it in my own head for a while. It proves audit events have exactly one emission point. It does not prove every write is audited. Something like thirty files in the backend still call the ORM directly, and most of them should: newsletter consent and template scaffolding are not editorial actions and have no business writing editorial audit rows.

What bounds the agent is narrower and more useful than universal coverage. Every write tool in the MCP server targets an /api/admin/* endpoint, and those handlers are parse-auth-status shells sitting on the write services. The agent's entire write surface runs through the runner, not because the runner is universal, but because the agent cannot reach anything that bypasses it.

None of that was agent work. It is the reason the agent work was tractable.

What the agent is allowed to touch#

Every write tool takes an explicit field allowlist, expressed as a typed input schema with a human-readable description on each field. The types are the easy part and they are not sufficient. A field typed string | null | undefined says nothing about how those three differ, and in a CMS that difference is the whole game: omitting a field means leave it alone, null means clear it, and nothing in the type tells an agent which one you wanted.

So the descriptions carry the semantics:

position: z.number().int().optional()
  .describe('Global sort position. Omit to append at the end.'),

canonicalPath: z.string().nullable().optional()
  .describe('Canonical URL path override. Null to clear.'),

seoTitle: z.string().optional()
  .describe('<title> override for SEO. Falls back to title when omitted.'),

publishedAt: z.string().nullable().optional()
  .describe('ISO 8601 datetime: publish window start. Only effective when status=PUBLISHED. Null to clear.'),

authorSlug: z.string().min(1)
  .describe('Author slug for YMYL attribution. Required. Use list_authors to find valid slugs.'),

Those are prompt fragments, and writing them was most of the prompt engineering I ever did on this system. Omission has behaviour: append to the end, or inherit from another field. Null is a separate instruction from omission. Some fields only take effect in a particular state. Some cannot be filled at all without calling a different tool first to find valid values.

Get one of those wrong and nothing raises. The agent writes something plausible and moves on. The largest entity carries seventy-two of these.

The honest part. That allowlist exists twice, once in the backend and once in the MCP server, deliberately, so the server stays a package you can deploy on its own. Adding a field means editing both files. Forget one and the update tool drops that field on the floor: the write reports success and the value simply is not there.

I knew that when I accepted it and wrote it into the decision record as a known hazard, which did not stop it from biting me. Starting again I would generate both from one source and accept the coupling.

The boundary that paid twice#

One accident worth keeping. The platform's calculators keep their strategy tables and scoring formulas server-side and never ship them in the client bundle, a decision made for competitive and licensing reasons months before any agent existed. If it is in the bundle, anyone can read it and republish it.

That same boundary means the agent cannot see the logic either. It can call the endpoints and read the results, and it has no path to the tables. I did not design it as an agent-safety property. It turns out "what must never leave the server" and "what an agent has no business reading" are nearly the same list, and the line only had to be drawn once.

What transfers#