18,699 lines gone in one commit
On 29 September I removed the browser admin from this site. It had screens for posts, projects, Labs, experience, Fun entries, the résumé, site settings, the contact inbox and access tokens, plus its own sign-in page. The commit touched 94 files, added 76 lines and deleted 18,699. The admin's stylesheet alone was 2,081 lines.
The plan from 15 September had three steps: build an MCP server, build a native iOS app, then remove the browser admin. The first version of the MCP server landed on 17 September. I skipped the iOS app. The last few posts hadn't gone through the admin anyway. An agent wrote them and published them from the terminal.
Clerk went 33 minutes after the admin. The only thing still using a sign-in was an image upload endpoint for the iOS app, and it had never worked in production because its allowlist was never configured. Removing it took away nothing that worked. The decision record now says the site "has no sign-in, middleware or session of any kind."
That left a gap. The MCP server had never been connected to the live site. For about an hour and three quarters that evening, the only way to change content was the Convex command line.
37 tools behind one launcher
Claude Code and Codex both read their MCP config from the repo, and both files point at the same launcher:
{
"mcpServers": {
"home-management": {
"command": "bun",
"args": ["packages/mcp/src/local.ts"]
}
}
}
The launcher loads four values from a git-ignored .env and starts the server. Neither committed file holds a secret.
The server has 37 tools. Sixteen read content. Ten save work without touching the live site: create, update and discard drafts for posts, projects and Labs, and mark a review note as resolved. Ten need the publish scope: publish and unpublish for all three, schedule and unschedule for posts, and open or close the preview page. The last one, upload_media, runs on my laptop and puts an image on Uploadfile, then returns the URL and dimensions a draft needs.
No tool deletes anything. Fun entries, experience, the résumé and site settings can be read but not edited, so those still need the command line.
What replaced the login screen
A sign-in screen answers one question once: is this me? The agent never signs in, so the backend does four things on every write instead.
The first is whether the token is valid. Each one has a name, a list of scopes and an expiry date, and the function that issues tokens refuses one without a future expiry. The token Claude Code and Codex share can read, write and publish content, and read profile data. It expires on 29 September 2027. It can't issue or revoke tokens; that needs deploy credentials.
The second is whether this exact change has already happened. Every write carries an idempotency key, and the backend keeps a receipt of the result for seven days. If the agent times out and sends the same call again, it gets the first result back instead of making a second change. The same key with different input is rejected.
The third is whether the agent read the latest version. Edits, schedules and publishes carry expectedRevision for the post and expectedDraftRevision for its staged draft. If either has moved since the agent read the post, the call fails with a conflict, and the tool description tells the agent to read the post again rather than retry with new numbers. So publish_post publishes the draft the agent read, not a newer one.
The fourth isn't a check. Each write adds a row to an audit log with the token, the operation, the record, the revision before and after, and the names of the fields that changed. It doesn't copy the content.
All of it runs inside one Convex mutation, so the checks, the change, the receipt and the audit row commit together or not at all:
const actor = await requireManagement(ctx, args, scope);
const receipt = await beginManagementWrite(ctx, actor, {
idempotencyKey: args.input.idempotencyKey, operation: args.operation, input: args.input,
});
if (receipt.replayed) return receipt.result;
const { result, audit } = await applyManagementPostWrite(ctx, args);
return await completeManagementWrite(ctx, actor, receipt.receipt, result, audit);
A preview page that takes a code
I still want to read a post in the real layout before it goes out. When I ask, the agent calls create_preview_code and gives me a single-use code that works for ten minutes. I type it into the preview page, and that browser gets a session that lasts 30 days from its last use. revoke_preview_sessions signs every browser out at once.
The page shows each draft as it will look on the site. I can highlight a sentence and react to it or leave a note, and the agent reads those through the MCP server.
That page does have buttons that change the live site: publish, schedule, move back to draft, and publish or discard pending changes. So strictly, one small admin survived. It only covers blog posts, it has no text fields and no delete, and publish, unpublish and discard each ask for a second click. Its actions go into the same audit log.
Where it's weaker than a login
The token sits in a .env file on my laptop. Anyone with that file can do what the agent does until the token expires or I revoke it, and revoking it is a Convex command-line call.
Nothing reads the audit log yet. It can show what happened after something goes wrong, but it doesn't stop anything.
The iOS app lost its sign-in, its editing screens and its photo upload along with Clerk. Its source still contains the Clerk SDK.
The agent's token has the publish scope, so it could publish a post I haven't seen. It doesn't, because I've told it not to. That's an instruction, not a check. The preview page asks twice before publishing; the MCP server doesn't ask at all.