Building a CMS an AI agent can actually drive
What it takes to let an agent drive a CMS without guessing: self-describing tools, structured errors, scoped tokens, and no second code path.
Most software that claims agent support has wrapped an existing API in tool definitions and stopped. That gets you a demo. It doesn't get you an agent that can build a whole website without a human correcting it every third step.
We built the agent surface as a first-class client rather than an afterthought, and a handful of design decisions did most of the work. This is a post for the developer in the agency, not a pitch.
Nothing is static, so the agent has to ask
The fundamental constraint here is that there is no fixed schema. Every workspace defines its own content types and fields at runtime, and installed apps contribute more. An agent cannot be trained on our data model, because our data model is different in every tenant.
So the first rule is that the tool surface is self-describing and mandatory to read. get_workspace_context confirms which tenant it's in. get_capabilities tells it what it's allowed to do. describe_schema returns the live content model — types, fields, which are required, which are references, which stacks exist. The instructions the server hands the agent on connect say plainly: always call describe_schema before reading or writing content, never assume field names.
That single rule removes most hallucinated writes. An agent that guesses body when the field is called content fails; an agent that asks doesn't.
The same idea runs through the rest: get_routes returns the URL manifest so the front end is generated from declared routing rather than a scheme the agent invented, and export_graphql_schema / export_types hand it the generated API and typed definitions so the queries it writes compile.
Errors are instructions, not stack traces
A failed write returns what to do next. Field-level validation errors naming the field, the expected type and what arrived. A refused operation explains why and what would work instead — when a render mode isn't offered any more, the error says so and names the tool to use instead.
This matters more than it sounds. An agent's recovery behaviour is driven entirely by what the error says. Given "500 Internal Server Error" it retries the same call. Given "field price expects a number; received a string" it fixes the field and moves on. The difference between those two responses is the difference between a build that completes and a loop that burns tokens.
There's a related habit: warn rather than silently accept. If a stack being created looks like a hand-rolled form — a submitLabel, a fields array — the write succeeds but comes back with a warning explaining that forms are a real primitive with a submit endpoint and an inbox, and that what it just built renders as inert text. Agents act on warnings. That one has saved more broken contact pages than any amount of prompt text.
Idempotency, because agents retry
Agents retry. Network blips, timeouts, a tool result that didn't parse, a context window that lost the thread. Without idempotency keys, a retried create_document gives the client two About pages and nobody notices until launch.
Every create accepts an idempotency key. Reuse it on retry and you get the original result rather than a second row. This is unglamorous and it is the single most load-bearing safety property in the whole surface.
Scoped tokens, and filters that only ever subtract
A connection carries a token scoped to a workspace and a set of capabilities. The tools the agent is given are computed from that scope — it sees what it may use, not a full catalogue where a third of the calls return 403. An agent that can see a tool it can't call will try it, repeatedly.
Two extra filters can narrow a connection further: read-only, and a category restriction. Both compose by intersection with the token's own grant. Nothing in the URL can widen what the credential permits — it can only take tools away. That property is easy to state and important to hold, because the connection string is the part a human pastes around.
On a content-scoped connection there's no publish, no deploy, no schema and no settings tool registered at all. Not hidden, not failing — absent. Prompt injection cannot reach a tool that isn't in the registry.
Publishing stops at staging
Publishing content puts it in staging. Production is reached through a separate, deliberate promotion step, and promotion can only move staging forward — there's no path that skips it. Every deployment can be rolled back, and every document keeps its revision history with restore.
The design goal is that the worst thing a confused agent can do is make a mess in staging that a human declines to promote.
One core, no second path
The rule that makes everything above trustworthy: the agent tool layer is a thin client over the same Management API the Studio uses. It doesn't import the engine, it doesn't have its own write path, and it doesn't have privileges the Studio lacks.
So every validation rule, every permission check, every tenant-isolation guarantee applies identically whether a person clicked a button or an agent called a tool. A content type the agent created is an ordinary row. A page it composed opens in the normal editor. A theme it deployed is the same theme you can pull with the CLI, edit and push back.
Two code paths would eventually diverge, and the day they diverged, "everything the agent does is editable by hand afterwards" would stop being true. That guarantee is the product. It only survives if there's one path.
What we'd tell anyone building this
Make the model introspectable and make asking mandatory. Write errors for a reader who will act on them. Assume every call will be retried. Compute the tool list from the credential. Keep the dangerous verb behind a human. And route it all through the same core your own UI uses, so there's nothing the agent can do that you can't undo.