How we control our Ghost publication programatically

A field-guide for AI engineers who want their agents to ship Ghost content straight from git.

AI engineers keep asking how to let agents ship Ghost posts safely. Here’s the exact workflow we just wired up for headcount0.com, battle-tested by deleting the stock “Coming soon” splash and publishing this article without touching the Ghost admin UI.

1. Treat content as code

content/
  posts/
    YYYY-MM-DD-slug.md
  pages/
  snippets/
  assets/

Every Markdown file owns its metadata via YAML frontmatter. Agents can’t forget what Ghost needs, because title, slug, status, tags, authors, newsletter, and updated_at all live beside the prose. When Ghost sends back new timestamps or IDs, we write them directly into the file so the next edit never collides.

2. Convert Markdown ↔ HTML deterministically

Ghost’s Admin API ingests Lexical, but accepts HTML if you pass { source: 'html' }. We use:

  • markdown-it → renders Markdown into HTML that Ghost converts to Lexical.
  • turndown → pulls HTML back into Markdown during syncs.

This round-trip gives agents a familiar writing surface while keeping the remote editor happy.

3. Ship a tiny CLI the agents can trust

scripts/ghost/index.mjs exposes three verbs:

# Pull an existing post into content/
bun scripts/ghost/index.mjs pull coming-soon

# Push local Markdown (draft by default)
bun scripts/ghost/index.mjs push content/posts/2025-11-11-example.md

# Publish immediately (with optional newsletter + segment)
bun scripts/ghost/index.mjs push --publish content/posts/...md

# Delete by slug when you need a clean slate
bun scripts/ghost/index.mjs delete ghost-cli-test

Under the hood we reuse a single GhostAdminAPI client configured via GHOST_ADMIN_URL + GHOST_ADMIN_API_KEY. Each push loads the Markdown file, converts it to HTML, sends tags/authors/newsletter settings, and always includes the latest updated_at to satisfy Ghost’s optimistic locking.

4. Make tests part of the contract

AI agents behave better when they have feedback loops:

  1. bun test – unit tests lock down Markdown parsers and Ghost payload mappers.
  2. bun run ghost:test – smoke-tests the Admin API credentials by browsing one post.
  3. bun scripts/ghost/index.mjs push --publish ... – integration test that writes real content.
  4. curl https://headcount0.com/<slug>/ – end-to-end proof that the public site serves the new bits. We grep for a known headline before declaring victory.

If any stage fails, we stop and fix the script instead of shipping a half-synced post.

5. Run the whole pipeline (copy this)

# 0. Ensure env vars are set
export GHOST_ADMIN_URL=https://<yoursite>.ghost.io
export GHOST_ADMIN_API_KEY=<admin-key>

# 1. Pull latest reference content
bun scripts/ghost/index.mjs pull existing-slug

# 2. Write or edit Markdown under content/posts/
cp content/posts/_template.md content/posts/$(date +%F)-my-post.md

# 3. Run unit tests
bun test

# 4. Publish
bun scripts/ghost/index.mjs push --publish content/posts/$(date +%F)-my-post.md

# 5. Verify live
slug=$(date +%F)-my-post
curl -s -o /tmp/$slug.html -w "%{http_code}\n" https://headcount0.com/$slug/
grep -q "$(yq '.title' content/posts/$(date +%F)-my-post.md)" /tmp/$slug.html

Everything in that sequence can be executed by an agent (or a CI job) because it has no hidden steps and no human-only secrets.

6. Lessons for other AI engineers

  • Keep the Ghost schema close to the content. When frontmatter mirrors Admin API fields, agents don’t guess.
  • Always round-trip tests. Convert Markdown to HTML and back in code so you notice when Ghost introduces new block types.
  • Use curl as the final gate. API success ≠ published success; the CDN might still be warming.
  • Prefer deletion over silent edits while bootstrapping. We deleted “coming soon” and our earlier CLI smoke test before publishing this piece to avoid confusing visitors.

This article itself was authored, published, and verified entirely via the workflow above. Fork it, tweak the directory layout to match your team, and let your AI agents handle Ghost like any other deploy target.