Taming the Content Firehose with Meridiano

  [ RSS / YouTube / Markdown / Scraper ]
                   |
                   v
+--------------------------------------+
|          MERIDIANO BACKEND           |
|  (NestJS 11 / Node 22 / Postgres 16) |
+------------------+-------------------+
                   |
    +--------------+--------------+
    |                             |
    v                             v
[ AI Service ]               [ Redis 7 / BullMQ ]
DeepSeek / OpenAI            (Async Processing)
Groq / Together.xyz
    |
    v
+------------------+         +------------------+
| MERIDIANO WEB UI | <-----> |  MERIDIANO CLI   |
|  (React / Vite)  |         | (Hermes Agent)   |
+------------------+         +------------------+
                   (Tailscale)

I read a lot, and the noise-to-signal ratio of the modern internet is overwhelming. Between RSS feeds, YouTube transcripts, and random articles I find during the day, keeping up feels like a full-time job.

I needed a system to cut through the noise, so I built Meridiano. It is a personal, single-user intelligence briefing platform. The lineage actually descends from a couple of forks (starting with iliane5/meridian, then lfzawacki/meridiano, before becoming my current setup). I explicitly designed it to be single-tenant. Multi-user support is completely deferred (and I even documented this decision in an ADR) because I wanted to focus entirely on personal utility.

Here is how I built it, why I structured the architecture the way I did, and a few tricks I learned along the way.

What It Actually Does

At its core, Meridiano ingests content from RSS feeds, scraped article URLs, manually uploaded Markdown files, and YouTube channel transcripts. Once the data is in the system, the AI pipeline takes over. It summarizes each article and assigns an editorial “Impact Rating”. It then generates embeddings and clusters related articles using K-means (via the ml-kmeans library).

These clusters are synthesized into a “Standard Briefing”, grouped by topic categories like technology, business, health, or science. If I want something specific, I can create “Curated Briefings” where I hand-pick articles, add an optional free-text focus instruction, and let the AI synthesize exactly what I need.

I also added a Daily Digest email sent via Mailgun. This picks the top 10 tech articles based on a personal-relevance prompt and runs independently of the main briefing feature. This will be completly replaced by my Hermes + Discord setup (more on that later).

There is also a completely separate pipeline for YouTube transcriptions that never mixes into the standard briefings.

Architecture (Or, Why I Avoided a Monorepo)

The project is split across four separate repositories: meridiano-nestjs (backend), meridiano-frontend (web UI), meridiano-cli (the CLI client).

I avoided monorepo tooling like Nx or Turborepo entirely. Instead, I keep the frontend and backend strictly contract-synced by committing the backend’s OpenAPI (Swagger) spec. The frontend uses openapi-typescript to generate typed API bindings directly from that spec. Every pull request on both the frontend and backend runs a CI check to explicitly watch for type or spec drift. It gives me the safety of a monorepo without the configuration overhead.

The Backend: Modular and Pluggable

The backend runs on TypeScript, NestJS 11, and Node (specifically versions >=22 and <23). I use PostgreSQL 16 with TypeORM 0.3 for core data (articles, briefings, users) and Redis 7 with BullMQ 5 to handle heavy async queues like article processing and audio generation.

Instead of locking myself into one AI provider, I built a pluggable AiService abstraction. Depending on my environment variables (ENABLED_CHAT_MODEL, ENABLED_TTS_MODEL), I can swap out adapters. I default to DeepSeek for chat models, but I also have adapters for OpenAI (chat and TTS), Groq (using the Orpheus model for text-to-speech summaries), and Together.xyz (using the intfloat/multilingual-e5-large-instruct model for embeddings).

Having this abstraction is crucial. When one provider has downtime or when a cheaper model drops, I just update an environment variable and restart the container.

The Frontend: Tailscale-Aware Routing

The frontend was migrated off Next.js and now runs on React 18, Vite, React Router v6, and TypeScript. I rely on TanStack Query v5 for server state and simple React Context for local state (no Redux or Zustand required). The UI is built with Tailwind CSS v4 and shadcn/ui components.

One of my favorite details in the frontend is how it connects to the backend API. Because I access Meridiano in a few different ways (localhost, LAN IP, or over a Tailscale VPN), hardcoding the API URL was a nightmare. Instead, the frontend dynamically infers the backend’s base URL from the current hostname at runtime (though it can be overridden with VITE_API_BASE_URL).

// A simplified example of the runtime inference trick
const determineApiBaseUrl = () => {
  if (import.meta.env.VITE_API_BASE_URL) {
    return import.meta.env.VITE_API_BASE_URL;
  }
  // If we are on a Tailscale IP, point to the backend port on the same host
  return `${window.location.protocol}//${window.location.hostname}:3005/api`;
};

export const apiClient = axios.create({
  baseURL: determineApiBaseUrl(),
});

This trick means the app works seamlessly whether I am sitting at my desk on the local network or checking a briefing from a coffee shop halfway across the world via Tailscale.

The CLI and the Raspberry Pi

The real operational heart of this system is the CLI (meridiano-cli). It is a thin HTTP client built with NestJS nest-commander and talks to the backend over native fetch. It authenticates using a static API key resolved from a local dotfile.

I deploy the ingestion side (both the backend and the CLI) to a Raspberry Pi sitting on my local network, accessed over Tailscale. This is not a conventional cloud SaaS setup; it is a homelab project designed to run quietly in the corner of my room.

The CLI provides commands like meridiano articles <url> --feed-profile <profile> which fire-and-forget ingestion triggers. Interestingly, I am rarely the one typing these commands. The primary caller for the CLI today is a separate AI agent of mine called Hermes, which uses the CLI to trigger ingestion on my behalf. (More on Hermes and how it manages my digital life in a future post).

Building with Agents

Finally, the development workflow for Meridiano is heavily reliant on AI coding agents, specifically Claude Code. Each repository has its own agent-facing conventions document. I follow a strict planning process (writing PRDs and tech specs in the repo’s GitHub issues first) before letting the agents touch any code. It forces clarity before execution, which is exactly how a solo project avoids turning into a tangled mess of half-finished ideas.

Meridiano is highly specific to how I consume information, but the architectural patterns (swappable AI providers, OpenAPI contract testing, and Tailscale-aware frontends) are concepts I will carry into every project I build from now on.

Conclusion

Building a personal intelligence platform is less about complex machine learning and more about plumbing (moving data from RSS feeds into queues, passing it to an LLM, and getting it rendered cleanly on a screen). By keeping the scope strictly single-user, I eliminated 80 percent of the standard web-app complexity and focused entirely on the features that save me time.

Down the Rabbit Hole