Vibecode Feedly
track this build7 phases, 15 steps, beginner friendly0%Core RSS reading, folders, search, and saved articles are very buildable; Feedly's paid moat is polished feed discovery, mobile, AI features, and reliability.
You are building a lean indie version of Feedly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Feedly · indie build A personal RSS reader that is polite to the sites it reads: your Feedly OPML imported with folders, conditional fetching so unchanged feeds cost nothing, dedupe that survives rotating GUIDs, sanitized content, a keyboard-driven reader, starred items and full-text search, and optional LLM summaries that only appear when a key exists. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | a reader UI and a fetch loop in one process | | Parsing | rss-parser, node-cron | battle-tested feed parsing; scheduling without a queue | | Search | SQLite FTS5 | full text over everything you have read, no service | | Hosting | localhost, or your own network | your reading list is nobody's business | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **Your Feedly OPML export** · free - Why: Phase 1 imports it so nothing is retyped. - Get it: Feedly > Settings (gear) > OPML > Export. Save it as feeds.opml in the project. - [ ] **A User-Agent string with a contact URL** · free - Why: Polite fetching identifies itself. Site owners can reach you if your reader misbehaves. - Get it: Something like MyReader/1.0 (+https://yourdomain.com/reader) - [ ] **An LLM API key (optional)** (optional) · pay per use - Why: Phase 6 adds a per-article summary button; without a key the button does not exist. - Get it: Anthropic or OpenAI console, into .env. ## Quick start ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. - mobile apps - feed discovery - integrations - Leo/AI filtering - uptime - polished reading UX If one of those is essential to you, that is the reason to keep paying for Feedly, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Feedly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal RSS reader to replace Feedly. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22, Express and better-sqlite3, server-rendered, bound to localhost. - `rss-parser` for parsing, `node-cron` for scheduling. No frontend framework. - Feeds live in `feeds.opml` with folders · OPML in, OPML out, so the reader is never the only place your subscription list exists. ### Data model (create this before Phase 1) - `feeds`: id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled - `items`: id, feed_id, guid, link, title, author, published_at, content, read (bool), starred (bool), fetched_at Unique index on `(feed_id, guid)`. `etag` and `last_modified` are not optimizations · see Phase 2. Store `published_at` normalized to UTC, and fall back to `fetched_at` when a feed omits or mangles the date, which many do. ### Phase 1 · OPML and feed list Build: parse `feeds.opml` into the `feeds` table on first run, preserving folder structure, and export the list back to OPML on demand. Handle a re-import idempotently · adding a feed by hand and re-importing must not duplicate it. Done when: a real Feedly OPML export imports with folders intact, re-importing adds nothing, and the export re-imports into an identical tree. Do not build yet: fetching. ### Phase 2 · Polite fetching Build: the fetch loop, every 30 minutes via node-cron. Three things matter more than the parsing: - Send `If-None-Match` with the stored `etag` and `If-Modified-Since` with `last_modified`, and handle `304 Not Modified` by doing nothing. Skipping this means re-downloading every feed in full forever, which is how a personal reader becomes a nuisance to the sites it reads. - Set a real `User-Agent` identifying the reader with a contact URL. - Set a timeout and a per-host concurrency limit of 1, with a small delay between requests to the same host. Back off feeds that fail repeatedly (double the interval per consecutive failure, capped), and disable one after enough failures rather than hammering it forever. Done when: a second fetch of an unchanged feed returns 304 and writes nothing, a feed returning 500 backs off rather than retrying every 30 minutes, a feed that times out does not delay the others, and a malformed XML body is logged without killing the loop. ### Phase 3 · Item ingestion Build: dedupe by `guid`, falling back to `link` when the guid is missing or absurd (some feeds regenerate guids every request · treat a guid that changes for an identical link as absent). Sanitize item HTML against an allowlist before storage · feed content is arbitrary remote HTML and it will end up in your DOM. Strip tracking pixels and rewrite relative URLs to absolute against `site_url`. Done when: refetching a feed produces zero new items, a feed with rotating guids does not duplicate, a `<script>` in item content is stripped, and a relative image URL renders correctly. ### Phase 4 · Reader UI Build: folder tree with unread counts, an article list, and a reading pane. Keyboard: `j`/`k` to move, `o` to open, `m` to toggle read, `s` to star, `A` to mark all read in the current view. Mark-as-read on scroll-past is a preference, not the default. Dark mode. Done when: unread counts stay correct through every action, keyboard navigation never loses focus position when the list re-renders, and a 5,000-item feed list scrolls without lag. ### Phase 5 · Starred and search Build: a starred view, and full-text search over titles and content with SQLite FTS5, kept in sync via triggers rather than a manual second write. Done when: search finds a phrase inside an article body, deleting an item removes it from the index, and search results open into the reading pane correctly. ### Phase 6 · Optional summaries Build: a per-article summarize button using an LLM key from `.env`. Hide the button entirely when no key is set · a dead button is worse than no button. Cache the summary on the item so re-opening does not re-bill. Done when: with no key present there is no trace of the feature in the UI, and with a key a summary generates once and is reused afterwards. ### Phase 7 · Retention and deploy Build: a nightly prune keeping read items 90 days and starred items forever, a nightly database backup, a systemd unit, and the README. Done when: pruning never deletes a starred item, and unread counts survive a restart. ### Out of scope (and why) - Mobile apps. The web UI over your network is the answer, and it is worse than a native reader on a train · say so. - Feed discovery and recommendations, and their AI filtering layer. Those need a crawl of the whole web, which is exactly the moat. ### README must contain - How to add a feed (edit the OPML) and how to export it back out. - The polite-fetching behavior, stated as a promise to the sites you read. - Where the database lives and the retention policy. ===== AGENTS.md ===== # Agent instructions · Feedly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, rss-parser, node-cron, SQLite FTS5, localhost, or your own network. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ===== BUILD_PLAN.md ===== # Build plan · Feedly A personal RSS reader that is polite to the sites it reads: your Feedly OPML imported with folders, conditional fetching so unchanged feeds cost nothing, dedupe that survives rotating GUIDs, sanitized content, a keyboard-driven reader, starred items and full-text search, and optional LLM summaries that only appear when a key exists. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · OPML and feed list Import with folders, idempotently, and export back. ### Steps 1. Create the project and the tables feeds (id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled), items (id, feed_id, guid, link, title, author, published_at, content, read, starred, fetched_at), unique (feed_id, guid). ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` 2. Import OPML preserving folders; export on demand ### Done when - [ ] A real Feedly OPML imports with folders intact - [ ] Re-importing adds nothing - [ ] The export re-imports into an identical tree ## Phase 2 · Polite fetching Conditional GETs, a real User-Agent, timeouts, per-host courtesy, backoff. ### Steps 1. Send If-None-Match and If-Modified-Since; handle 304 by doing nothing Skipping this re-downloads every feed forever, which is how a reader becomes a nuisance. 2. Set USER_AGENT, a timeout, per-host concurrency of 1 with a delay 3. Back off failing feeds and disable after enough failures ### Done when - [ ] A second fetch of an unchanged feed returns 304 and writes nothing - [ ] A feed returning 500 backs off - [ ] A timeout does not delay the others - [ ] Malformed XML is logged without killing the loop ## Phase 3 · Item ingestion Dedupe that survives bad GUIDs, and HTML that cannot hurt you. ### Steps 1. Dedupe by guid, fall back to link, treat a rotating guid as absent 2. Sanitize content against an allowlist, strip tracking pixels, absolutize URLs ```sh npm install sanitize-html@2 ``` ### Done when - [ ] Refetching produces zero new items - [ ] A feed with rotating guids does not duplicate - [ ] A script tag in content is stripped - [ ] A relative image renders correctly ## Phase 4 · Reader UI Folders with counts, a list, a reading pane, keyboard everything. ### Steps 1. Build the three panes: folder tree with unread counts, article list, reading pane 2. Add keyboard shortcuts j/k/o/m/s/A and dark mode Mark-as-read on scroll-past is a preference, not the default. ### Done when - [ ] Unread counts stay correct through every action - [ ] Keyboard focus survives list re-renders - [ ] A 5,000-item list scrolls without lag ## Phase 5 · Starred and search Starred forever, FTS5 via triggers. ### Steps 1. Add the starred view 2. Add an FTS5 table over titles and content, synced by triggers ### Done when - [ ] Search finds a phrase inside an article body - [ ] Deleting an item removes it from the index ## Phase 6 · Optional summaries A button that exists only when a key does, cached per item. ### Steps 1. Render the summarize button only when ANTHROPIC_API_KEY is set A dead button is worse than no button. 2. Cache the summary on the item so reopening does not re-bill ### Done when - [ ] With no key there is no trace of the feature - [ ] With a key a summary generates once and is reused ## Phase 7 · Retention and deploy Prune read items, keep starred, back up, run as a service. ### Steps 1. Nightly prune of read items older than 90 days, never starred 2. Nightly backup, a systemd unit, and the README README: how to add a feed (edit the OPML) and export it back, the polite-fetching behaviour as a promise, where the database lives. Files: `README.md` ### Done when - [ ] Pruning never deletes a starred item - [ ] Unread counts survive a restart ## Not in this build - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. ## After v1, if you want it - A read-later inbox that saves arbitrary URLs into the same reader - Per-folder digests by email ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bound to 127.0.0.1 or your LAN. PORT=4830 # Required. SQLite file. DATABASE_PATH=./data/reader.db # Required. Your subscription list; the reader exports back to it. OPML_PATH=./feeds.opml # Required. Identifies your fetcher to the sites you read. USER_AGENT=MyReader/1.0 (+https://yourdomain.com/reader) # Optional. How often the loop runs. FETCH_INTERVAL_MINUTES=30 # Optional · secret. Enables summaries. Empty hides the feature entirely. ANTHROPIC_API_KEY=sk-ant-...
You are building a lean indie version of Feedly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Feedly · indie build A personal RSS reader that is polite to the sites it reads: your Feedly OPML imported with folders, conditional fetching so unchanged feeds cost nothing, dedupe that survives rotating GUIDs, sanitized content, a keyboard-driven reader, starred items and full-text search, and optional LLM summaries that only appear when a key exists. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | a reader UI and a fetch loop in one process | | Parsing | rss-parser, node-cron | battle-tested feed parsing; scheduling without a queue | | Search | SQLite FTS5 | full text over everything you have read, no service | | Hosting | localhost, or your own network | your reading list is nobody's business | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **Your Feedly OPML export** · free - Why: Phase 1 imports it so nothing is retyped. - Get it: Feedly > Settings (gear) > OPML > Export. Save it as feeds.opml in the project. - [ ] **A User-Agent string with a contact URL** · free - Why: Polite fetching identifies itself. Site owners can reach you if your reader misbehaves. - Get it: Something like MyReader/1.0 (+https://yourdomain.com/reader) - [ ] **An LLM API key (optional)** (optional) · pay per use - Why: Phase 6 adds a per-article summary button; without a key the button does not exist. - Get it: Anthropic or OpenAI console, into .env. ## Quick start ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. - mobile apps - feed discovery - integrations - Leo/AI filtering - uptime - polished reading UX If one of those is essential to you, that is the reason to keep paying for Feedly, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Feedly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal RSS reader to replace Feedly. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22, Express and better-sqlite3, server-rendered, bound to localhost. - `rss-parser` for parsing, `node-cron` for scheduling. No frontend framework. - Feeds live in `feeds.opml` with folders · OPML in, OPML out, so the reader is never the only place your subscription list exists. ### Data model (create this before Phase 1) - `feeds`: id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled - `items`: id, feed_id, guid, link, title, author, published_at, content, read (bool), starred (bool), fetched_at Unique index on `(feed_id, guid)`. `etag` and `last_modified` are not optimizations · see Phase 2. Store `published_at` normalized to UTC, and fall back to `fetched_at` when a feed omits or mangles the date, which many do. ### Phase 1 · OPML and feed list Build: parse `feeds.opml` into the `feeds` table on first run, preserving folder structure, and export the list back to OPML on demand. Handle a re-import idempotently · adding a feed by hand and re-importing must not duplicate it. Done when: a real Feedly OPML export imports with folders intact, re-importing adds nothing, and the export re-imports into an identical tree. Do not build yet: fetching. ### Phase 2 · Polite fetching Build: the fetch loop, every 30 minutes via node-cron. Three things matter more than the parsing: - Send `If-None-Match` with the stored `etag` and `If-Modified-Since` with `last_modified`, and handle `304 Not Modified` by doing nothing. Skipping this means re-downloading every feed in full forever, which is how a personal reader becomes a nuisance to the sites it reads. - Set a real `User-Agent` identifying the reader with a contact URL. - Set a timeout and a per-host concurrency limit of 1, with a small delay between requests to the same host. Back off feeds that fail repeatedly (double the interval per consecutive failure, capped), and disable one after enough failures rather than hammering it forever. Done when: a second fetch of an unchanged feed returns 304 and writes nothing, a feed returning 500 backs off rather than retrying every 30 minutes, a feed that times out does not delay the others, and a malformed XML body is logged without killing the loop. ### Phase 3 · Item ingestion Build: dedupe by `guid`, falling back to `link` when the guid is missing or absurd (some feeds regenerate guids every request · treat a guid that changes for an identical link as absent). Sanitize item HTML against an allowlist before storage · feed content is arbitrary remote HTML and it will end up in your DOM. Strip tracking pixels and rewrite relative URLs to absolute against `site_url`. Done when: refetching a feed produces zero new items, a feed with rotating guids does not duplicate, a `<script>` in item content is stripped, and a relative image URL renders correctly. ### Phase 4 · Reader UI Build: folder tree with unread counts, an article list, and a reading pane. Keyboard: `j`/`k` to move, `o` to open, `m` to toggle read, `s` to star, `A` to mark all read in the current view. Mark-as-read on scroll-past is a preference, not the default. Dark mode. Done when: unread counts stay correct through every action, keyboard navigation never loses focus position when the list re-renders, and a 5,000-item feed list scrolls without lag. ### Phase 5 · Starred and search Build: a starred view, and full-text search over titles and content with SQLite FTS5, kept in sync via triggers rather than a manual second write. Done when: search finds a phrase inside an article body, deleting an item removes it from the index, and search results open into the reading pane correctly. ### Phase 6 · Optional summaries Build: a per-article summarize button using an LLM key from `.env`. Hide the button entirely when no key is set · a dead button is worse than no button. Cache the summary on the item so re-opening does not re-bill. Done when: with no key present there is no trace of the feature in the UI, and with a key a summary generates once and is reused afterwards. ### Phase 7 · Retention and deploy Build: a nightly prune keeping read items 90 days and starred items forever, a nightly database backup, a systemd unit, and the README. Done when: pruning never deletes a starred item, and unread counts survive a restart. ### Out of scope (and why) - Mobile apps. The web UI over your network is the answer, and it is worse than a native reader on a train · say so. - Feed discovery and recommendations, and their AI filtering layer. Those need a crawl of the whole web, which is exactly the moat. ### README must contain - How to add a feed (edit the OPML) and how to export it back out. - The polite-fetching behavior, stated as a promise to the sites you read. - Where the database lives and the retention policy. ===== AGENTS.md ===== # Agent instructions · Feedly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, rss-parser, node-cron, SQLite FTS5, localhost, or your own network. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ===== BUILD_PLAN.md ===== # Build plan · Feedly A personal RSS reader that is polite to the sites it reads: your Feedly OPML imported with folders, conditional fetching so unchanged feeds cost nothing, dedupe that survives rotating GUIDs, sanitized content, a keyboard-driven reader, starred items and full-text search, and optional LLM summaries that only appear when a key exists. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · OPML and feed list Import with folders, idempotently, and export back. ### Steps 1. Create the project and the tables feeds (id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled), items (id, feed_id, guid, link, title, author, published_at, content, read, starred, fetched_at), unique (feed_id, guid). ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` 2. Import OPML preserving folders; export on demand ### Done when - [ ] A real Feedly OPML imports with folders intact - [ ] Re-importing adds nothing - [ ] The export re-imports into an identical tree ## Phase 2 · Polite fetching Conditional GETs, a real User-Agent, timeouts, per-host courtesy, backoff. ### Steps 1. Send If-None-Match and If-Modified-Since; handle 304 by doing nothing Skipping this re-downloads every feed forever, which is how a reader becomes a nuisance. 2. Set USER_AGENT, a timeout, per-host concurrency of 1 with a delay 3. Back off failing feeds and disable after enough failures ### Done when - [ ] A second fetch of an unchanged feed returns 304 and writes nothing - [ ] A feed returning 500 backs off - [ ] A timeout does not delay the others - [ ] Malformed XML is logged without killing the loop ## Phase 3 · Item ingestion Dedupe that survives bad GUIDs, and HTML that cannot hurt you. ### Steps 1. Dedupe by guid, fall back to link, treat a rotating guid as absent 2. Sanitize content against an allowlist, strip tracking pixels, absolutize URLs ```sh npm install sanitize-html@2 ``` ### Done when - [ ] Refetching produces zero new items - [ ] A feed with rotating guids does not duplicate - [ ] A script tag in content is stripped - [ ] A relative image renders correctly ## Phase 4 · Reader UI Folders with counts, a list, a reading pane, keyboard everything. ### Steps 1. Build the three panes: folder tree with unread counts, article list, reading pane 2. Add keyboard shortcuts j/k/o/m/s/A and dark mode Mark-as-read on scroll-past is a preference, not the default. ### Done when - [ ] Unread counts stay correct through every action - [ ] Keyboard focus survives list re-renders - [ ] A 5,000-item list scrolls without lag ## Phase 5 · Starred and search Starred forever, FTS5 via triggers. ### Steps 1. Add the starred view 2. Add an FTS5 table over titles and content, synced by triggers ### Done when - [ ] Search finds a phrase inside an article body - [ ] Deleting an item removes it from the index ## Phase 6 · Optional summaries A button that exists only when a key does, cached per item. ### Steps 1. Render the summarize button only when ANTHROPIC_API_KEY is set A dead button is worse than no button. 2. Cache the summary on the item so reopening does not re-bill ### Done when - [ ] With no key there is no trace of the feature - [ ] With a key a summary generates once and is reused ## Phase 7 · Retention and deploy Prune read items, keep starred, back up, run as a service. ### Steps 1. Nightly prune of read items older than 90 days, never starred 2. Nightly backup, a systemd unit, and the README README: how to add a feed (edit the OPML) and export it back, the polite-fetching behaviour as a promise, where the database lives. Files: `README.md` ### Done when - [ ] Pruning never deletes a starred item - [ ] Unread counts survive a restart ## Not in this build - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. ## After v1, if you want it - A read-later inbox that saves arbitrary URLs into the same reader - Per-folder digests by email ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bound to 127.0.0.1 or your LAN. PORT=4830 # Required. SQLite file. DATABASE_PATH=./data/reader.db # Required. Your subscription list; the reader exports back to it. OPML_PATH=./feeds.opml # Required. Identifies your fetcher to the sites you read. USER_AGENT=MyReader/1.0 (+https://yourdomain.com/reader) # Optional. How often the loop runs. FETCH_INTERVAL_MINUTES=30 # Optional · secret. Enables summaries. Empty hides the feature entirely. ANTHROPIC_API_KEY=sk-ant-...
You are building a production product version of Feedly. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Feedly · product brief ## Problem Core RSS reading, folders, search, and saved articles are very buildable; Feedly's paid moat is polished feed discovery, mobile, AI features, and reliability. ## Product outcome A reader you could run for a few people on your network: polite by construction, searchable for years, with summaries as a paid-key extra. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted cron or local daemon - RSS parser - database - web UI - optional LLM API ## Explicit non-goals for v1 - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. - mobile apps - feed discovery - integrations - Leo/AI filtering - uptime - polished reading UX ## Success criteria - 304 handling verified against a real feed - Sanitizer verified against a hostile fixture - One restore drill performed ===== BRIEF.md ===== # Build brief · Feedly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal RSS reader to replace Feedly. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22, Express and better-sqlite3, server-rendered, bound to localhost. - `rss-parser` for parsing, `node-cron` for scheduling. No frontend framework. - Feeds live in `feeds.opml` with folders · OPML in, OPML out, so the reader is never the only place your subscription list exists. ### Data model (create this before Phase 1) - `feeds`: id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled - `items`: id, feed_id, guid, link, title, author, published_at, content, read (bool), starred (bool), fetched_at Unique index on `(feed_id, guid)`. `etag` and `last_modified` are not optimizations · see Phase 2. Store `published_at` normalized to UTC, and fall back to `fetched_at` when a feed omits or mangles the date, which many do. ### Phase 1 · OPML and feed list Build: parse `feeds.opml` into the `feeds` table on first run, preserving folder structure, and export the list back to OPML on demand. Handle a re-import idempotently · adding a feed by hand and re-importing must not duplicate it. Done when: a real Feedly OPML export imports with folders intact, re-importing adds nothing, and the export re-imports into an identical tree. Do not build yet: fetching. ### Phase 2 · Polite fetching Build: the fetch loop, every 30 minutes via node-cron. Three things matter more than the parsing: - Send `If-None-Match` with the stored `etag` and `If-Modified-Since` with `last_modified`, and handle `304 Not Modified` by doing nothing. Skipping this means re-downloading every feed in full forever, which is how a personal reader becomes a nuisance to the sites it reads. - Set a real `User-Agent` identifying the reader with a contact URL. - Set a timeout and a per-host concurrency limit of 1, with a small delay between requests to the same host. Back off feeds that fail repeatedly (double the interval per consecutive failure, capped), and disable one after enough failures rather than hammering it forever. Done when: a second fetch of an unchanged feed returns 304 and writes nothing, a feed returning 500 backs off rather than retrying every 30 minutes, a feed that times out does not delay the others, and a malformed XML body is logged without killing the loop. ### Phase 3 · Item ingestion Build: dedupe by `guid`, falling back to `link` when the guid is missing or absurd (some feeds regenerate guids every request · treat a guid that changes for an identical link as absent). Sanitize item HTML against an allowlist before storage · feed content is arbitrary remote HTML and it will end up in your DOM. Strip tracking pixels and rewrite relative URLs to absolute against `site_url`. Done when: refetching a feed produces zero new items, a feed with rotating guids does not duplicate, a `<script>` in item content is stripped, and a relative image URL renders correctly. ### Phase 4 · Reader UI Build: folder tree with unread counts, an article list, and a reading pane. Keyboard: `j`/`k` to move, `o` to open, `m` to toggle read, `s` to star, `A` to mark all read in the current view. Mark-as-read on scroll-past is a preference, not the default. Dark mode. Done when: unread counts stay correct through every action, keyboard navigation never loses focus position when the list re-renders, and a 5,000-item feed list scrolls without lag. ### Phase 5 · Starred and search Build: a starred view, and full-text search over titles and content with SQLite FTS5, kept in sync via triggers rather than a manual second write. Done when: search finds a phrase inside an article body, deleting an item removes it from the index, and search results open into the reading pane correctly. ### Phase 6 · Optional summaries Build: a per-article summarize button using an LLM key from `.env`. Hide the button entirely when no key is set · a dead button is worse than no button. Cache the summary on the item so re-opening does not re-bill. Done when: with no key present there is no trace of the feature in the UI, and with a key a summary generates once and is reused afterwards. ### Phase 7 · Retention and deploy Build: a nightly prune keeping read items 90 days and starred items forever, a nightly database backup, a systemd unit, and the README. Done when: pruning never deletes a starred item, and unread counts survive a restart. ### Out of scope (and why) - Mobile apps. The web UI over your network is the answer, and it is worse than a native reader on a train · say so. - Feed discovery and recommendations, and their AI filtering layer. Those need a crawl of the whole web, which is exactly the moat. ### README must contain - How to add a feed (edit the OPML) and how to export it back out. - The polite-fetching behavior, stated as a promise to the sites you read. - Where the database lives and the retention policy. ===== ARCHITECTURE.md ===== # Architecture · Feedly ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | a reader UI and a fetch loop in one process | | Parsing | rss-parser, node-cron | battle-tested feed parsing; scheduling without a queue | | Search | SQLite FTS5 | full text over everything you have read, no service | | Hosting | localhost, or your own network | your reading list is nobody's business | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscriptions | feeds table and OPML in/out | Any list format producing the same rows | | Fetcher | conditional GET, backoff, courtesy | The politeness contract lives here | | Ingest | dedupe and sanitizing | Stricter allowlists here only | | Reader | UI, FTS, starred | Any UI over items | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Bound to 127.0.0.1 or your LAN. - `DATABASE_PATH` · required · SQLite file. - `OPML_PATH` · required · Your subscription list; the reader exports back to it. - `USER_AGENT` · required · Identifies your fetcher to the sites you read. - `FETCH_INTERVAL_MINUTES` · optional · How often the loop runs. - `ANTHROPIC_API_KEY` · optional, secret · Enables summaries. Empty hides the feature entirely. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Feedly product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and better-sqlite3, rss-parser, node-cron, SQLite FTS5, localhost, or your own network. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ===== MILESTONES.md ===== # Delivery milestones · Feedly Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · OPML and feed list Import with folders, idempotently, and export back. ### Steps 1. Create the project and the tables feeds (id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled), items (id, feed_id, guid, link, title, author, published_at, content, read, starred, fetched_at), unique (feed_id, guid). ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` 2. Import OPML preserving folders; export on demand ### Done when - [ ] A real Feedly OPML imports with folders intact - [ ] Re-importing adds nothing - [ ] The export re-imports into an identical tree ## M2 · Polite fetching Conditional GETs, a real User-Agent, timeouts, per-host courtesy, backoff. ### Steps 1. Send If-None-Match and If-Modified-Since; handle 304 by doing nothing Skipping this re-downloads every feed forever, which is how a reader becomes a nuisance. 2. Set USER_AGENT, a timeout, per-host concurrency of 1 with a delay 3. Back off failing feeds and disable after enough failures ### Done when - [ ] A second fetch of an unchanged feed returns 304 and writes nothing - [ ] A feed returning 500 backs off - [ ] A timeout does not delay the others - [ ] Malformed XML is logged without killing the loop ## M3 · Item ingestion Dedupe that survives bad GUIDs, and HTML that cannot hurt you. ### Steps 1. Dedupe by guid, fall back to link, treat a rotating guid as absent 2. Sanitize content against an allowlist, strip tracking pixels, absolutize URLs ```sh npm install sanitize-html@2 ``` ### Done when - [ ] Refetching produces zero new items - [ ] A feed with rotating guids does not duplicate - [ ] A script tag in content is stripped - [ ] A relative image renders correctly ## M4 · Reader UI Folders with counts, a list, a reading pane, keyboard everything. ### Steps 1. Build the three panes: folder tree with unread counts, article list, reading pane 2. Add keyboard shortcuts j/k/o/m/s/A and dark mode Mark-as-read on scroll-past is a preference, not the default. ### Done when - [ ] Unread counts stay correct through every action - [ ] Keyboard focus survives list re-renders - [ ] A 5,000-item list scrolls without lag ## M5 · Starred and search Starred forever, FTS5 via triggers. ### Steps 1. Add the starred view 2. Add an FTS5 table over titles and content, synced by triggers ### Done when - [ ] Search finds a phrase inside an article body - [ ] Deleting an item removes it from the index ## M6 · Optional summaries A button that exists only when a key does, cached per item. ### Steps 1. Render the summarize button only when ANTHROPIC_API_KEY is set A dead button is worse than no button. 2. Cache the summary on the item so reopening does not re-bill ### Done when - [ ] With no key there is no trace of the feature - [ ] With a key a summary generates once and is reused ## M7 · Retention and deploy Prune read items, keep starred, back up, run as a service. ### Steps 1. Nightly prune of read items older than 90 days, never starred 2. Nightly backup, a systemd unit, and the README README: how to add a feed (edit the OPML) and export it back, the polite-fetching behaviour as a promise, where the database lives. Files: `README.md` ### Done when - [ ] Pruning never deletes a starred item - [ ] Unread counts survive a restart ## M8 · Operate it like a product (production only) Only for the product-builder path: know when the reader is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443 ===== OPERATIONS.md ===== # Operations · Feedly ## Backup SQLite .backup nightly. ## Restore Copy back; unread counts and stars are in the file. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Alert if the fetch loop has not run in an hour. ## Incident checklist If a site complains, disable the feed and check your User-Agent and interval. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] 304 handling verified against a real feed - [ ] Sanitizer verified against a hostile fixture - [ ] One restore drill performed ## Launch constraint Do not market omitted Feedly capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bound to 127.0.0.1 or your LAN. PORT=4830 # Required. SQLite file. DATABASE_PATH=./data/reader.db # Required. Your subscription list; the reader exports back to it. OPML_PATH=./feeds.opml # Required. Identifies your fetcher to the sites you read. USER_AGENT=MyReader/1.0 (+https://yourdomain.com/reader) # Optional. How often the loop runs. FETCH_INTERVAL_MINUTES=30 # Optional · secret. Enables summaries. Empty hides the feature entirely. ANTHROPIC_API_KEY=sk-ant-...
# Feedly · indie build A personal RSS reader that is polite to the sites it reads: your Feedly OPML imported with folders, conditional fetching so unchanged feeds cost nothing, dedupe that survives rotating GUIDs, sanitized content, a keyboard-driven reader, starred items and full-text search, and optional LLM summaries that only appear when a key exists. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | a reader UI and a fetch loop in one process | | Parsing | rss-parser, node-cron | battle-tested feed parsing; scheduling without a queue | | Search | SQLite FTS5 | full text over everything you have read, no service | | Hosting | localhost, or your own network | your reading list is nobody's business | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Everything in this build runs on it: the server, the scripts, the tests. - Get it: Download the LTS installer from nodejs.org, or install with your package manager (brew install node, or nvm install 22). Restart the terminal afterwards. - Verify: node --version prints v22 or higher - [ ] **A terminal and a code editor** · free - Why: Every step below is a command you type or a file you edit. - Get it: VS Code (code.visualstudio.com), Cursor or Zed. Open a folder for the project and use the editor's built-in terminal. - Verify: You can open a folder and run a command in its terminal - [ ] **Git** · free - Why: History for your code, and the way most hosts deploy. - Get it: Install from git-scm.com or with your package manager, then run git init in the project folder once it exists. - Verify: git --version prints a version - [ ] **Your Feedly OPML export** · free - Why: Phase 1 imports it so nothing is retyped. - Get it: Feedly > Settings (gear) > OPML > Export. Save it as feeds.opml in the project. - [ ] **A User-Agent string with a contact URL** · free - Why: Polite fetching identifies itself. Site owners can reach you if your reader misbehaves. - Get it: Something like MyReader/1.0 (+https://yourdomain.com/reader) - [ ] **An LLM API key (optional)** (optional) · pay per use - Why: Phase 6 adds a per-article summary button; without a key the button does not exist. - Get it: Anthropic or OpenAI console, into .env. ## Quick start ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. - mobile apps - feed discovery - integrations - Leo/AI filtering - uptime - polished reading UX If one of those is essential to you, that is the reason to keep paying for Feedly, and the README should say so rather than pretend.
# Build brief · Feedly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal RSS reader to replace Feedly. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22, Express and better-sqlite3, server-rendered, bound to localhost. - `rss-parser` for parsing, `node-cron` for scheduling. No frontend framework. - Feeds live in `feeds.opml` with folders · OPML in, OPML out, so the reader is never the only place your subscription list exists. ### Data model (create this before Phase 1) - `feeds`: id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled - `items`: id, feed_id, guid, link, title, author, published_at, content, read (bool), starred (bool), fetched_at Unique index on `(feed_id, guid)`. `etag` and `last_modified` are not optimizations · see Phase 2. Store `published_at` normalized to UTC, and fall back to `fetched_at` when a feed omits or mangles the date, which many do. ### Phase 1 · OPML and feed list Build: parse `feeds.opml` into the `feeds` table on first run, preserving folder structure, and export the list back to OPML on demand. Handle a re-import idempotently · adding a feed by hand and re-importing must not duplicate it. Done when: a real Feedly OPML export imports with folders intact, re-importing adds nothing, and the export re-imports into an identical tree. Do not build yet: fetching. ### Phase 2 · Polite fetching Build: the fetch loop, every 30 minutes via node-cron. Three things matter more than the parsing: - Send `If-None-Match` with the stored `etag` and `If-Modified-Since` with `last_modified`, and handle `304 Not Modified` by doing nothing. Skipping this means re-downloading every feed in full forever, which is how a personal reader becomes a nuisance to the sites it reads. - Set a real `User-Agent` identifying the reader with a contact URL. - Set a timeout and a per-host concurrency limit of 1, with a small delay between requests to the same host. Back off feeds that fail repeatedly (double the interval per consecutive failure, capped), and disable one after enough failures rather than hammering it forever. Done when: a second fetch of an unchanged feed returns 304 and writes nothing, a feed returning 500 backs off rather than retrying every 30 minutes, a feed that times out does not delay the others, and a malformed XML body is logged without killing the loop. ### Phase 3 · Item ingestion Build: dedupe by `guid`, falling back to `link` when the guid is missing or absurd (some feeds regenerate guids every request · treat a guid that changes for an identical link as absent). Sanitize item HTML against an allowlist before storage · feed content is arbitrary remote HTML and it will end up in your DOM. Strip tracking pixels and rewrite relative URLs to absolute against `site_url`. Done when: refetching a feed produces zero new items, a feed with rotating guids does not duplicate, a `<script>` in item content is stripped, and a relative image URL renders correctly. ### Phase 4 · Reader UI Build: folder tree with unread counts, an article list, and a reading pane. Keyboard: `j`/`k` to move, `o` to open, `m` to toggle read, `s` to star, `A` to mark all read in the current view. Mark-as-read on scroll-past is a preference, not the default. Dark mode. Done when: unread counts stay correct through every action, keyboard navigation never loses focus position when the list re-renders, and a 5,000-item feed list scrolls without lag. ### Phase 5 · Starred and search Build: a starred view, and full-text search over titles and content with SQLite FTS5, kept in sync via triggers rather than a manual second write. Done when: search finds a phrase inside an article body, deleting an item removes it from the index, and search results open into the reading pane correctly. ### Phase 6 · Optional summaries Build: a per-article summarize button using an LLM key from `.env`. Hide the button entirely when no key is set · a dead button is worse than no button. Cache the summary on the item so re-opening does not re-bill. Done when: with no key present there is no trace of the feature in the UI, and with a key a summary generates once and is reused afterwards. ### Phase 7 · Retention and deploy Build: a nightly prune keeping read items 90 days and starred items forever, a nightly database backup, a systemd unit, and the README. Done when: pruning never deletes a starred item, and unread counts survive a restart. ### Out of scope (and why) - Mobile apps. The web UI over your network is the answer, and it is worse than a native reader on a train · say so. - Feed discovery and recommendations, and their AI filtering layer. Those need a crawl of the whole web, which is exactly the moat. ### README must contain - How to add a feed (edit the OPML) and how to export it back out. - The polite-fetching behavior, stated as a promise to the sites you read. - Where the database lives and the retention policy.
# Agent instructions · Feedly indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Express and better-sqlite3, rss-parser, node-cron, SQLite FTS5, localhost, or your own network. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs".
# Build plan · Feedly A personal RSS reader that is polite to the sites it reads: your Feedly OPML imported with folders, conditional fetching so unchanged feeds cost nothing, dedupe that survives rotating GUIDs, sanitized content, a keyboard-driven reader, starred items and full-text search, and optional LLM summaries that only appear when a key exists. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · OPML and feed list Import with folders, idempotently, and export back. ### Steps 1. Create the project and the tables feeds (id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled), items (id, feed_id, guid, link, title, author, published_at, content, read, starred, fetched_at), unique (feed_id, guid). ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` 2. Import OPML preserving folders; export on demand ### Done when - [ ] A real Feedly OPML imports with folders intact - [ ] Re-importing adds nothing - [ ] The export re-imports into an identical tree ## Phase 2 · Polite fetching Conditional GETs, a real User-Agent, timeouts, per-host courtesy, backoff. ### Steps 1. Send If-None-Match and If-Modified-Since; handle 304 by doing nothing Skipping this re-downloads every feed forever, which is how a reader becomes a nuisance. 2. Set USER_AGENT, a timeout, per-host concurrency of 1 with a delay 3. Back off failing feeds and disable after enough failures ### Done when - [ ] A second fetch of an unchanged feed returns 304 and writes nothing - [ ] A feed returning 500 backs off - [ ] A timeout does not delay the others - [ ] Malformed XML is logged without killing the loop ## Phase 3 · Item ingestion Dedupe that survives bad GUIDs, and HTML that cannot hurt you. ### Steps 1. Dedupe by guid, fall back to link, treat a rotating guid as absent 2. Sanitize content against an allowlist, strip tracking pixels, absolutize URLs ```sh npm install sanitize-html@2 ``` ### Done when - [ ] Refetching produces zero new items - [ ] A feed with rotating guids does not duplicate - [ ] A script tag in content is stripped - [ ] A relative image renders correctly ## Phase 4 · Reader UI Folders with counts, a list, a reading pane, keyboard everything. ### Steps 1. Build the three panes: folder tree with unread counts, article list, reading pane 2. Add keyboard shortcuts j/k/o/m/s/A and dark mode Mark-as-read on scroll-past is a preference, not the default. ### Done when - [ ] Unread counts stay correct through every action - [ ] Keyboard focus survives list re-renders - [ ] A 5,000-item list scrolls without lag ## Phase 5 · Starred and search Starred forever, FTS5 via triggers. ### Steps 1. Add the starred view 2. Add an FTS5 table over titles and content, synced by triggers ### Done when - [ ] Search finds a phrase inside an article body - [ ] Deleting an item removes it from the index ## Phase 6 · Optional summaries A button that exists only when a key does, cached per item. ### Steps 1. Render the summarize button only when ANTHROPIC_API_KEY is set A dead button is worse than no button. 2. Cache the summary on the item so reopening does not re-bill ### Done when - [ ] With no key there is no trace of the feature - [ ] With a key a summary generates once and is reused ## Phase 7 · Retention and deploy Prune read items, keep starred, back up, run as a service. ### Steps 1. Nightly prune of read items older than 90 days, never starred 2. Nightly backup, a systemd unit, and the README README: how to add a feed (edit the OPML) and export it back, the polite-fetching behaviour as a promise, where the database lives. Files: `README.md` ### Done when - [ ] Pruning never deletes a starred item - [ ] Unread counts survive a restart ## Not in this build - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. ## After v1, if you want it - A read-later inbox that saves arbitrary URLs into the same reader - Per-folder digests by email
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bound to 127.0.0.1 or your LAN. PORT=4830 # Required. SQLite file. DATABASE_PATH=./data/reader.db # Required. Your subscription list; the reader exports back to it. OPML_PATH=./feeds.opml # Required. Identifies your fetcher to the sites you read. USER_AGENT=MyReader/1.0 (+https://yourdomain.com/reader) # Optional. How often the loop runs. FETCH_INTERVAL_MINUTES=30 # Optional · secret. Enables summaries. Empty hides the feature entirely. ANTHROPIC_API_KEY=sk-ant-...
# Feedly · product brief ## Problem Core RSS reading, folders, search, and saved articles are very buildable; Feedly's paid moat is polished feed discovery, mobile, AI features, and reliability. ## Product outcome A reader you could run for a few people on your network: polite by construction, searchable for years, with summaries as a paid-key extra. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - hosted cron or local daemon - RSS parser - database - web UI - optional LLM API ## Explicit non-goals for v1 - Mobile apps; the web UI over your network is the answer. - Feed discovery and their AI filtering layer, which need a crawl of the web. - mobile apps - feed discovery - integrations - Leo/AI filtering - uptime - polished reading UX ## Success criteria - 304 handling verified against a real feed - Sanitizer verified against a hostile fixture - One restore drill performed
# Build brief · Feedly The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal RSS reader to replace Feedly. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Node 22, Express and better-sqlite3, server-rendered, bound to localhost. - `rss-parser` for parsing, `node-cron` for scheduling. No frontend framework. - Feeds live in `feeds.opml` with folders · OPML in, OPML out, so the reader is never the only place your subscription list exists. ### Data model (create this before Phase 1) - `feeds`: id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled - `items`: id, feed_id, guid, link, title, author, published_at, content, read (bool), starred (bool), fetched_at Unique index on `(feed_id, guid)`. `etag` and `last_modified` are not optimizations · see Phase 2. Store `published_at` normalized to UTC, and fall back to `fetched_at` when a feed omits or mangles the date, which many do. ### Phase 1 · OPML and feed list Build: parse `feeds.opml` into the `feeds` table on first run, preserving folder structure, and export the list back to OPML on demand. Handle a re-import idempotently · adding a feed by hand and re-importing must not duplicate it. Done when: a real Feedly OPML export imports with folders intact, re-importing adds nothing, and the export re-imports into an identical tree. Do not build yet: fetching. ### Phase 2 · Polite fetching Build: the fetch loop, every 30 minutes via node-cron. Three things matter more than the parsing: - Send `If-None-Match` with the stored `etag` and `If-Modified-Since` with `last_modified`, and handle `304 Not Modified` by doing nothing. Skipping this means re-downloading every feed in full forever, which is how a personal reader becomes a nuisance to the sites it reads. - Set a real `User-Agent` identifying the reader with a contact URL. - Set a timeout and a per-host concurrency limit of 1, with a small delay between requests to the same host. Back off feeds that fail repeatedly (double the interval per consecutive failure, capped), and disable one after enough failures rather than hammering it forever. Done when: a second fetch of an unchanged feed returns 304 and writes nothing, a feed returning 500 backs off rather than retrying every 30 minutes, a feed that times out does not delay the others, and a malformed XML body is logged without killing the loop. ### Phase 3 · Item ingestion Build: dedupe by `guid`, falling back to `link` when the guid is missing or absurd (some feeds regenerate guids every request · treat a guid that changes for an identical link as absent). Sanitize item HTML against an allowlist before storage · feed content is arbitrary remote HTML and it will end up in your DOM. Strip tracking pixels and rewrite relative URLs to absolute against `site_url`. Done when: refetching a feed produces zero new items, a feed with rotating guids does not duplicate, a `<script>` in item content is stripped, and a relative image URL renders correctly. ### Phase 4 · Reader UI Build: folder tree with unread counts, an article list, and a reading pane. Keyboard: `j`/`k` to move, `o` to open, `m` to toggle read, `s` to star, `A` to mark all read in the current view. Mark-as-read on scroll-past is a preference, not the default. Dark mode. Done when: unread counts stay correct through every action, keyboard navigation never loses focus position when the list re-renders, and a 5,000-item feed list scrolls without lag. ### Phase 5 · Starred and search Build: a starred view, and full-text search over titles and content with SQLite FTS5, kept in sync via triggers rather than a manual second write. Done when: search finds a phrase inside an article body, deleting an item removes it from the index, and search results open into the reading pane correctly. ### Phase 6 · Optional summaries Build: a per-article summarize button using an LLM key from `.env`. Hide the button entirely when no key is set · a dead button is worse than no button. Cache the summary on the item so re-opening does not re-bill. Done when: with no key present there is no trace of the feature in the UI, and with a key a summary generates once and is reused afterwards. ### Phase 7 · Retention and deploy Build: a nightly prune keeping read items 90 days and starred items forever, a nightly database backup, a systemd unit, and the README. Done when: pruning never deletes a starred item, and unread counts survive a restart. ### Out of scope (and why) - Mobile apps. The web UI over your network is the answer, and it is worse than a native reader on a train · say so. - Feed discovery and recommendations, and their AI filtering layer. Those need a crawl of the whole web, which is exactly the moat. ### README must contain - How to add a feed (edit the OPML) and how to export it back out. - The polite-fetching behavior, stated as a promise to the sites you read. - Where the database lives and the retention policy.
# Architecture · Feedly ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Express and better-sqlite3 | a reader UI and a fetch loop in one process | | Parsing | rss-parser, node-cron | battle-tested feed parsing; scheduling without a queue | | Search | SQLite FTS5 | full text over everything you have read, no service | | Hosting | localhost, or your own network | your reading list is nobody's business | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Subscriptions | feeds table and OPML in/out | Any list format producing the same rows | | Fetcher | conditional GET, backoff, courtesy | The politeness contract lives here | | Ingest | dedupe and sanitizing | Stricter allowlists here only | | Reader | UI, FTS, starred | Any UI over items | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `PORT` · required · Bound to 127.0.0.1 or your LAN. - `DATABASE_PATH` · required · SQLite file. - `OPML_PATH` · required · Your subscription list; the reader exports back to it. - `USER_AGENT` · required · Identifies your fetcher to the sites you read. - `FETCH_INTERVAL_MINUTES` · optional · How often the loop runs. - `ANTHROPIC_API_KEY` · optional, secret · Enables summaries. Empty hides the feature entirely. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Feedly product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Express and better-sqlite3, rss-parser, node-cron, SQLite FTS5, localhost, or your own network. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone.
# Delivery milestones · Feedly Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · OPML and feed list Import with folders, idempotently, and export back. ### Steps 1. Create the project and the tables feeds (id, url, title, folder, site_url, etag, last_modified, last_fetched_at, last_status, consecutive_failures, disabled), items (id, feed_id, guid, link, title, author, published_at, content, read, starred, fetched_at), unique (feed_id, guid). ```sh mkdir reader && cd reader && git init && npm init -y && npm pkg set type=module npm install express@4 better-sqlite3@13 rss-parser@3 node-cron@3 mkdir data && cp .env.example .env ``` 2. Import OPML preserving folders; export on demand ### Done when - [ ] A real Feedly OPML imports with folders intact - [ ] Re-importing adds nothing - [ ] The export re-imports into an identical tree ## M2 · Polite fetching Conditional GETs, a real User-Agent, timeouts, per-host courtesy, backoff. ### Steps 1. Send If-None-Match and If-Modified-Since; handle 304 by doing nothing Skipping this re-downloads every feed forever, which is how a reader becomes a nuisance. 2. Set USER_AGENT, a timeout, per-host concurrency of 1 with a delay 3. Back off failing feeds and disable after enough failures ### Done when - [ ] A second fetch of an unchanged feed returns 304 and writes nothing - [ ] A feed returning 500 backs off - [ ] A timeout does not delay the others - [ ] Malformed XML is logged without killing the loop ## M3 · Item ingestion Dedupe that survives bad GUIDs, and HTML that cannot hurt you. ### Steps 1. Dedupe by guid, fall back to link, treat a rotating guid as absent 2. Sanitize content against an allowlist, strip tracking pixels, absolutize URLs ```sh npm install sanitize-html@2 ``` ### Done when - [ ] Refetching produces zero new items - [ ] A feed with rotating guids does not duplicate - [ ] A script tag in content is stripped - [ ] A relative image renders correctly ## M4 · Reader UI Folders with counts, a list, a reading pane, keyboard everything. ### Steps 1. Build the three panes: folder tree with unread counts, article list, reading pane 2. Add keyboard shortcuts j/k/o/m/s/A and dark mode Mark-as-read on scroll-past is a preference, not the default. ### Done when - [ ] Unread counts stay correct through every action - [ ] Keyboard focus survives list re-renders - [ ] A 5,000-item list scrolls without lag ## M5 · Starred and search Starred forever, FTS5 via triggers. ### Steps 1. Add the starred view 2. Add an FTS5 table over titles and content, synced by triggers ### Done when - [ ] Search finds a phrase inside an article body - [ ] Deleting an item removes it from the index ## M6 · Optional summaries A button that exists only when a key does, cached per item. ### Steps 1. Render the summarize button only when ANTHROPIC_API_KEY is set A dead button is worse than no button. 2. Cache the summary on the item so reopening does not re-bill ### Done when - [ ] With no key there is no trace of the feature - [ ] With a key a summary generates once and is reused ## M7 · Retention and deploy Prune read items, keep starred, back up, run as a service. ### Steps 1. Nightly prune of read items older than 90 days, never starred 2. Nightly backup, a systemd unit, and the README README: how to add a feed (edit the OPML) and export it back, the polite-fetching behaviour as a promise, where the database lives. Files: `README.md` ### Done when - [ ] Pruning never deletes a starred item - [ ] Unread counts survive a restart ## M8 · Operate it like a product (production only) Only for the product-builder path: know when the reader is down, never lose the database, and keep the server patched. ### Steps 1. Add a /healthz endpoint and an external uptime check against it Answer 200 with the build id and a quick database read. Point a free uptime monitor (or your own, from the Healthchecks entry on this site) at it so an outage is noticed before a user notices. 2. Write structured request logs and rotate them One JSON line per request: method, path, status, duration, no raw IPs. Rotate weekly with logrotate, keep eight. 3. Back the SQLite file up off the machine nightly and test a restore SQLite's .backup command makes a consistent copy while the app runs. Copy it to object storage or a second machine; then, once, restore it into a fresh checkout and confirm the app reads it. ```sh sqlite3 data/app.db ".backup '/tmp/app-$(date +%F).db'" rclone copy /tmp/app-$(date +%F).db remote:backups/ ``` 4. Lock the box down Firewall allowing only 22, 80 and 443; unattended security updates on; the app running as an unprivileged user under systemd with Restart=on-failure. ### Done when - [ ] Stopping the service triggers an uptime alert within a few minutes - [ ] A restore from last night's backup contains yesterday's data - [ ] A port scan from another machine shows only 22, 80 and 443
# Operations · Feedly ## Backup SQLite .backup nightly. ## Restore Copy back; unread counts and stars are in the file. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Alert if the fetch loop has not run in an hour. ## Incident checklist If a site complains, disable the feed and check your User-Agent and interval. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] 304 handling verified against a real feed - [ ] Sanitizer verified against a hostile fixture - [ ] One restore drill performed ## Launch constraint Do not market omitted Feedly capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bound to 127.0.0.1 or your LAN. PORT=4830 # Required. SQLite file. DATABASE_PATH=./data/reader.db # Required. Your subscription list; the reader exports back to it. OPML_PATH=./feeds.opml # Required. Identifies your fetcher to the sites you read. USER_AGENT=MyReader/1.0 (+https://yourdomain.com/reader) # Optional. How often the loop runs. FETCH_INTERVAL_MINUTES=30 # Optional · secret. Enables summaries. Empty hides the feature entirely. ANTHROPIC_API_KEY=sk-ant-...
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay to avoid maintaining hundreds of feeds and reader apps across devices.
xmobile apps
xfeed discovery
xintegrations
xLeo/AI filtering
xuptime
xpolished reading UX
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Feedly pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $0/user | $0/user | Up to 100 sources; 3 Feeds; 3 Boards. |
| pro | $7/user | $5.42/user | Faster feed polling, search and integrations; annual total is $65. |
| pro+ | custom | — | AI Feeds, RSS Builder and Zapier; feed polling can be as frequent as 7 minutes. |
| enterprise | custom | — | Team feeds, administration and intelligence tools; numeric seat limits and price are quote-based. |
free tier100 sources; 3 Feeds; 3 Boards
billingPro offers monthly + annual; Pro+ is annual-only; Enterprise is quote-based
hidden costsself-service refunds are limited to 5 days after a charge; Enterprise cost scales by seats and selected intelligence features
verified 2026-08-13 · source ↗
Vibecode Feedly
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Feedly replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Feedly cost?
Feedly costs about $7/month (Pro, checked 2026-07-30), which is $84 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Feedly?
Honestly: mobile apps; feed discovery; integrations; Leo/AI filtering; uptime; polished reading UX. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Feedly?
Yes: Folo (A modern hosted research feed with 150 free subscriptions, one inbox and one automation.) NewsBlur (Feeds, newsletters and trainable filters, in exchange for running several databases.) The prompt is for when you want it exactly your way.