Letting AI agents write my blog, without letting them publish it

How I set up a Ghost blog that Claude Code, Codex and Hermes can draft for, but can't publish without me. Plus the seven things that broke.

Share

I wanted somewhere to write up the things I build and break. I also wanted my AI agents to do the drafting: Claude Code on my Mac, Codex, and Hermes, the assistant I run on my own server. What I didn't want was any of them pressing Publish without me.

So this blog is set up so agents can write, edit and schedule posts, but nothing goes live until I've read the preview and said yes. It took one evening to build, and seven things broke along the way. Here's the setup and the fixes, in case you're building something similar.

The setup

  • Ghost 6 with MySQL 8, running in Docker on a Coolify server.
  • A Cloudflare Tunnel, so the server has no open ports. The tunnel's connector runs as a third container next to Ghost and reaches it over the private Docker network.
  • Cloudflare Access in front of /ghost, the admin area. I sign in with Google. Agents get in with a service token.
  • A small Node script with no dependencies that talks to the Ghost Admin API. I wrapped it as a skill, so Claude Code, Codex and Hermes all use it the same way.

Two Access bypasses Ghost needs

Ghost publishes a scheduled post by calling its own public address, /ghost/api/admin/schedules/.... With Cloudflare Access guarding /ghost, that call would hit the login page and the post would never go out.

So there's a second Access application on /ghost/api/admin/schedules with a Bypass policy. Ghost signs each of those calls with its own token, so the endpoint is still protected. A third covers /ghost/api/content, which the site search and the sign-up pop-up use. Create the bypass applications before the main one, so the general /ghost rule never catches their paths.

The publishing rule

Agents can create drafts, upload images, edit posts and schedule them. The skill gives them one rule: no scheduling or publishing until I've seen the preview and asked for it in the same chat. Ghost's preview links work without signing in, so I can check a draft on my phone.

The script also protects my own edits. Every update has to quote the version of the post the agent last read. If I've changed the post in the browser since then, Ghost refuses the update, and the agent has to read the post again:

$ ghost update 6aceb507... --base 2026-10-13T22:47:35.000Z --title "New title"
ghost: The post changed since you read it. Run get again, re-apply your edit, and pass the new updated_at as --base.

What broke, and the fixes

1. A new domain is "not found" for an hour

Something looked up the domain before Cloudflare was serving it and got "no such domain". The .me registry tells DNS servers to remember that answer for an hour:

$ dig SOA me. +short
a0.nic.me. hostmaster.donuts.email. 1791929753 7200 900 1209600 3600

The last number is the time in seconds. My phone couldn't open the site, and Ghost itself kept failing to reach its own address after DNS had caught up. Switching the phone from Wi-Fi to mobile data fixed the first. Restarting the Ghost container fixed the second, and the stuck post published straight away.

2. Ghost dropped my uploaded video

When you send Ghost HTML, it turns the markup it recognises into editor cards and throws the rest away. A plain <figure><video> vanished without an error. It needs the wrapper Ghost writes itself:

<figure class="kg-card kg-video-card">
  <div class="kg-video-container">
    <video src="VIDEO_URL" width="640" height="360" playsinline preload="metadata"></video>
  </div>
</figure>

3. YouTube embeds showed "Error 153"

Ghost's pages carry <meta name="referrer" content="same-origin">, so the browser sends YouTube no referrer, and YouTube won't play the video. YouTube's own embed code includes a referrer policy on the iframe. Adding the same attribute fixed it:

<iframe src="https://www.youtube.com/embed/VIDEO_ID"
  referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

4. A good backup reported as failed

The backup job checks each database dump before keeping it. My first check was:

zcat ghost-db.sql.gz | grep -q 'CREATE TABLE `posts`'

The backup script runs with set -o pipefail. grep -q stops reading as soon as it finds a match, so zcat is still writing when the pipe closes. It dies with SIGPIPE, pipefail passes that on as exit code 141, and a perfectly good backup counts as a failure. grep -c reads to the end, so this version works:

zcat ghost-db.sql.gz | grep -c 'CREATE TABLE `posts`' >/dev/null

5. zsh doesn't split variables

The skill first told agents to save the command in a variable and run it:

G="node ghost.mjs"
$G list

Bash splits $G into a command and its arguments. zsh, the default shell on a Mac, doesn't, and looks for a program called node ghost.mjs. A shell function works in both:

ghost() { node "$HOME/.claude/skills/ghost-blog/scripts/ghost.mjs" "$@"; }
ghost list

6. Coolify said my settings already existed

Coolify reads the ${VARIABLES} in a compose file and creates each one, empty, when you add the service. Setting them through the API with POST then returns 409, because they already exist. PATCH updates them instead.

7. Sign in sent nothing

I tried to sign in to my own blog as a reader and waited for a code that never came. Ghost doesn't email addresses it doesn't know, so nobody can use the form to find out who's a member. My owner login is a staff account, not a member account. Sign up worked first time.

Backups

Every six hours a backup job dumps the database, copies Ghost's content folder, and sends both offsite. I also tested a restore. I loaded the latest dump into a fresh MySQL container and checked the post count matched the live site.

What next

Next I'll write up more of the hardware and agent work I've been doing. If you're setting up something similar and hit a problem I haven't covered, leave a comment and I'll add it here.