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.

Week 35 Summary (Aug 23 - Aug 29, 2026)

A week bookended by writing. It opened with On Integrating Quick Reads Into My Digital Ecosystem going out the door on Sunday, and closed with three consecutive mornings on “I’m proud of my failures” article as a creator alongside the next issue of the ephemeral scrapbook newsletter. Underneath it all was a sustained hunt for better reading sources — chasing an RSS feed on understandingwar.org, adopting Bubbles.town, and opening a trial of Standard Reader. AI experiments ran in parallel almost daily: Whisper transcription on the M4 Mac mini, scheduled tasks in the Claude cloud, Claude in gateway mode against Ollama, and an attempt to make Ghost CMS content searchable through Claude — most of them reaching a verdict rather than staying open. Maintenance took a real share of the week too, with n8n instability, a manual MCP config merge, and a refreshed ecosystem diagram, while a consistent editorial stance on engagement-driven platforms surfaced twice in conversation.

Week 34 Summary (Aug 16 - Aug 22, 2026)

A week with one clear centre of gravity: the On Integrating Quick Reads Into My Digital Ecosystem piece, picked up on five consecutive mornings — continued, diagrammed, re-diagrammed, then restarted from scratch on Thursday. Around it, Claude AI shifted from assistant to workbench: documenting MCP endpoint dependencies, connecting Neon Postgres, refreshing n8n workflow docs, and by week’s end turning bank statements into a financial dashboard for retirement planning. Reading was front-loaded into early mornings and skewed toward AI commentary and Apple’s App Store fight, with a detour into Craft Agents. Infrastructure got quiet attention too — an n8n upgrade to 2.35.4, and a failed Micro.blog highlight deletion that turned into a support ticket. Momentum tapered after Wednesday: Friday came down to a single entry, and Saturday was blank.

Week 33 Summary (Aug 09 - Aug 15, 2026)

This is my first week summary. It is built automatically with AI by consuming my manually-written notes Craft Daily notes where I document most of my activities. Then, I do some touch ups before publishing here.

A week centered on the Quick Reads read-later service, which threaded through nearly every day — from drafting a review and appreciation piece to extracting text highlights, weighing data portability, and wiring a Quick Reads section into the personal Dashboard. Automation was the second big theme: the newly discovered Brrr push-notification app went from curiosity on Tuesday to being integrated into four n8n workflows by Saturday, while Claude Code helped tidy the bookmarking app’s documentation and fix its code. Two blog posts shipped — ‘The digital toxicity’ on the return to the web for app development, and ‘It’s fun to go back’ reflecting on past writing. Reading and watching leaned heavily on AI and industry shifts, spanning antirez on lab risks, the OpenAI/Hugging Face incident timeline, the end of Google Search, and self-hosting LLMs to control token costs. The week opened with continued research for an upcoming iPhone review, focused on the camera control button.

Experimenting with a new visitor card design

I’m currently working on an updated visitor card for my digital space. The following is an updated version, the same design as before but with updated information. It’s done in Keynote. The second is an AI-generated concept for a refreshed design. I’m kind of liking the new structure (my wife, too). I might try to reproduce this in Keynote, but I see a few challenges. Keynote is limited for things like this, and I’m not sure I want to switch to another app.

Numeric Citizen Digital Space visitor card previous designNumeric Citizen Digital Space visitor card new concept design built using AI

I went looking for a reason to use Ollama

I tried to find a place for Ollama in my automation stack. I wanted two things: a lower Anthropic bill, and Ollama running somewhere in my workflows. The first reason didn’t survive contact with the data.

The number

I have a workflow that pulls my Anthropic cost report every morning and posts it to Discord and my Craft daily note. So I checked it.

Thirty days, 56 workflows, every LLM call I make: $7.48. Twenty-five cents a day, already down 60% over that window.

Moving everything off Claude would save about $90 a year. Keeping Claude for work where quality matters brings that closer to thirty.

I standardized most of my stack on Haiku months ago. That’s where the savings already went. I went looking to cut a bill that was already at its floor, because reducing API costs sounds like good engineering whether or not the number supports it.

Ollama Cloud costs more than I spend

Two things I had confused: open-weight models and locally-hosted models are separate choices. I don’t want models running on my Mac. That’s not an argument against open models — Ollama Cloud runs them on their hardware behind a normal API. I’d even set up the credentials back in December and forgotten.

But Ollama Cloud doesn’t bill per token. It’s a flat subscription metered by GPU time, with session limits every five hours and weekly limits every seven days. Pro is $20 a month — nearly three times my current bill, for smaller models.

The free tier might cover my volume. I can’t confirm that, because Ollama has no account usage endpoint. Checking your quota means logging into the web dashboard. Several open feature requests ask for an API; none have shipped. You find out you’re near the ceiling when they email you at 90%.

I went looking to reduce a cost I track with a daily automated report, and the alternative can’t be tracked with one.

The reason that holds up

Cost is out. Privacy doesn’t apply — I’m summarizing my own RSS feeds and my own billing data.

What’s left: I don’t know which of my workflows actually need a frontier model.

I have a dozen-plus workflows making LLM calls. Some summarize articles or pull signal out of a week of notes. Others do arithmetic and pattern-matching dressed up as reasoning. I’ve never tested the difference. Everything points at the same model because at twenty-five cents a day there was no reason to check.

An open model is a cheap way to test that. Not to replace Claude — to find out where the line is. Any task where a small open model matches Haiku is a task I’ve been over-provisioning.

The experiment

I picked the most mechanical node I have: the one that asks four questions about thirty numbers. Trend, spikes, projection. 570 tokens in, no nuance required.

Both models now run in parallel from the same prompt, joined by a merge node so the message builder doesn’t fire twice and double-post. Both answers print back to back, labelled with their model. The Ollama arm fails soft — a rate limit on the test shouldn’t break the report.

One thing I nearly got wrong: I reached for the largest open model first, because it was the name I recognized from the docs. Wrong instrument. A big model will obviously handle a trend summary, and confirming that tells me nothing. If you’re looking for the floor, start at the floor.

If you’re considering the same thing

Spending real money on inference? Do the migration. The math works.

Bill is a rounding error? Then cost isn’t your reason and privacy probably isn’t either. The useful question is which of your automations need a frontier model and which ones you’re over-provisioning out of habit.

I’ll report back either way.

AI made me replace another subscription: Ulysses

I finally cancelled my subscription to Ulysses. Why? I was using Ulysses because it supported Craft’s export document so that I could then publish either to Ghost or Micro.blog. Since the beginning of this year, I’ve created two n8n automation workflows to publish directly from Craft to Ghost or Micro.blog.

Publishing from Craft to Micro.blog with a Single n8n Workflow

This is a technical write-up of an n8n automation that takes a document written in Craft, converts it to clean HTML, rehosts its images, and publishes it to Micro.blog as a draft — all from a single webhook call. It’s a companion to an earlier Craft → Ghost publisher and reuses the same document-fetching approach, but targets Micro.blog’s Micropub API instead of a proprietary CMS.

The goal is to write in one place (Craft) and let automation handle the tedious, error-prone part: block conversion and image migration.

What it does

An AI assistant (via n8n’s MCP integration) or any HTTP client sends a webhook request naming a Craft document by title. The workflow then:

  1. Searches the Craft space for that title.
  2. Scores the search results to pick the actual matching document.
  3. Fetches the document’s full block tree.
  4. Converts Craft blocks into Micropub-ready HTML.
  5. Extracts every Craft-hosted image, downloads it, and re-uploads it to Micro.blog’s media endpoint.
  6. Swaps the original image URLs for their new Micro.blog-hosted equivalents.
  7. Publishes the result as a draft for manual review before it goes live.

The whole run typically completes in about 4–6 seconds, with image uploads accounting for most of the overhead.

Trigger and input

The workflow is triggered by an HTTP POST webhook that waits for the full run to complete before responding (a “response node” pattern), so the caller gets the final published URL back synchronously. It’s also exposed through n8n’s MCP server, which is what lets an assistant invoke it conversationally.

The input payload is minimal:

{ "targetTitle": "My Article Title" }

Flow overview

Webhook
  → Search Document in Craft
  → Find Best Matching Document
  → Fetch Document Content
  → Convert Craft Blocks to HTML
  → Set Micro.blog Token
  → Extract Craft Image URLs
  → Has Images?
       ├─ true  → Split by Image URL → Download from Craft → Fix Filename
       │          → Upload to Micro.blog Media → Merge Upload Result
       │          → Reassemble HTML → Merge Before Publish
       └─ false → Format No-Image Post → Merge Before Publish
  → Publish to Micro.blog
  → Respond to Webhook

The interesting problems

Most of the engineering effort went into three areas that aren’t obvious until you hit them.

Craft’s search API does full-text search, not title-only. That means a document that merely mentions the target title somewhere in its body can rank above the document actually named that. Publishing the wrong document is a nasty failure mode.

The fix is a small scoring node that runs after the search and ranks each candidate:

  • Exact title match: 100
  • Title starts with the query: 80
  • Title contains the query: under 50, penalized by how much longer the title is than the query

Before comparing, it strips Markdown noise — bold markers, italics, and heading prefixes — so formatting in a title doesn’t throw off the match. If nothing scores above zero, the node throws a descriptive error that lists every candidate it saw, which makes debugging a missed title trivial. The node emits the single best match in the same shape as the search results, so the downstream “fetch content” step doesn’t need to know any scoring happened.

2. Converting Craft blocks to HTML

Craft stores documents as a nested tree of typed blocks, not as HTML. A dedicated conversion node walks that tree and emits Micropub-ready markup. It covers:

  • Paragraphs and headings (h1–h4)
  • Blockquotes and horizontal rules
  • Unordered and ordered lists — consecutive list-item blocks are grouped into a single <ul> or <ol> rather than emitting one list per item
  • Task checkboxes (rendered as ✅ / ☐)
  • Images, wrapped in <figure>
  • Rich-link bookmarks, rendered as a styled bookmark block
  • Code blocks (<pre><code>)
  • Inline formatting: bold, italic, strikethrough, inline code, links, and proper HTML-entity escaping

It also handles two different document shapes — a notebook with sub-pages and a flat single document — with a fallback lookup, so it doesn’t matter how the source is organized.

3. Rehosting images across services

Craft images live on Craft’s own CDN. If you publish those URLs directly, your post depends on Craft continuing to serve them — not acceptable for a permanent blog post. So every Craft image has to be pulled down and re-uploaded to Micro.blog.

The image branch:

  1. Regex-scans the generated HTML for Craft image URLs.
  2. Splits into one item per image.
  3. Downloads each image’s binary from Craft.
  4. Fixes the filename extension based on the response’s MIME type (Craft URLs don’t always carry a usable extension).
  5. Uploads each image to Micro.blog’s media endpoint as multipart/form-data.

A subtle detail: Micro.blog’s media endpoint returns 202 Accepted and puts the new image URL in the HTTP Location response header, not in the body. To capture a header you have to ask the HTTP node for the full response rather than just the parsed body. A later node maps each uploaded image’s Location back to its original Craft URL by index, and the HTML is reassembled with every Craft URL replaced by its Micro.blog equivalent.

Image failures are handled gracefully: the upload node continues on error, so if one image fails (AVIF, for instance, tends to be rejected), the post still publishes — that image just keeps its original URL instead of breaking the whole run.

Why Micropub

The most reusable idea here is that Micro.blog speaks Micropub, a W3C standard, rather than a proprietary publishing API. The final payload is a standard h-entry:

{
  "type": ["h-entry"],
  "properties": {
    "name": ["My Article Title"],
    "content": ["<h2>...</h2><p>...</p>"],
    "post-status": ["draft"]
  }
}

Any Micropub-compatible endpoint would accept the same shape, so the publishing half of this workflow isn’t locked to one host. The post-status: draft field is deliberate — every post lands as a draft so it can be reviewed in the editor before going public. The endpoint responds with the new post’s URL, a preview link, and an edit link, which the workflow passes straight back to the webhook caller.

Error handling

  • A shared error-handler workflow catches failures across the automation.
  • The webhook always responds, even on failure, so a caller never hangs.
  • Image-upload failures are non-fatal (see above).
  • A missing title produces an explicit, descriptive error listing what was searched.
  • Full-text false positives are neutralized by the scoring node rather than by hoping the search ranks correctly.

Credentials and configuration (redacted)

For obvious reasons, the specifics below are not published:

  • Craft Connect REST link — a private access link used to search Craft and fetch block content.
  • Micro.blog app token — a Bearer token generated in Micro.blog account settings, stored in a dedicated “Set Token” node so it can be rotated in one place.
  • n8n instance URL, webhook path, and internal workflow IDs — infrastructure details specific to a self-hosted deployment.

If you rebuild something like this, you’d supply your own Craft access link and your own Micro.blog token, and point the workflow at your own n8n instance.

Takeaways

  • Treat a full-text search as untrusted input and score its results before acting on them.
  • When migrating content between hosts, migrate the assets too — don’t hotlink another service’s CDN.
  • Watch where an API returns the data you need; a Location header is easy to miss if you only read response bodies.
  • Prefer open standards (Micropub) over proprietary APIs where you can — it keeps the integration portable.
  • Publish to draft first. Automation should hand you a reviewable result, not a live surprise.

Manton Reece on WordPress blogs in Micro.blog:

Several months ago I blogged that we wanted to have more integration with WordPress and Ghost in Micro.blog. I’ve now rolled out the first part of that, with an option to add a WordPress blog to Micro.blog’s web interface. New posts and edits sync back and forth between Micro.blog and WordPress.

I no longer use WordPress; I use Ghost since I moved out a few years ago. I’m really looking forward to seeing how this new integration enables new workflows or simply tweaks existing ones. Some posts are rather complex, and I’m curious to see how Micro.blog would handle these.

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.