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.

Reach is the right word

I was exploring the GitHub website, my profile page to be exact. Here’s my GitHub activity panel for 2026 so far. Apart from the commits from the Micro.blog auto-archiving process, this is what it shows, with everything beginning on January 1st: the contributions coming from my vibe activities. This represents nearly 8 commits per day, every day, resulting in seven custom-built web applications.

Looking back on the past six months, I cannot believe how far I’ve come with all this. If I could choose one word to summarize what generative AI and vibe coding have done for me, it would be “reach”. I have achieved goals that would otherwise have required years of coding experience. The only thing I had was years of end-user experience and a clear idea of what would be fulfilling.

Excited to see what the next six months of 2026 have in store.

The Numeric Citizen Analytics Dashboard Experiment

This morning, I delved into integrating Craft with Claude Cowork. Using the Craft MCP connector, I built a dashboard that sources data directly from Craft tables. This setup generates a Live Artifact in Claude Cowork, which dynamically updates as more data is added. Although Lovable could have been used to achieve this, the absence of custom domain support in its free tier led me to choose Claude Cowork, which offers more flexibility for my needs.

I’m not planning to go any further with my Lovable experimentation. The reason? On the free plan, custom domains are not supported. It’s a complete deal-breaker. Plus, the free tier doesn’t provide enough credits to build something meaningful, and you have to wait 24 hours before getting more. Too bad, it was somewhat promising. Pass. 😔 The good thing is that I’m focusing on Vercel for hosting web apps like mine.

Coming Soon

One of the first things I’m going to do when I come back home is to deploy my new blog visual theme built entirely with Claude Code. It’s ready. I’m anxiously awaiting for the moment I put that project behind. Next, I’ll spend some time on Who Is Numeric Citizen website design and hosting solution (I’m thinking of leaving Chillidog Hosting hosting service to go with Elements’ built in solution instead).

I realized I forgot to clearly state the design goals before starting to build this custom theme for Micro.blog. They became clearer as I progressed and explored how Hugo and Micro.blog work, especially with assistance from Claude AI, and as I encountered various challenges. Here are the goals: a) I want a theme that stands out and doesn’t resemble typical Micro.blog blogs. b) I aim to minimize the use of external plugins, ensuring that all functionality is integrated within the custom theme. c) I want the same theme to be usable on more than one blog (I have two). Stay tuned for more news.

Takeaways From Building a Custom Micro.blog Theme Using Claude Code and Claude AI

Building a custom visual theme for Micro.blog presents unique challenges at the intersection of web design, static site generation, and AI-assisted development. This blog post captures practical insights and lessons learned from creating a custom theme with Claude Code and Claude AI, including key considerations on Hugo compatibility, plugin conflicts, and theme architecture.

  • Claude AI isn’t well-trained on Micro.blog architecture and dependency on Hugo static website generator. Claude doesn’t make a clear distinction between Hugo’s capabilities and Micro.blog’s unique features.
  • Plugins are a challenge because they might inject conflicting formatting instructions into your custom-built theme. It’s hard to debug.
  • Plugins can conflict with each other.
  • Don’t use the latest Hugo version (0.158) and stay on 0.117 if you use many plugins. By removing low-value plugins, you increase your chance of using version 0.158.
  • The workflow for updating a GitHub repo hosting your custom theme to the deployment on The process of Micro.blog is tedious because it’s manual and slow, especially for large websites and Hugo rendering engine. Hugo 0.158 could help, unless it conflicts with one plugin that you need.
  • More than ever: less is more. Keep it simple, and problems will be kept at bay: removing low-value plugins can make a big difference.
  • Test the website after each plugin removal, force a rebuild of the entire site to cleanup things. It’s slow but it might help debug later.
  • If a plugin introduces support for smart code in blog posts, after removing that plugin, Hugo will generate errors. These blog posts need to be either updated or deleted.
  • If the custom-built theme covers a specific design area, like a /photos page, don’t use a plugin touching the same area, prioritize the custom-built theme and skip the plugin. The idea is to have a self-contained custom theme.
  • Stay away from abandoned-ware plugins.
  • Prefer adding stuff in the custom theme instead of relying on an external plugin, so the theme is self-contained.
  • The lower the number of plugins, the better the chance you can use the latest version of Hugo.
  • Using the custom.css functionality, which helps externalize and override the custom-theme’s styling, speeds up testing different styling options.

Creating a custom Micro.blog theme using Claude AI revealed both the power and limitations of AI-assisted development in specialized ecosystems. While the tools provided invaluable support, the real breakthroughs came from hands-on debugging and understanding the unique interactions between Hugo, Micro.blog, and plugins. These takeaways form the foundation for a more robust theme architecture—one that will continue to improve with future iterations.

I’m still not done with my design. As I progress, you can visit my test blog.

One positive side effect of this custom visual theme that I’m working on is that I’m cleaning up this plugin’s mess on my blog. I have way too many with little added value for the readers.