Making Eight Years of Blogging Searchable

I have been publishing on Ghost since October 2018. That is 677 posts, and until this week I had no good way to ask questions about any of it. I could search my own site, which matches titles. I could remember roughly what I wrote and when, which works for the last few months and fails completely for 2019.

What I wanted was to ask “what have I written about X” and get a real answer drawn from the full text of every post. Ghost offers an API but no MCP endpoint, so the obvious path was not available. Here is what it actually took, including the parts I got wrong.

Starting with what already exists

There are several community-built Ghost MCP servers. I tried one that supports the read-only Content API, and it worked immediately. Posts, pages, tags, authors, all queryable in conversation.

For about an hour I thought I was done.

The thing that quietly breaks everything

Ghost 6.0 removed support for ?limit=all and imposed a maximum page size of 100 on every API endpoint. That change is documented and reasonable, since unbounded queries hurt large sites.

The problem is how it fails. Requesting ?limit=all does not error. It returns 100 items and says nothing about the other 577.

I found this by running a curl command to measure my archive size and getting back exactly 100 posts. A suspiciously round number for a blog running since 2018. The same cap applies to any MCP server built on the Content API, which means every answer I had been getting was drawn from my most recent fourteen months of writing while presenting itself as complete.

That is worse than an error. An error tells you to fix something. This just quietly narrows your world.

There is a second limitation underneath it. Ghost’s NQL filter language has no like operator and no partial matching, and content fields are not filterable at all. You can retrieve plaintext in a response, but you cannot query against it. So even with correct pagination, there is no way to search article bodies through the API.

Ghost’s own site search works around this the same way I ended up doing: download the corpus and search it locally.

The local copy

The design that followed is unglamorous and took an afternoon.

A Postgres table holding one row per post, with the full body in a plaintext column and a generated tsvector column indexed with GIN. An n8n workflow pulls the archive weekly with explicit pagination, seven pages at 100 posts each with a one second delay between them, and upserts everything in a single round trip.

The schema is boring on purpose:

create table ghost.posts (
  id            text primary key,
  slug          text not null unique,
  title         text,
  plaintext     text,
  url           text,
  tags          text[],
  published_at  timestamptz,
  updated_at    timestamptz,
  synced_at     timestamptz not null,
  fts tsvector generated always as (
    setweight(to_tsvector('english'::regconfig, coalesce(title, '')),     'A') ||
    setweight(to_tsvector('english'::regconfig, coalesce(plaintext, '')), 'B')
  ) stored
);

The setweight calls mean a title match outranks a body match. Without them, a post that mentions a term once in passing can outrank the post named after it.

Two details in the sync are worth stealing if you build something similar.

Full reconcile, not incremental. My first instinct was a watermark on updated_at, which is what I use for feed-based syncs. It is the wrong tool here. It would save six HTTP calls a week and would never detect a deleted or unpublished post. Pulling everything self-heals.

A guard on the delete step. Every row gets stamped with the run’s timestamp, and anything older gets pruned, which is how deletions propagate. But the prune only runs if this pass fetched at least 80% of the rows already in the table. Without that check, one bad fetch silently wipes eight years of archive. This is the kind of thing you want in place before you need it.

I was wrong about the size

I nearly did not build this because I assumed the archive would blow past my database provider’s free tier. That assumption was off by roughly two orders of magnitude.

677 posts come to 3.2 million characters of body text. The table with its full-text index totals 11 MB against a 512 MB limit. I would need something like 32,000 posts to be in trouble.

A related surprise: my recent posts average about 8,400 characters while the archive average is 4,700. My writing has roughly doubled in length over eight years. I did not know that about myself before running the query.

The more useful lesson is that on serverless Postgres, storage is almost never the binding constraint for text. Compute hours are. Every query wakes the database for a minimum billing interval regardless of how fast it runs, so a scattered handful of ad-hoc searches costs more than the weekly bulk write does. Worth knowing before you optimise the wrong thing, which I did for a while.

The part I did not anticipate

With the table populated, I assumed I was finished. I was not, and the gap here is the least obvious thing in this whole project.

Nothing routes the question.

Each time you ask something, the assistant picks tools based on their names and descriptions. It sees a Ghost MCP whose tools are explicitly labelled as browsing and reading blog posts. It also sees a generic SQL tool pointed at a database it knows nothing about. Ask “what have I written about X” and the match is overwhelmingly toward the Ghost tools, which return the 100 most recent posts and no warning.

Building the better data source does not cause it to be used. You have to say so, explicitly, somewhere persistent.

I solved this with a Skill: a set of instructions that triggers on phrasings like “what did I write about X” and states the routing rule as a prohibition rather than a preference. Do not use the Ghost browse tools for search, here is why they fail, here is the table and the query shape to use instead.

The skill also pins the retrieval pattern, which matters more than I expected. Search first with ranked snippets, then fetch full bodies for only the three to five posts that are actually relevant. And read whole posts, never fragments. A paragraph mentioning a product without the surrounding argument is exactly how you end up having your own past position mischaracterised back to you.

I conflated these two for a while, and the distinction is worth being precise about.

What I built matches words and their grammatical variants. Ask about “Apple Intelligence” and it finds posts containing those words. Ask “how has my thinking about AI subscriptions evolved” and it will find something, because the words appear somewhere, but it has no way to surface a post that discusses the same idea in entirely different vocabulary.

Semantic search needs embeddings, a vector column, and a model called at both index and query time. That is a real layer of work.

I deliberately did not build it. Full-text over 677 posts with English stemming handles the lookup questions well, and lookup is most of what I actually ask. If the thematic questions come back consistently thin, the embeddings layer drops into the same table without rearchitecting anything. Building it up front would have been solving a problem I had not yet confirmed I have.

What it does not do

Four posts have empty bodies. The unauthenticated Content API cannot see members-only content, so gated posts arrive with a title and nothing else. They are findable by name but their text is not indexed. Fixing that means switching to the Admin API with JWT signing, which is not worth it for four posts.

The copy is up to seven days stale. Irrelevant for archive questions, relevant if I ask about something published yesterday.

And the whole thing is lexical. When a thematic question comes back thin, the honest answer is that the tool is not built for it, not that the archive is silent.

Was it worth it

An afternoon, one database table, one scheduled workflow, and one skill file.

The first real test was asking what I had written about GuruShots, a photography game I covered in late 2018. Two posts came back with dates and links. Through the Ghost API alone, that question has no answer at all.

That is the whole point. Eight years of writing is only an archive if you can reach into it.

Solving Broken Images When Exporting Craft Documents

The Problem

When exporting articles from Craft to Ulysses and Micro.blog, images referenced by temporary URLs quickly become broken links. Craft hosts images with public URLs that expire within days, leaving your published posts with missing images. This automation workflow solves that problem by re-hosting all images on Micro.blog before publishing.

How It Works

The workflow is triggered when you request to publish a Craft document to Micro.blog. Here's what happens behind the scenes:

  1. A request is send in Claude with the article title to publish
  2. The n8n automation searches my Craft space and uses a scoring algorithm to find the exact document (preventing false matches from body text mentions)
  3. The document's content is converted from Craft's block format into clean HTML, preserving headings, lists, formatting, quotes, code blocks, and more
  4. The workflow scans for all image URLs pointing to Craft's backend hosting
  5. Each image is downloaded from Craft and immediately re-uploaded to Micro.blog's permanent media storage
  6. All image references in the post are updated to point to the new, stable Micro.blog URLs
  7. The article is published as a draft to Micro.blog for review before going live, which is a manual process

Key Technical Features

Smart Document Matching

The search algorithm scores results by how closely the document title matches the request. Exact matches score 100, partial matches score lower, and body-text mentions are heavily penalized. This ensures I get the right document even if multiple articles discuss similar topics. The search is done via Craft API endpoint.

Rich Content Support

The conversion preserves all the formatting: paragraph styles, heading levels (h1–h4), bullet and numbered lists, blockquotes, task checkboxes, strikethrough text, code blocks, horizontal rules, and rich link bookmarks. Consecutive list items are automatically grouped into single lists.

Graceful Image Handling

If an image fails to upload (for example, unsupported formats like AVIF), the system keeps the original Craft URL in your post rather than failing entirely. This ensures the article publishes even if one image has issues.

Manual Review

All posts are created as drafts in Micro.blog. I can review the final result, check that images loaded correctly, and make edits before publishing it live.

Workflow Specifications

  • Trigger: Webhook at craft-to-microblog (available in my n8n instance wirh MCP endpoint enabled)
  • Input: Simple JSON with the article title to publish
  • APIs used: Craft's document search and block retrieval, Micro.blog's Micropub standard API
  • Typical execution: 4–6 seconds (image uploads add ~1.5 seconds)
  • Output: Draft post URL, preview link, and edit URL from Micro.blog

Why This Matters

This automation fills a gap in the content publishing process. I can craft and organize my articles in Craft, a versatile and attractive writing environment, and then publish them to Micro.blog without losing images or manually fixing broken links. I use a similar method to publish new editions of the Ephemeral Scrapbook newsletter, which relies on a separate n8n workflow to handle Ghost CMS-specific requirements.

Building an Automated Publishing Pipeline: From Craft to Ghost

For months, I’ve been publishing my weekly newsletter, The Ephemeral Scrapbook, using a manual process: write in Craft, export to Ulysses, copy to Ghost, reformat everything, add images, fix formatting issues, and finally publish. It worked, but it was tedious and time-consuming.

Today, that process is fully automated. Here’s how Claude and I built it together.

The Challenge

My workflow had become a bottleneck:

  • Writing newsletters in Craft Docs (my preferred writing environment)
  • Exporting to Ulysses as an intermediary step
  • Manual copy/paste to Ghost (my publishing platform)
  • Reformatting all the markdown and HTML
  • Dealing with Craft-specific formatting that Ghost didn’t understand
  • Adding metadata like excerpts and tags manually

I wanted automation, but I also wanted to understand the infrastructure I was building. That’s where working with Claude became invaluable—not just executing commands, but learning and iterating together.

The Solution: n8n Workflow Automation

We decided to build an n8n workflow that would:

  1. Search for a document in Craft by title
  2. Fetch all the content blocks
  3. Transform Craft’s markdown/blocks into clean HTML
  4. Publish to Ghost as a draft
  5. Return confirmation with the post URL

Simple in concept, complex in execution.

The Journey: Key Milestones

Milestone 1: Understanding the Architecture

Challenge: Should we use multiple workflows or one unified workflow?

Decision: One end-to-end workflow that handles everything from search to publish.

Learning: Simplicity wins. Rather than orchestrating multiple workflows, we built one cohesive pipeline that’s easier to debug and maintain.

Workflow nodes:

  • Webhook (trigger)
  • HTTP Request (search Craft)
  • HTTP Request (fetch document)
  • Code (transform to HTML)
  • HTTP Request (publish to Ghost)
  • Respond to Webhook

The Iterative Building Process

One of the most important decisions we made was to build and test incrementally. Rather than assembling the entire workflow at once and hoping it would work, we added one node at a time, testing after each addition.

The Testing Cadence:

  1. Add Webhook → Test: Confirmed the webhook received the query parameter correctly
  2. Add Search Node → Test: Verified we could find the document and get the correct document ID
  3. Add Fetch Node → Test: Checked that we retrieved all 54 blocks of content with the proper nested structure
  4. Add Code Node → Test: Validated the HTML transformation, checking for clean output without Craft tags
  5. Add Ghost Publish Node → Test: Ensured the post was created as a draft with all content intact
  6. Add Response Node → Test: Confirmed the workflow returned post details back to Claude

Why This Mattered:

Each test revealed issues that would have been much harder to debug in a complete workflow:

  • The search node helped us understand Craft returns multiple matches (we needed the first result)
  • The fetch node showed us the nested structure (parent document → edition page → content blocks)
  • The code node iterations caught formatting issues (<callout> tags, ## symbols, <highlight> tags)
  • The Ghost publish node revealed we needed the ?source=html query parameter

By testing at each step, we could pinpoint exactly where problems occurred. When something didn’t work, we knew it was the node we just added, not some mysterious interaction between distant parts of the workflow.

This incremental approach turned what could have been hours of debugging into a smooth building process. Each successful test gave us confidence to move forward, and each failure was easy to isolate and fix.

Milestone 2: Building the HTML Transformer

Challenge: Craft uses its own markdown dialect with special tags like <callout>, <highlight color="blue">, and markdown headers in text blocks.

What we built: A comprehensive JavaScript transformation engine that:

  • Removes Craft-specific tags (<callout>, <highlight>)
  • Converts markdown formatting (bold, italic, links, code)
  • Processes different block types (text, headers, quotes, code, images, videos)
  • Handles rich URL blocks (YouTube embeds)
  • Preserves anchor links for internal navigation
  • Generates proper HTML for Ghost’s Lexical editor

Key functions:

  • markdownToHtml() - Converts inline markdown to HTML
  • processBlock() - Handles each block type (text, image, richUrl, code, line, etc.)

Milestone 3: Testing and Validation

The Process:

  • Test with real content (Edition 2025-52 with 54 blocks)
  • Verify HTML output in Ghost’s editor
  • Check for Craft formatting artifacts
  • Confirm all sections, videos, quotes, and images are preserved

Quality Checks:

  • ✅ No <callout> tags
  • ✅ No <highlight> tags
  • ✅ No ## symbols in headers
  • ✅ All YouTube videos embedded correctly
  • ✅ Blockquotes formatted properly
  • ✅ Images included
  • ✅ 9-minute reading time (17,000+ characters)

The Final Workflow

Input: {"query": "The Ephemeral Scrapbook — Edition 2025-52"}

Output: Draft post in Ghost with:

  • Complete HTML content
  • All formatting preserved
  • Clean structure
  • Ready for manual review (add images, tags, excerpt)

Execution time: ~3-4 seconds total

  • Search: 1-2 seconds
  • Fetch: 1-2 seconds
  • Transform: 36-84ms
  • Publish: 600-900ms

The Tools

  • Craft: My writing environment with a powerful API
  • Ghost: My publishing platform with a robust Admin API
  • n8n: Workflow automation platform (self-hosted on DigitalOcean)
  • Claude AI: My pair-programming partner via MCP (Model Context Protocol)

The Result

The workflow is production-ready. My publishing workflow went from 20+ minutes of manual work through Craft, Ulysses, and Ghost to a single command:

“Claude, publish Edition 2026-01 to Ghost”

And it just works. 🎉

Why I Built a Micro.blog Front End?

As recently shared on my blog, I have finished (or mostly finished1) building a simple front end for Micro.blog. This front end, as depicted in the following screenshot, presents the user with a straightforward UI: a title field, a body field, blog post categories, and a Publish button—very focused, with no distractions. It works on desktops and mobile devices. I even added PWA support. But why did I build this?

First, I wanted to dip my toes into Vercel. I’ve recently stumbled upon many posts about web apps built and deployed on Vercel by people claiming no programming experience. Most people were using Claude AI or Claude Code to describe their app and deploy it to Vercel. Some apps were impressively designed and functional. Yet, I thought it wasn’t that easy and required a lot of technical knowledge. I was intrigued. I was “mostly” wrong.

I’ve been using Claude AI since mid-December, in conversational mode, for different tasks, including getting explanations on building apps on Vercel and other platforms. I’ve been looking for small project ideas since then. Building a simple front-end to Micro.blog quickly became the perfect test. Micro.blog offers a simple API for many things. Using Claude and the API documentation, I asked Claude AI whether it was possible to build a simple UI for posting on Micro.blog. Sure enough, it was. My initial prompt describing the envisioned app follows:

Let’s build a web app hosted on Vercel that lets me to write blog posts for Micro.blog. The form will include only two text fields: a blog post title and the blog post text itself. Include a character count that will update as I type. Maximum of 5000 characters. The web page should include a title “Microblog Poster", centered.

Micro.blog supports Markdown, so the blog post text field should support it too.

The authorization token should be stored in an environment variable named “microblog_token” which I will provide once the project is created on Vercel.

I will use a GitHub repo, which should be named after the application name: “(redacted)” where the app will use the full URL: https://(redacted)

Provided that Micro.blog supports draft posts as exposed in the Micro.blog APIs, a toggle named “Draft” should be on the web form and be off by default. When enabled, this means I can send the blog post to Micro.blog but with a draft status. Otherwise, the blog post is published.

The initial state of the web app is to list all available blog post categories as a series of checkboxes, all off by default. You will need to retrieve possible blog post categories during the initialization phase. A blog post can have more than one category selected or none. This list of checkboxes should be left-aligned. The category list should be saved in the browser’s local storage and initialized on the first invocation of the web app.

The form will contain a button “Publish” centered horizontally (like all the other UI elements, except the toggle underneath the Publish button which should be left aligned. Once clicked, if the post operation is successful, add a small banner (centered) telling me the operation was successful with an appropriate message.

For a non-draft post, after hitting Publish, the form should display a clickable link to the blog post’s final URL. For the draft post, you should display the clickable link to the draft post instead.

Images or any other attachments are not needed.

You can look at micro.blog API documentation in the following URLs:

For reading data from Micro.blog service: https://help.micro.blog/t/json-api/97

For posting to Micro.blog service: https://help.micro.blog/2017/api-posting/

After a few hiccups and errors, it eventually worked. I had to install GitHub Desktop on My Mac as well as Visual Source Code, but I eventually realized Claude AI wasn’t optimal. I ultimately switched to Claude Code to iterate on the initial release. My experience was so much smoother. I do experience so weird issues with GitHub, but it seems without impacts on the deployment.

So, building the app requires a GitHub repository for holding the source code. Vercel connects to my GitHub repo, and as soon as a new commit is made, a new app deployment happens; It’s all automatic. One important thing to know: a project environment variable2 to hold the Micro.blog app token is needed before trying the app for the first time.

My first try mainly worked as expected. I made sure to have a draft mode available in the UI so that I don’t mess up my timeline with test posts. Once the app is deployed and available for use, any modifications are made through prompting Claude Code on my local machine. Code changes are pushed to GitHub on demand. It takes a few minutes for a new iteration to be available for testing.

If you have any questions or comments, feel free to post them, and I’ll do my best to answer them to the best of my knowledge.

One more thing: Vercel is free to use in my case because my app is relatively lightweight. Lastly, one benefit of building my app is that it will circumvent a design issue with Micro.blog’s post editor on the web: the title field and categories aren’t listed by default. I find this to be annoying. My app shows them. I’m happy with that.


  1. Software is never finished! ↩︎

  2. It’s the most secure way to keep that token away from unauthorized eyes. ↩︎

Screenflow + Screen Studio

This week, I decided to add Screen Studio to my YouTube recording workflow. Screen Studio brings simplicity for recording more dynamic screen sequences. Everything Screen Studio does can be done in ScreenFlow, but it requires significantly more manual work. But Screen Studio has a severe limitation: we cannot merge recorded sequences. That’s why I’m keeping ScreenFlow.

In summary, my workflow proceeds as follows: individual sequences are recorded in Screen Studio, exported as .mp4 files, and then imported into ScreenFlow to be assembled into a complete video sequence, which includes the intro and outro sequences with background music. Chapter markers are also added in ScreenFlow before final export. Finally, video subtitles are created using Whisper Transcription and exported as an .srt file, which is compatible with YouTube Studio.

Overall, I do spend more time on video rendering, but I think it’s worth it. Lastly, disk space consumption is way higher than before, with 2x-3x more space consumed than with ScreenFlow alone. Ouch.

One more thing: Screen Studio is the only app that makes the M4 Mac mini fan run at full speed. I wonder if Screen Studio uses Apple Metal technology?

Behind the Scenes of the “On Apple Failures" Writing Project

I’ve long wanted to write an article like this one. However, as Apple continued to add to its list of failures, poor Apple, I kept pushing back the deadline. This summer, however, the timing was right. Here’s what I did differently this time.

A few months ago, I started gathering a list of Apple’s failures in a Craft document. I wanted to cover the period from when Tim Cook took over as Apple’s leader, following Steve Jobs’ passing, up until now. For each failure, I wrote a summary that included a description, some context, and a list of potential collateral damage to Apple’s reputation and brand. Then, I turned to ChatGPT for help.

I set up a space to upload files, one for each failure, and began a separate “conversation” to explore areas I hadn’t already covered. This process took a few weeks. I’d revisit one of the failures every other day and continue the conversation until I was satisfied.

Next, I started creating a first draft based on all the conversations in this ChatGPT writing project. It took many prompts to refine the base content before exporting it as a Markdown file. Then, I set up a new conversation, uploaded the file, and asked ChatGPT to continue working on the article, this time in canvas mode. It took many more iterations and manual edits to finish around 85% of the writing process.

After that, I imported the text back into Craft and kept adding relevant facts and comments. As I went along, I started searching for photos that could illustrate each section. I used Kagi Search for all my image searches. For each photo, I wrote a brief caption that gave a unique perspective on the failure it was highlighting.

It’s also worth noting the role of Grammarly. As I finished writing in Craft, I used Grammarly to rephrase parts I didn’t quite like. I ended up keeping around half of Grammarly’s suggested rephrases.

In summary, generative AI was a significant contributor to my writing, either through the use of ChatGPT or with Grammarly’s constant supervision. I’m not sure how I should feel about this, nor how you should think about it, now that you know. Make no mistake, the original writing project idea is mine. The selection of Apple’s failures is mine. The starting point of research is mine. The selection of images is mine. Supervision of ChatGPT’s contribution is mine. But is the final product mine? Anyway, complete transparency, now you know.

The Future of Writing? Testing ChatGPT Canvas for a Specific Use Case

In October 2024, OpenAI launched ChatGPT Canvas, designed to enhance the writing experience. Before ChatGPT Canvas, one writing approach using ChatGPT involved compiling these references into a ChatGPT project, then starting the writing process by using a first prompt, followed by another, and so forth. With ChatGPT Canvas, the approach promised to be more user-friendly, more interactive, more natural.

I wondered which writing project I could use to test this new conversational experience. For a long time, I’ve wanted to write about the data protection and privacy features offered by Apple’s ecosystem for iPhone and Mac users. I had already started gathering references from Apple’s support website and elsewhere on the internet. It was the perfect use case for this experiment.

ChatGPT Canvas starts off with a prompt, as usual, but now you include the term “canvas” in the request. The rest of the experience unfolds in an interface split into two sections: on the left side is the writing conversation, and on the right is the evolving draft. ChatGPT Canvas lets you interactively edit sections of text by selecting them first before requesting modifications. It’s highly interactive; somewhat like working with an editor in real-time. It’s very stimulating.

With “Protecting Your Digital Life: Privacy and Security Measures for Apple Users”, I had the opportunity to fully test this experience with my previously mentioned article project. ChatGPT was central to this writing project, but I also revised certain parts by removing or adding some content and by adding details that ChatGPT didn’t consider important enough to include. The result isn’t perfect, but it’s definitely better than what I would have written from scratch. I hope you enjoy reading it, and that you find the article informative. PS, the diagram is mine, not ChatGPT’s.

Combining Craft And Things 3 For My Writing Projects

This article is about how I’m using Craft and Things 3, which is behind any short or long article I share online. Here is what happens when I get a new post idea.

  1. In Things 3, Create an entry and set priority and desired or expected date of publication if known.
  2. In Craft, I create a new document, set the title and then copy the document’s deeplink to the clipboard.
  3. Still within Craft, I move the newly created document in the appropriate folder.
  4. Still within Craft, I optionally update my private creator dashboard document.
  5. Back to Things 3 and I paste the deeplink in the note field. It’s handy to jump from Things 3 to Craft with a single tap.

At this point, I can start my research, writing and editing of my article or blog post in Craft. Now, here is what happens after publishing my article:

  1. Mark the to-do item as done in Things 3.
  2. I update my private creator dashboard document by converting my deeplink to a new a permalink that I put in the Recently Published section.
  3. I monitor the appropriate RSS feed for quality control. See this article about subscribing to my own RSS feeds.

There you have it. Craft plays a central role in My Blogger Workflow. This blog post exposes what happens at the beginning and at the end of a new post idea. I hope you enjoyed it and maybe learned something.