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 | says nothing about how those three differ, and in a CMS that
undefined
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
- Decide what the agent authenticates as before deciding what it can do. A borrowed human token makes the audit log useless for precisely the question you will want to ask it later.
- Consolidate the write path first, add the agent second. If your rules live in eighteen places by convention, an agent will find the drift faster than your team ever did.
- Make the invariant a test, not a docstring. A grep asserting that only one file may emit audit events is cruder than any architecture diagram and considerably more load-bearing.
- Describe the semantics of absence, not just the field. No type system distinguishes "leave this alone" from "clear this." An agent that guesses wrong overwrites data it was asked to preserve, and nothing in the response will say so.
- Bound the agent by what it can reach, not by what you audit. Universal coverage is hard. Giving the agent exactly one family of endpoints, all of them sitting on the seam you enforce, is easy and provable.
Top comments (1)
The reframe from "will the agent write something embarrassing" to "what does it authenticate as, can I reconstruct what it did" is the right order of questions, quality is the visible worry, but auditability is the one that actually bites later.
The null-vs-omitted distinction is the sharpest point here. An agent guessing wrong on that doesn't error or crash, it just silently drops data in production, invisible until someone notices weeks later.
"Consolidate the write path first, add the agent second" is the line worth remembering. The agent didn't create the mess (eighteen modules half-implementing the same recipe), it just made ignoring that debt too expensive to keep doing.