How my AI agent talks to the CMS
How Claude works with this site's CMS: it prepares drafts through EmDash's MCP tools and rules, I review and publish them, and the site runs on Astro and EmDash in Docker on a small ARM server.
- Status
- Done · 2026
- Built with
- Astro 7, EmDash 1.4, Node, SQLite, Litestream, Docker Compose, Caddy, Cloudflare, R2, GitHub Actions, Claude Code
- Time
- 7 days to launch
- People

I don't write in an admin panel. I send Claude my notes in a chat, and it prepares a draft through EmDash's tools and rules; nothing goes live until I say publish. New sections go through a pull request I review. This page explains how, and was written the same way.

Why I built it this way
I work as a Forward Deployed Engineer: I take a goal, pick the stack, ship it to production and keep it running. My own site should show that, so it runs on my own server, in containers, behind a CDN, with real deploys and backups. Not on a site builder.
The other goal was to never fight a CMS. On most personal sites the code is fine and the writing stops, because every entry means an evening in an editor. If an entry starts as a few lines in a chat, I write more of them.
I compared two setups. The first was Next.js with every entry as an MDX file in git, so each post would be a pull request. The second was EmDash: an open-source CMS built on Astro that keeps content as Portable Text in SQLite and ships an MCP server: a defined set of tools an agent can use to read the content model, upload media and prepare drafts, within the rules the CMS sets. I picked EmDash. Content doesn't wait for a deploy, the content model is one JSON file in git, and the content can move to another frontend later if I outgrow it.
Two paths: content and code
Lives in | Live after | |
|---|---|---|
Content: Lab, AI toolbox, pages | EmDash, through its MCP tools | The next request |
Code: new sections and features | The git repo, through pull requests | About 3 minutes |
Content through MCP
EmDash's MCP server offers 72 tools, each a defined action with its own checks: read a collection's schema, upload a file, create a draft, publish. Claude connects with a token I created in the admin and can only do what those tools allow. For a new entry it runs the same steps every time:
- Read before writing. The collection's schema, the existing authors and topics, and a check that there isn't an entry on this already.
- Media first. Every image and video goes up before the text, each with alt text that says what is in it.
- A draft. Text goes in as Markdown, which EmDash turns into Portable Text, plus the blocks Markdown can't express: images, silent video loops and charts.
- My review. I get a link and a short list of anything Claude guessed or left empty.
- Publish when I say so. Or publish it locked first: a locked entry is live, but only I see it while signed in, so I can check it on the real site before anyone else does.
{
"collection": "lab",
"slug": "ai-agent-cms",
"data": {
"title": "How my AI agent talks to the CMS",
"lab_status": "done",
"cover": { media from media_upload },
"content": [ 67 blocks: text, images, a video, charts ]
},
"bylines": [{ "bylineId": "vedran", "roleLabel": "Direction, review, product" }],
"taxonomies": { "topic": ["web", "ai"] }
}The charts on this page are blocks like that: a title, a few rows of numbers and a caption. The site draws them in HTML and CSS in the brand, and the bars grow in when they scroll into view. Nothing is a screenshot of a spreadsheet.
What Claude writes is set by a playbook, CLAUDE.md in the repo, which every session reads first. It holds the content model, the voice (first person, plain sentences, numbers over adjectives, no sales talk) and the hard rules: never invent a fact, never publish without my go-ahead, and ask me one question at a time as a multiple-choice card, because I'd rather tap an answer than type one.
Don't make up facts: dates, numbers, client names, results, people. If something is missing, leave the field empty and ask.
Code through threads
Code changes are for new sections and features; writing a new entry never touches the code. For that work I use a Claude project with one thread per task: "Fix the cookie modal", "Reach 100 mobile PageSpeed score", "Security test petrach.dev". A coordinating Claude starts each thread, the thread works on its own branch and ends in a pull request that I review and merge. Several threads run at once, and they share a project memory, so a decision I made in one thread is there for the next.
The whole site is just under 12,000 lines of code, with no framework on the public pages. Most of it is components, the small scripts behind the canvases, and the pages themselves.
What the code is made of, in lines
- Components and layout3,142
- Scripts: canvases and UI2,505
- Pages and routes2,424
- Styles1,365
- Library: queries, discovery, profile1,218
- Local checks: smoke, contrast613
- Deploy and CI425
Not every thread changed code. Of the 48 threads before this one, half changed the site. The rest planned it, filled it with content or made things around it: the brand, business cards, a CV, promo videos.
What the 48 threads were about
- Code and infrastructure24
- Brand, video and print9
- Content through MCP8
- Planning and design7
How a request reaches the page

- Cloudflare holds the DNS, handles TLS for visitors and caches static files, on the free plan.
- Caddy takes the traffic from Cloudflare and passes it to the app.
- The app is Astro 7 with EmDash 1.4 on Node, in one container. Every page is rendered on the server from the database, so a published entry is live on the next page view, with no build step.
- SQLite sits on a Docker volume. Litestream streams every change to an R2 bucket, so if the server goes, the content can be restored from that backup.
- Media goes to Cloudflare R2 and is served from files.petrach.dev. Images are resized to WebP with a srcset on the way out.
- The server is a Hetzner Cloud ARM machine with 2 vCPUs and 4 GB of RAM. The image is built for ARM and x86, so it can also run on a regular x86 server.
From merge to live
A merge to main starts two workflows on GitHub Actions. CI validates the content model, type checks, builds the site and checks the Caddy config. Deploy builds the Docker image, pushes it to GitHub's registry and rolls it out over SSH.
From merge to live, one real deploy
- Build the image116 s
- Prepare the release6 s
- Pull the image5 s
- Swap and restart20 s
- Health check1 s
- Secrets are kept in GitHub.
- A newer merge wins. It cancels an older build that hasn't finished, but a rollout that has already started is left to finish. On launch day six builds were cancelled this way.
- One switch locks one entry. A Lab or toolbox entry with the Locked field on is published, but only I see it while signed in. For everyone else it is a 404, and it is left out of the indexes, the sitemap and search.
Fast on a phone
The budget is under 40 KB of gzipped JavaScript per page, with no framework on public pages. React only runs in the EmDash admin. Pages also work without JavaScript and with reduced motion: every canvas has a fallback, and CSS hides things only when scripts are on.
JavaScript per page, gzipped
- The budget40.0 KB
- Home25.1 KB
- Lab25.0 KB
- AI toolbox21.9 KB
- A Lab entry22.0 KB
- A toolbox entry19.2 KB
- About18.3 KB
After launch, mobile PageSpeed on Home was 97. First paint, largest paint, blocking time and layout shift were all green; only Speed Index was slow, at 4.6 seconds. The cause was animation nobody could see: the step diagrams on the toolbox cards kept looping off screen, and every frame of those SVG loops forced a layout. The hero also repainted every lattice cell on every frame. Now the loops pause off screen and the hero draws its lattice once, into an offscreen canvas. Nothing looks different.
Mobile Speed Index on Home
- Before4.6 s
- After1.5 s
Moving between pages uses a CSS view transition (the header stays, the page fades and rises), and links prefetch on hover with speculation rules. It feels like an app without a client-side router.
Readable by search engines and AI agents
/sitemap.xmland/llms.txtare built from the CMS on each request, so a new entry is in both the moment it is published.llms.txtshows each entry's summary, which is why every summary is one plain sentence.- Every page carries a Person JSON-LD node, so search engines and agents can tell whose site it is.
- Every page has its own share card, drawn on request with satori and sharp: paper, the lattice, the logo and the page's own title.

Privacy and the admin
- Analytics only after a yes. Google Tag Manager loads only after a visitor accepts analytics in the cookie banner. The banner has Necessary, Analytics and Marketing, and everything except Necessary stays off until someone switches it on.
- Passkeys only. The admin signs in with passkeys and sign-up is off.
- A security check workflow. Claude looks at the live site the way an outsider would, and reports what it finds. I go through the report, and the fixes come back as pull requests I review.
What broke
- Signed out on every deploy. Admin sessions lived inside the container, so each deploy logged me out. They now live on the data volume.
- Deploys racing each other. Two quick merges meant two rollouts at once. Now the newer build cancels the older one, and a rollout that has started is left to finish.
- One slow number. Everything green except Speed Index, caused by animation running off screen (above).
- One field type. EmDash couldn't create a URL field through MCP, so that one field on the live site is a plain string.
- A blocked client. Cloudflare's bot protection blocked Python's default user agent on scripted MCP calls. A named user agent fixed it.
- Boolean filters. EmDash queries filter on strings and arrays, not booleans, so "featured" and "building now" are filtered in code.
What I'd take to the next project
- Split content from code. Content in a CMS that an agent works with through defined tools and rules, code in git through pull requests. Each gets the review it needs, and writing never waits for a deploy.
- Write the playbook first. One
CLAUDE.mdwith the content model, the voice and the hard rules gives every session the same starting point. - Publish locked, then open it. I'd rather check an entry on the real site, before anyone sees it, than in a preview.
Who built it
- Vedran PetračDirection, review, productI build apps and websites, and the odd maths model.About me

Follow along
New builds and write-ups now, videos from 2027. Pick the channel you already use.
- GitHubCode and files from the Lab
- YouTubeLab builds on videoComing in 2027
- Say hi[email protected]