Teaching My CLI to Blog For Me
There's a particular kind of laziness that loops back around to being productive. It's the kind where you spend three hours automating a task that takes five minutes, then feel like a genius every time those five minutes happen without you. This is a story about that kind of laziness.
The Problem Nobody Asked Me to Solve
I set up a Ghost blog last week. Dark theme, purple accents, a tagline about things that go moo in the night - the whole aesthetic. One post in and I was already dreading the second one. Not the writing itself, but the context switch. I'd just spent an hour wrangling firewall rules or migrating a service, everything still fresh in my head, and then I'd have to open a browser, navigate to the admin panel, stare at a blank editor, and try to remember what I actually did and why.
By the third time I thought "I should write about this," and then didn't, I knew the real project wasn't whatever I'd been configuring. It was eliminating the gap between doing the thing and writing about the thing.
The Lightbulb Moment
My AI assistant was right there. It had watched me fumble through every SSH session, every config tweak, every "wait, why is this not working" moment. It literally had the entire session in context. Why was I going to retype all of that into a blog editor?
What if I could just say /blog-post and have it draft something from the session we just had? And what if it could publish it directly, without me ever touching the Ghost admin panel?
Two problems to solve: teach it how to write like a human who runs a homelab, and give it the keys to Ghost.
Giving the Robot a Blog Login
Ghost has an Admin API that speaks JWT, which is a fancy way of saying "here's a token, let me in." I needed a Custom Integration - basically an API key that Claude Code could use to create posts programmatically.
I could have done this through the Ghost admin UI like a normal person, but the browser extension wasn't cooperating, so I did what any reasonable person would do and created it directly in the database:
INSERT INTO integrations (id, type, name, slug, created_at, updated_at)
VALUES ('630e...', 'custom', 'Claude Code', 'claude-code', NOW(), NOW());One integration and one API key later, Ghost was ready to accept posts from the command line. The missing piece was an MCP server - a bridge that lets Claude Code talk to external APIs through native tools instead of raw curl commands. I wired up @densh/ghost-mcp-server in the Claude Code config, pointed it at the blog, and suddenly my terminal had create_post, update_post, and search_posts as first-class tools.
Or so I thought.
The Part Where It Didn't Work
First publish attempt: title showed up, content was completely empty. Just a blank post with a name. Very on-brand for a blog about automation going wrong, but not what I was going for.
Turns out the MCP server had a bug. When you send HTML to Ghost's Admin API, you need to include source: "html" so Ghost knows to convert it to its internal Lexical editor format. Without that flag, Ghost just... ignores the content entirely. No error, no warning. Just vibes and an empty page.
But here's the thing that made it properly annoying to debug. The source parameter isn't part of the post body - it's a query parameter. The Ghost Admin API SDK's add() method takes two arguments: the post data and an optional query params object. The MCP server was only passing one.
The fix was surgical:
export const createPost = async (params) => {
try {
const queryParams = {};
if (params.html) {
queryParams.source = 'html';
}
const post = await ghostApi.posts.add(params, queryParams);
// ...
}
};Three lines. That's it. The kind of bug that takes thirty seconds to fix once you know what's wrong, and an embarrassingly long time to figure out when you don't. And then you have to restart the MCP server, which means restarting Claude Code, which means you lose your conversation context, which means you're explaining to your AI assistant what you were doing five minutes ago.
We did this dance three times before the fix stuck.
Bug Number Two: The Phantom Editor
With publishing working, I tried to update the post to add the PR link. Ghost came back with "Saving failed! Someone else is editing this post." Nobody else was editing anything. This is a blog with one author and zero readers.
Time to read Ghost's source code. Deep in @tryghost/bookshelf-collision, I found the culprit: Ghost uses updated_at as an optimistic concurrency check. When you update a post, Ghost compares the updated_at you send with what's in the database. If they don't match, it assumes someone else modified the post and throws a collision error.
The MCP server's updatePost was setting updated_at to new Date().toISOString() - the current time. But the post in the database had the timestamp from when it was created or last modified. Current time will never match the database value. Every single update would fail.
The fix: fetch the post first, grab its actual updated_at, and pass that along. The updatePage function in the same codebase already did this correctly - updatePost just... didn't.
// Fetch current post to get the correct updated_at
const currentPost = await ghostApi.posts.read({ id });
params.updated_at = currentPost.updated_at || new Date().toISOString();Two bugs. Same PR. Both of them the kind of thing that makes you wonder how anyone successfully used this tool before.
The same source: "html" bug affected pages too - createPage and updatePage had the same missing query parameter. All four functions fixed, forked, and submitted upstream: denishartl/ghost-mcp-server#3.
The Skill Itself
With the plumbing finally sorted, the fun part: the slash command. Claude Code has this concept of "skills" - markdown files that act as slash commands with embedded instructions. Mine lives at ~/.claude/skills/blog-post/SKILL.md and it's essentially a creative brief.
The key bits:
/blog-postwith no arguments means "look at what we just did and write about it"/blog-post "topic"means "write about this specific thing"- Always show the draft first. Never auto-publish. I'm lazy, not reckless
- Write like a person, not a tutorial factory. Include the dead ends. Nobody learns from a story where everything worked the first time
That last point was the one I cared about most. There's a particular flavour of AI-generated blog post that reads like it was written by someone who has never touched a keyboard in frustration. I wanted the opposite - something that sounds like it was written at midnight after finally getting the thing to work, still riding the high.
The Meta Part
Here's where it gets recursive. This post - the one you're reading right now - is the test run. The skill was built, the MCP server was configured (and patched, and restarted, and patched again), and then I said "right, let's test it with this session." The session where we built the thing that writes about sessions.
It's blog posts all the way down.
What's Next
The real test is whether this actually makes me blog more. The friction is gone. The context switch is gone. At the end of a session, it's one command and a quick review before hitting publish.
If the frequency of posts on this blog picks up, you'll know it worked. If it doesn't... well, at least I had fun building it.