Watch my work ↗
Engineering / AI / Pulse7 MIN READ

Inside Pulse: How I Built an AI News Engine That Updates Itself Every Morning

The stack, the hosting, the data model, and — the actual product — the scheduled AI research pipeline that publishes tech, fintech, and business news, with real African coverage, before I've had coffee.

By Victor MasokeFounder & builder
Inside Pulse — architecture of an AI news engine

Pulse is a page that's different every morning: tech, fintech, and business stories, researched, written, and published before I've had coffee, with a deliberate slice of that being African coverage most news apps skip. No one on my team writes those stories. Nothing gets rehosted from an RSS feed. A scheduled routine goes out, researches, and publishes — and I only touch it to read the result.

That last part is the whole engineering problem: how do you build a content pipeline where the "writer" is an AI agent running on a schedule, and the output has to be trustworthy enough that I'll build my own blog research on top of it? Here's exactly how it's built — stack, hosting, data model, and the ingestion pipeline that's the actual product.

The stack

Pulse is a Next.js 16 app on React 19, with Prisma over Postgres for storage, deployed on Railway. Nothing exotic — the interesting engineering isn't in the framework choice, it's in what happens before anything hits the database.

Pulse web app on MacBook
Web — desktop reading experience
Pulse mobile app view
Mobile — same data, responsive layout

System Overview

flowchart LR subgraph Ingestion["Scheduled ingestion (Claude Code)"] A[Claude Code scheduled routine] --> B[Source APIs + WebSearch] end subgraph App["Pulse app - Railway"] C[Next.js 16 / React 19] D[(Postgres via Prisma)] E[Ingest API] F[Cleanup API] end B -->|POST articles, bearer auth| E E --> D F --> D D --> C C --> G[pulsenews.buzz]

The app itself is a fairly ordinary content site — article pages, category filters, a saved/bookmarks view that's entirely local (no accounts, nothing sent to the server for that), an RSS feed, a sitemap. The part worth explaining in detail is the ingestion side, because that's where the actual product lives.

The data model

Five tables, and each one earns its place:

Data Model

erDiagram Article ||--o{ ArticleVote : receives Article ||--o{ ReferenceLink : cites Article { string slug string title string dek string body enum category "tech, fintech, business" enum region "africa, international" string sourceUrl int readCount int likeCount int dislikeCount datetime publishedAt } ArticleVote { string articleId string clientId enum type "like, dislike" } ReferenceLink { string articleId string url string sourceName enum kind "primary, related, data, background" } BlogPost { string slug string title string content } IngestionRun { datetime runAt int articlesCreated int articlesSkipped }

A few decisions here do real work:

  • sourceUrl is unique. That's the primary defense against duplicate stories — an article can't be ingested twice for the same source link, enforced at the database level, not just in application logic.
  • region is africa or international, and it's a first-class column, not a tag. That's what makes it possible to guarantee African coverage shows up every day rather than however often it happens to surface.
  • ReferenceLink.kindprimary, related, data, background — exists because a Pulse article is meant to be a jumping-off point, not the final word. Every story ships with the sources behind it, categorized by what kind of source they are, so a reader can go straight to the primary reporting if they want to go deeper.
  • ArticleVote is keyed on (articleId, clientId), a per-browser random ID generated client-side and never tied to an account — so likes and dislikes work without Pulse needing to know who anyone is.

The ingestion pipeline — the actual product

This is the part that makes Pulse a research assistant instead of an aggregator. Every morning, a scheduled Claude Code routine runs through a defined process:

Daily Ingestion Sequence

sequenceDiagram participant Cron as Scheduled routine participant Src as Source APIs participant Web as WebSearch participant AI as Claude (research + write) participant API as /api/ingest/articles participant DB as Postgres Cron->>Src: fetchHackerNewsFrontPage() Cron->>Src: fetchGuardianNews() / fetchNewsApiArticles() / fetchFinnhubNews() Cron->>Web: search TechCabal, Techpoint Africa, Disrupt Africa, Rest of World Africa Cron->>AI: pick ~6-10 stories, balance category + region AI->>AI: read primary sources, write original 120-220 word summary AI->>API: POST articles + references (Bearer INGEST_SECRET) API->>DB: dedupe on sourceUrl, insert Cron->>API: POST /api/cleanup/articles API->>DB: delete articles older than 30 days

Two decisions in that flow are the whole reason this works:

1. African coverage doesn't come from the news APIs — because it can't. I checked this directly while building the pipeline: the Guardian's Africa tag returns general politics and sports, not tech or business; NewsAPI and Finnhub have essentially no African source coverage. So instead of hoping a general feed surfaces something, the routine goes straight to the outlets that actually cover this beat — TechCabal, Techpoint Africa, Disrupt Africa, IT News Africa, Rest of World's Africa desk — via targeted WebSearch, and treats finding 2-3 genuinely strong African stories as a non-negotiable part of every run, not a nice-to-have.

2. The summary is written, not copied. The routine reads the primary source and writes an original ~120-220 word summary — never paraphrased-to-the-point-of-plagiarism, never a re-hosted lede. That's the actual value proposition: Pulse isn't trying to be the news, it's trying to be a trustworthy starting point that hands you the sources to go verify and go deeper yourself.

The ingest API — the boundary that keeps this safe

Everything the scheduled routine produces goes through one authenticated endpoint:

POST /api/ingest/articles
Authorization: Bearer {INGEST_SECRET}
Content-Type: application/json

{ "articles": [ { slug, title, dek, body, category, region, sourceUrl, references: [...] } ] }

This is deliberately the only write path into the Article table from outside the app itself. A few things happen here that matter:

  • Schema validation (Zod, matching ingestArticleSchema) rejects malformed payloads before they ever touch the database — a bad category enum or a missing references array fails loudly instead of silently corrupting a row.
  • Server-side dedup on sourceUrl — even if the calling routine forgets its own duplicate check, the database-level unique constraint plus an application-level guard means the same story can't land twice.
  • The secret is a bearer token, generated fresh per environment — not reused between local dev and production, so a leaked dev credential can't touch the live database.

A second endpoint, /api/cleanup/articles, runs after every ingestion pass and deletes anything older than 30 days. Pulse is deliberately not an archive — it's a rolling window of what's current, which keeps the database small and keeps every visit relevant instead of stale.

Architecture at a glance

Pulse hosting and deployment architecture on Railway
Railway hosting architecture — app, database, and scheduled ingestion pipeline
Pulse data model and knowledge graph architecture
Data model and knowledge graph — how articles, references, and votes connect

Hosting and deploys

Railway, with Nixpacks auto-detecting the Next.js build. The one non-default piece: the start command is prisma migrate deploy && next start, so every deploy applies any pending database migrations automatically before the app boots — no manual SQL step, no drift between what's in prisma/migrations/ and what's actually running in production. migrate deploy only applies migrations it hasn't recorded as applied, so a deploy with no schema changes is a no-op.

Everything environment-specific — the database URL, the ingest secret, the app's public URL that feeds the sitemap and OG tags — comes from environment variables. Nothing is hardcoded, which is what makes it possible to run the exact same codebase locally against a dev database and in production against Railway's Postgres without touching a line of code between them.

Why this shape, not a simpler one

The obvious simpler version of this project is a cron job that hits an RSS feed and republishes it. I didn't build that, on purpose. An RSS republish is exactly the low-value aggregator category Pulse exists to not be — no original synthesis, no African-first editorial priority, no sourcing discipline. The extra engineering — the authenticated ingest boundary, the region-aware research step, the reference-link taxonomy — is what turns "a page that updates" into something I actually trust enough to use as my own research tool before I write.

That's the real test I built this against: would I, personally, rely on this every morning instead of doing the reading myself? Pulse is what it took to get to yes.

THE CONVERSATION CONTINUES

Come for the ideas.
Leave with clarity.

▶ Watch My Work

Tech, business, and the things I learn building both.