Vibecode Inoreader
track this build5 phases, 15 steps, beginner friendly0%A single-user RSS reader with rules and alerts can be built quickly on top of existing parsers, though not with Inoreader's scale and polish.
You are building a lean indie version of Inoreader. 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 ===== # Inoreader · indie build A reader that fetches feeds on a schedule with conditional requests so unchanged feeds cost almost nothing, identifies items stably so an edited post does not reappear as new, applies keyword and author rules to tag or mute, and sends alerts for the handful of things you actually want to be interrupted for. Estimated effort: **weekend**. 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 SQLite | a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup | | Fetching | Conditional requests with ETag and If-Modified-Since | an unchanged feed should cost a few hundred bytes, and publishers notice when it does not | | Parsing | A library handling RSS, Atom and JSON Feed | three formats with a long tail of malformed variants, none of which is worth writing | | Identity | A stable item key derived per feed | the single thing that decides whether your reader is usable or full of duplicates | | Rules | Ordered rules over title, content, author and feed | filtering is what makes a large subscription list survivable | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the fetcher and the reader. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Your real subscription list** · free - Why: Malformed feeds, feeds without identifiers and feeds that change identifiers on edit all exist in any real list and none of them appear in a handful of test feeds. - Get it: Export from whatever you read now. If you read nothing, collect thirty feeds you would actually subscribe to. - Verify: At least thirty real feeds import - [ ] **Three deliberately awkward feeds** · free - Why: Phase 2's identity work needs one feed without item identifiers, one that changes them on edit, and one with repeated titles. Without them the fallback chain is untested. - Get it: They are common; look through your own list for feeds whose items reappear as unread, which is the symptom. - Verify: All three are identified and their behaviour recorded - [ ] **Somewhere to run the fetcher continuously** (optional) · 5 to 10 US dollars a month, or free on hardware you own - Why: A reader that only fetches while your laptop is open misses things and then fetches a burst, which is both worse for you and ruder to publishers. - Get it: A small virtual server or a machine at home that stays on. - Verify: The fetcher runs on a schedule and survives a reboot - [ ] **An email or webhook destination for alerts** (optional) · free tiers cover this volume easily - Why: Phase 5 only. Everything before it works without any delivery. - Get it: A transactional email provider with a verified domain, or a webhook into a chat tool you already use. - Verify: A test alert arrives ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. - enterprise-scale feed handling - mobile apps - integrations - article translation - polished search - support If one of those is essential to you, that is the reason to keep paying for Inoreader, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Inoreader 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 feed reader that polls politely, never shows you the same item twice, and filters by rules you wrote to replace Inoreader. Build it in phases, in the order below. Do not write the whole thing 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) - Runtime: Node 22 with SQLite · a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup - Fetching: Conditional requests with ETag and If-Modified-Since · an unchanged feed should cost a few hundred bytes, and publishers notice when it does not - Parsing: A library handling RSS, Atom and JSON Feed · three formats with a long tail of malformed variants, none of which is worth writing - Identity: A stable item key derived per feed · the single thing that decides whether your reader is usable or full of duplicates - Rules: Ordered rules over title, content, author and feed · filtering is what makes a large subscription list survivable ### Data model (create this before Phase 1) - feeds(id, url, site_url, title, etag, last_modified, last_fetched_at, next_fetch_at, failure_count, interval_minutes) - items(id, feed_id, item_key, title, author, url, published_at, fetched_at, content, content_hash) - reads(item_id, read_at), stars(item_id, starred_at) - rules(id, priority, scope_feed_id, match_field, pattern, action, tag) · action is tag, mute or alert - tags(id, name), item_tags(item_id, tag_id) - fetch_log(feed_id, at, status, bytes, items_new, note) · so a feed that quietly stopped is visible - The item key is unique per feed. Choosing it well in Phase 2 is what stops the reader filling with duplicates. ### Phase 1 · Fetching without being rude Build: Fetch feeds on a schedule, sending the stored ETag and last modified date. Store both from every response. Back off on failures and on a rate limit response. Log every fetch with its status and byte count. Trap: Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. Done when: An unchanged feed returns a not-modified response and transfers almost no bytes, verified in the fetch log. A feed returning an error backs off with increasing intervals rather than retrying at the same rate. A feed that has failed many times is marked and reported rather than retried forever. Every fetch is logged with status and bytes. Do not build yet: No item storage, deduplication or rules. ### Phase 2 · Item identity, which decides everything Build: Derive a stable key per item: the feed's own identifier where it provides one, falling back to a hash of the link, falling back to a hash of title and date. Store a content hash separately so an edit updates rather than inserting. Trap: Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. Trap: Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. Done when: Re-fetching an unchanged feed creates no new items. A post edited upstream updates in place and does not become unread again. A feed with no item identifiers produces stable keys across fetches, verified by fetching twice. Two different posts with identical titles in the same feed remain two items. Do not build yet: No reading interface or rules. ### Phase 3 · Reading at volume Build: A reading view by folder, feed and tag, with unread counts. Mark as read on scroll past, and a mark-all with an undo. Full text search across items. Test with a realistic subscription list rather than five feeds. Trap: Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. Done when: A sidebar with three hundred feeds and two hundred thousand items renders unread counts without a visible pause. Marking all as read is one action and is undoable. Search across the archive returns in well under a hundred milliseconds. Scrolling past an item marks it read without a jump. Do not build yet: No rules or alerts yet. ### Phase 4 · Rules that tame the volume Build: Ordered rules matching on title, content, author or feed, with actions to tag, mute or alert. Rules apply to new items on arrival, not retroactively unless you ask. Show which rule acted on an item. Trap: Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. Done when: A rule tags matching new items and each item shows the rule that acted. A mute rule keeps items out of unread counts without deleting them. Changing a rule does not alter the archive until a retroactive run is invoked, and that run reports its count first. A rule matching nothing is visibly flagged rather than silently useless. Do not build yet: No alerts or export yet. ### Phase 5 · Alerts worth being interrupted by Build: Alert rules delivering by email or webhook, batched rather than per item, with a cap per period. Include enough context to decide without opening the reader. Make it trivial to turn an alert back into a plain tag when it fires too often. Trap: An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. Done when: An alert rule delivers a batched notification containing item titles and links. A rule matching fifty items in an hour delivers one capped notification reporting the count rather than fifty messages. Turning an alert into a tag is one action. A delivery failure is retried and then reported rather than lost. Do not build yet: Import and export come last. ### Phase 6 · Import, export and keeping it running Build: Import a standard subscription list and export one. Export items and stars as JSON. Run the fetcher as a service with a report of feeds that have stopped producing, which is different from feeds that are failing. Trap: A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. Done when: A standard subscription list imports with folders preserved and exports back. Items and stars export as JSON and re-import into an empty installation. Feeds that have failed and feeds that have gone quiet are reported separately. The fetcher runs as a service and survives a restart. Do not build yet: Nothing after this. ### Out of scope (and why) - Feed handling at the scale the hosted product manages, including feeds that need per-publisher workarounds. - Mobile applications with offline sync, which is how most people read. - The integration catalogue that pushes items into other tools. - Full text extraction for feeds that publish only summaries, which is a per-site parsing problem that never finishes. ### README must contain - That fetching uses conditional requests and how to verify you are getting not-modified responses - The item key fallback chain and why identity decides whether the reader is usable - That rules apply going forward and retroactive application is explicit - That alerts are batched and capped, and how to demote one - That failing feeds and quiet feeds are reported separately because the fix differs ===== AGENTS.md ===== # Agent instructions · Inoreader indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with SQLite, Conditional requests with ETag and If-Modified-Since, A library handling RSS, Atom and JSON Feed, A stable item key derived per feed, Ordered rules over title, content, author and feed. 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". ## Known traps - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ===== BUILD_PLAN.md ===== # Build plan · Inoreader A reader that fetches feeds on a schedule with conditional requests so unchanged feeds cost almost nothing, identifies items stably so an edited post does not reappear as new, applies keyword and author rules to tag or mute, and sends alerts for the handful of things you actually want to be interrupted for. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Fetching without being rude Feeds update, and an unchanged feed costs almost nothing. ### Steps 1. Send and store conditional headers ETag and If-Modified-Since from the last response. Handle 304 by doing nothing. Files: `src/fetch.js` 2. Back off on failure and rate limiting Exponential, respecting a Retry-After header when one is sent. Files: `src/backoff.js` 3. Log every fetch Status, bytes, new items. A feed that stopped publishing and one that stopped working look identical without this. Files: `src/log.js` ### Done when - [ ] An unchanged feed returns not-modified and transfers almost nothing - [ ] A failing feed backs off rather than retrying at the same rate - [ ] A persistently failing feed is marked and reported - [ ] Every fetch is logged with status and bytes ### Watch out - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. ## Phase 2 · Item identity, which decides everything An edited post does not reappear as new, and two feeds carrying the same post are two items rather than a mess. ### Steps 1. Derive the key with a documented fallback chain Identifier, then link, then title and date. Record which strategy each feed used. Files: `src/identity.js` 2. Track content separately A content hash so an edit updates the item rather than inserting a second one. Files: `src/items.js` 3. Test the awkward feeds One with no identifiers, one that changes them on edit, one with duplicate titles. All three exist. Files: `test/identity.test.js` ### Done when - [ ] Re-fetching an unchanged feed creates no items - [ ] An edited post updates rather than becoming unread - [ ] A feed without identifiers produces stable keys - [ ] Duplicate titles remain distinct items ### Watch out - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. ## Phase 3 · Reading at volume Three hundred feeds is navigable rather than a wall of unread. ### Steps 1. Generate a realistic archive Three hundred feeds and a couple of hundred thousand items. Every performance claim depends on it. Files: `scripts/generate.js` 2. Make unread counts cheap Incremental or indexed. The sidebar renders on every navigation. Files: `src/counts.js` 3. Add full text search Across titles and content, updated on insert. Files: `src/search.js` ### Done when - [ ] Unread counts render without a pause at realistic scale - [ ] Mark all as read is one undoable action - [ ] Search returns in under a hundred milliseconds - [ ] Scroll-past marking does not cause a jump ### Watch out - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. ## Phase 4 · Rules that tame the volume The things you care about surface and the noise disappears without unsubscribing. ### Steps 1. Evaluate rules on arrival In priority order, recording the acting rule on the item. Files: `src/rules.js` 2. Make mute non-destructive Out of unread, still findable by search. Deleting is a different, rarer intention. Files: `src/rules.js` 3. Report zero-match rules A rule that has never matched is usually a typo, and it is invisible without a report. Files: `src/rules.js` ### Done when - [ ] A rule tags new items and the acting rule is visible - [ ] A mute removes from unread without deleting - [ ] Retroactive application is explicit and reports its count - [ ] A rule matching nothing is flagged ### Watch out - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. ## Phase 5 · Alerts worth being interrupted by You are notified about the few things that matter and not about everything else. ### Steps 1. Batch and cap deliveries One message per period with a maximum. Report when the cap was applied. Files: `src/alerts.js` 2. Include decisive context Titles, feeds and links. Enough to judge without opening anything. Files: `src/alerts.js` 3. Make demotion easy One action from the notification or the rule list. Alert fatigue is the failure mode here. Files: `src/rules.js` ### Done when - [ ] An alert delivers a batched notification with context - [ ] A high-matching rule produces one capped notification - [ ] Demoting an alert to a tag is one action - [ ] A failed delivery is retried then reported ### Watch out - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ## Not in this build - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. ## After v1, if you want it - Full text fetch for a small allowlist of sites you read closely - A daily digest of the highest-priority tags, replacing per-item alerts - Duplicate detection across feeds, since syndicated posts arrive several times ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Feeds, items and rules. One file, and the whole backup. DB_PATH=./data/reader.db # Required. Base polling interval. Conditional requests make this cheap, but it is still someone's server. DEFAULT_FETCH_MINUTES=30 # Required. Longest interval after repeated failures or a consistently unchanged feed. MAX_FETCH_MINUTES=720 # Required. How often alerts are delivered. Batching is what prevents notification fatigue. ALERT_BATCH_MINUTES=60 # Required. Cap per delivery. Exceeding it reports a count rather than sending everything. ALERT_MAX_PER_BATCH=20 # Required. How long without new items before a feed is reported as quiet rather than working. QUIET_FEED_DAYS=120
You are building a lean indie version of Inoreader. 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 ===== # Inoreader · indie build A reader that fetches feeds on a schedule with conditional requests so unchanged feeds cost almost nothing, identifies items stably so an edited post does not reappear as new, applies keyword and author rules to tag or mute, and sends alerts for the handful of things you actually want to be interrupted for. Estimated effort: **weekend**. 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 SQLite | a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup | | Fetching | Conditional requests with ETag and If-Modified-Since | an unchanged feed should cost a few hundred bytes, and publishers notice when it does not | | Parsing | A library handling RSS, Atom and JSON Feed | three formats with a long tail of malformed variants, none of which is worth writing | | Identity | A stable item key derived per feed | the single thing that decides whether your reader is usable or full of duplicates | | Rules | Ordered rules over title, content, author and feed | filtering is what makes a large subscription list survivable | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the fetcher and the reader. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Your real subscription list** · free - Why: Malformed feeds, feeds without identifiers and feeds that change identifiers on edit all exist in any real list and none of them appear in a handful of test feeds. - Get it: Export from whatever you read now. If you read nothing, collect thirty feeds you would actually subscribe to. - Verify: At least thirty real feeds import - [ ] **Three deliberately awkward feeds** · free - Why: Phase 2's identity work needs one feed without item identifiers, one that changes them on edit, and one with repeated titles. Without them the fallback chain is untested. - Get it: They are common; look through your own list for feeds whose items reappear as unread, which is the symptom. - Verify: All three are identified and their behaviour recorded - [ ] **Somewhere to run the fetcher continuously** (optional) · 5 to 10 US dollars a month, or free on hardware you own - Why: A reader that only fetches while your laptop is open misses things and then fetches a burst, which is both worse for you and ruder to publishers. - Get it: A small virtual server or a machine at home that stays on. - Verify: The fetcher runs on a schedule and survives a reboot - [ ] **An email or webhook destination for alerts** (optional) · free tiers cover this volume easily - Why: Phase 5 only. Everything before it works without any delivery. - Get it: A transactional email provider with a verified domain, or a webhook into a chat tool you already use. - Verify: A test alert arrives ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. - enterprise-scale feed handling - mobile apps - integrations - article translation - polished search - support If one of those is essential to you, that is the reason to keep paying for Inoreader, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Inoreader 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 feed reader that polls politely, never shows you the same item twice, and filters by rules you wrote to replace Inoreader. Build it in phases, in the order below. Do not write the whole thing 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) - Runtime: Node 22 with SQLite · a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup - Fetching: Conditional requests with ETag and If-Modified-Since · an unchanged feed should cost a few hundred bytes, and publishers notice when it does not - Parsing: A library handling RSS, Atom and JSON Feed · three formats with a long tail of malformed variants, none of which is worth writing - Identity: A stable item key derived per feed · the single thing that decides whether your reader is usable or full of duplicates - Rules: Ordered rules over title, content, author and feed · filtering is what makes a large subscription list survivable ### Data model (create this before Phase 1) - feeds(id, url, site_url, title, etag, last_modified, last_fetched_at, next_fetch_at, failure_count, interval_minutes) - items(id, feed_id, item_key, title, author, url, published_at, fetched_at, content, content_hash) - reads(item_id, read_at), stars(item_id, starred_at) - rules(id, priority, scope_feed_id, match_field, pattern, action, tag) · action is tag, mute or alert - tags(id, name), item_tags(item_id, tag_id) - fetch_log(feed_id, at, status, bytes, items_new, note) · so a feed that quietly stopped is visible - The item key is unique per feed. Choosing it well in Phase 2 is what stops the reader filling with duplicates. ### Phase 1 · Fetching without being rude Build: Fetch feeds on a schedule, sending the stored ETag and last modified date. Store both from every response. Back off on failures and on a rate limit response. Log every fetch with its status and byte count. Trap: Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. Done when: An unchanged feed returns a not-modified response and transfers almost no bytes, verified in the fetch log. A feed returning an error backs off with increasing intervals rather than retrying at the same rate. A feed that has failed many times is marked and reported rather than retried forever. Every fetch is logged with status and bytes. Do not build yet: No item storage, deduplication or rules. ### Phase 2 · Item identity, which decides everything Build: Derive a stable key per item: the feed's own identifier where it provides one, falling back to a hash of the link, falling back to a hash of title and date. Store a content hash separately so an edit updates rather than inserting. Trap: Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. Trap: Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. Done when: Re-fetching an unchanged feed creates no new items. A post edited upstream updates in place and does not become unread again. A feed with no item identifiers produces stable keys across fetches, verified by fetching twice. Two different posts with identical titles in the same feed remain two items. Do not build yet: No reading interface or rules. ### Phase 3 · Reading at volume Build: A reading view by folder, feed and tag, with unread counts. Mark as read on scroll past, and a mark-all with an undo. Full text search across items. Test with a realistic subscription list rather than five feeds. Trap: Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. Done when: A sidebar with three hundred feeds and two hundred thousand items renders unread counts without a visible pause. Marking all as read is one action and is undoable. Search across the archive returns in well under a hundred milliseconds. Scrolling past an item marks it read without a jump. Do not build yet: No rules or alerts yet. ### Phase 4 · Rules that tame the volume Build: Ordered rules matching on title, content, author or feed, with actions to tag, mute or alert. Rules apply to new items on arrival, not retroactively unless you ask. Show which rule acted on an item. Trap: Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. Done when: A rule tags matching new items and each item shows the rule that acted. A mute rule keeps items out of unread counts without deleting them. Changing a rule does not alter the archive until a retroactive run is invoked, and that run reports its count first. A rule matching nothing is visibly flagged rather than silently useless. Do not build yet: No alerts or export yet. ### Phase 5 · Alerts worth being interrupted by Build: Alert rules delivering by email or webhook, batched rather than per item, with a cap per period. Include enough context to decide without opening the reader. Make it trivial to turn an alert back into a plain tag when it fires too often. Trap: An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. Done when: An alert rule delivers a batched notification containing item titles and links. A rule matching fifty items in an hour delivers one capped notification reporting the count rather than fifty messages. Turning an alert into a tag is one action. A delivery failure is retried and then reported rather than lost. Do not build yet: Import and export come last. ### Phase 6 · Import, export and keeping it running Build: Import a standard subscription list and export one. Export items and stars as JSON. Run the fetcher as a service with a report of feeds that have stopped producing, which is different from feeds that are failing. Trap: A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. Done when: A standard subscription list imports with folders preserved and exports back. Items and stars export as JSON and re-import into an empty installation. Feeds that have failed and feeds that have gone quiet are reported separately. The fetcher runs as a service and survives a restart. Do not build yet: Nothing after this. ### Out of scope (and why) - Feed handling at the scale the hosted product manages, including feeds that need per-publisher workarounds. - Mobile applications with offline sync, which is how most people read. - The integration catalogue that pushes items into other tools. - Full text extraction for feeds that publish only summaries, which is a per-site parsing problem that never finishes. ### README must contain - That fetching uses conditional requests and how to verify you are getting not-modified responses - The item key fallback chain and why identity decides whether the reader is usable - That rules apply going forward and retroactive application is explicit - That alerts are batched and capped, and how to demote one - That failing feeds and quiet feeds are reported separately because the fix differs ===== AGENTS.md ===== # Agent instructions · Inoreader indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with SQLite, Conditional requests with ETag and If-Modified-Since, A library handling RSS, Atom and JSON Feed, A stable item key derived per feed, Ordered rules over title, content, author and feed. 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". ## Known traps - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ===== BUILD_PLAN.md ===== # Build plan · Inoreader A reader that fetches feeds on a schedule with conditional requests so unchanged feeds cost almost nothing, identifies items stably so an edited post does not reappear as new, applies keyword and author rules to tag or mute, and sends alerts for the handful of things you actually want to be interrupted for. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Fetching without being rude Feeds update, and an unchanged feed costs almost nothing. ### Steps 1. Send and store conditional headers ETag and If-Modified-Since from the last response. Handle 304 by doing nothing. Files: `src/fetch.js` 2. Back off on failure and rate limiting Exponential, respecting a Retry-After header when one is sent. Files: `src/backoff.js` 3. Log every fetch Status, bytes, new items. A feed that stopped publishing and one that stopped working look identical without this. Files: `src/log.js` ### Done when - [ ] An unchanged feed returns not-modified and transfers almost nothing - [ ] A failing feed backs off rather than retrying at the same rate - [ ] A persistently failing feed is marked and reported - [ ] Every fetch is logged with status and bytes ### Watch out - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. ## Phase 2 · Item identity, which decides everything An edited post does not reappear as new, and two feeds carrying the same post are two items rather than a mess. ### Steps 1. Derive the key with a documented fallback chain Identifier, then link, then title and date. Record which strategy each feed used. Files: `src/identity.js` 2. Track content separately A content hash so an edit updates the item rather than inserting a second one. Files: `src/items.js` 3. Test the awkward feeds One with no identifiers, one that changes them on edit, one with duplicate titles. All three exist. Files: `test/identity.test.js` ### Done when - [ ] Re-fetching an unchanged feed creates no items - [ ] An edited post updates rather than becoming unread - [ ] A feed without identifiers produces stable keys - [ ] Duplicate titles remain distinct items ### Watch out - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. ## Phase 3 · Reading at volume Three hundred feeds is navigable rather than a wall of unread. ### Steps 1. Generate a realistic archive Three hundred feeds and a couple of hundred thousand items. Every performance claim depends on it. Files: `scripts/generate.js` 2. Make unread counts cheap Incremental or indexed. The sidebar renders on every navigation. Files: `src/counts.js` 3. Add full text search Across titles and content, updated on insert. Files: `src/search.js` ### Done when - [ ] Unread counts render without a pause at realistic scale - [ ] Mark all as read is one undoable action - [ ] Search returns in under a hundred milliseconds - [ ] Scroll-past marking does not cause a jump ### Watch out - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. ## Phase 4 · Rules that tame the volume The things you care about surface and the noise disappears without unsubscribing. ### Steps 1. Evaluate rules on arrival In priority order, recording the acting rule on the item. Files: `src/rules.js` 2. Make mute non-destructive Out of unread, still findable by search. Deleting is a different, rarer intention. Files: `src/rules.js` 3. Report zero-match rules A rule that has never matched is usually a typo, and it is invisible without a report. Files: `src/rules.js` ### Done when - [ ] A rule tags new items and the acting rule is visible - [ ] A mute removes from unread without deleting - [ ] Retroactive application is explicit and reports its count - [ ] A rule matching nothing is flagged ### Watch out - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. ## Phase 5 · Alerts worth being interrupted by You are notified about the few things that matter and not about everything else. ### Steps 1. Batch and cap deliveries One message per period with a maximum. Report when the cap was applied. Files: `src/alerts.js` 2. Include decisive context Titles, feeds and links. Enough to judge without opening anything. Files: `src/alerts.js` 3. Make demotion easy One action from the notification or the rule list. Alert fatigue is the failure mode here. Files: `src/rules.js` ### Done when - [ ] An alert delivers a batched notification with context - [ ] A high-matching rule produces one capped notification - [ ] Demoting an alert to a tag is one action - [ ] A failed delivery is retried then reported ### Watch out - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ## Not in this build - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. ## After v1, if you want it - Full text fetch for a small allowlist of sites you read closely - A daily digest of the highest-priority tags, replacing per-item alerts - Duplicate detection across feeds, since syndicated posts arrive several times ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Feeds, items and rules. One file, and the whole backup. DB_PATH=./data/reader.db # Required. Base polling interval. Conditional requests make this cheap, but it is still someone's server. DEFAULT_FETCH_MINUTES=30 # Required. Longest interval after repeated failures or a consistently unchanged feed. MAX_FETCH_MINUTES=720 # Required. How often alerts are delivered. Batching is what prevents notification fatigue. ALERT_BATCH_MINUTES=60 # Required. Cap per delivery. Exceeding it reports a count rather than sending everything. ALERT_MAX_PER_BATCH=20 # Required. How long without new items before a feed is reported as quiet rather than working. QUIET_FEED_DAYS=120
You are building a production product version of Inoreader. 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 ===== # Inoreader · product brief ## Problem A single-user RSS reader with rules and alerts can be built quickly on top of existing parsers, though not with Inoreader's scale and polish. ## Product outcome A reader that costs publishers almost nothing to poll, never shows the same item twice, stays fast with a large archive, filters the noise by rules you wrote, and interrupts you only for what you asked to be interrupted about. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - RSS parser - database - background jobs - email/webhook notifications - hosted app or local service ## Explicit non-goals for v1 - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. - enterprise-scale feed handling - mobile apps - integrations - article translation - polished search - support ## Success criteria - An unchanged feed is confirmed to return not-modified with negligible bytes - All three awkward feeds produce stable identity across fetches - Unread counts render without pause on the large fixture - A high-matching alert rule is confirmed to produce one capped notification - A subscription list and item export round trip ===== BRIEF.md ===== # Build brief · Inoreader 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 feed reader that polls politely, never shows you the same item twice, and filters by rules you wrote to replace Inoreader. Build it in phases, in the order below. Do not write the whole thing 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) - Runtime: Node 22 with SQLite · a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup - Fetching: Conditional requests with ETag and If-Modified-Since · an unchanged feed should cost a few hundred bytes, and publishers notice when it does not - Parsing: A library handling RSS, Atom and JSON Feed · three formats with a long tail of malformed variants, none of which is worth writing - Identity: A stable item key derived per feed · the single thing that decides whether your reader is usable or full of duplicates - Rules: Ordered rules over title, content, author and feed · filtering is what makes a large subscription list survivable ### Data model (create this before Phase 1) - feeds(id, url, site_url, title, etag, last_modified, last_fetched_at, next_fetch_at, failure_count, interval_minutes) - items(id, feed_id, item_key, title, author, url, published_at, fetched_at, content, content_hash) - reads(item_id, read_at), stars(item_id, starred_at) - rules(id, priority, scope_feed_id, match_field, pattern, action, tag) · action is tag, mute or alert - tags(id, name), item_tags(item_id, tag_id) - fetch_log(feed_id, at, status, bytes, items_new, note) · so a feed that quietly stopped is visible - The item key is unique per feed. Choosing it well in Phase 2 is what stops the reader filling with duplicates. ### Phase 1 · Fetching without being rude Build: Fetch feeds on a schedule, sending the stored ETag and last modified date. Store both from every response. Back off on failures and on a rate limit response. Log every fetch with its status and byte count. Trap: Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. Done when: An unchanged feed returns a not-modified response and transfers almost no bytes, verified in the fetch log. A feed returning an error backs off with increasing intervals rather than retrying at the same rate. A feed that has failed many times is marked and reported rather than retried forever. Every fetch is logged with status and bytes. Do not build yet: No item storage, deduplication or rules. ### Phase 2 · Item identity, which decides everything Build: Derive a stable key per item: the feed's own identifier where it provides one, falling back to a hash of the link, falling back to a hash of title and date. Store a content hash separately so an edit updates rather than inserting. Trap: Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. Trap: Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. Done when: Re-fetching an unchanged feed creates no new items. A post edited upstream updates in place and does not become unread again. A feed with no item identifiers produces stable keys across fetches, verified by fetching twice. Two different posts with identical titles in the same feed remain two items. Do not build yet: No reading interface or rules. ### Phase 3 · Reading at volume Build: A reading view by folder, feed and tag, with unread counts. Mark as read on scroll past, and a mark-all with an undo. Full text search across items. Test with a realistic subscription list rather than five feeds. Trap: Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. Done when: A sidebar with three hundred feeds and two hundred thousand items renders unread counts without a visible pause. Marking all as read is one action and is undoable. Search across the archive returns in well under a hundred milliseconds. Scrolling past an item marks it read without a jump. Do not build yet: No rules or alerts yet. ### Phase 4 · Rules that tame the volume Build: Ordered rules matching on title, content, author or feed, with actions to tag, mute or alert. Rules apply to new items on arrival, not retroactively unless you ask. Show which rule acted on an item. Trap: Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. Done when: A rule tags matching new items and each item shows the rule that acted. A mute rule keeps items out of unread counts without deleting them. Changing a rule does not alter the archive until a retroactive run is invoked, and that run reports its count first. A rule matching nothing is visibly flagged rather than silently useless. Do not build yet: No alerts or export yet. ### Phase 5 · Alerts worth being interrupted by Build: Alert rules delivering by email or webhook, batched rather than per item, with a cap per period. Include enough context to decide without opening the reader. Make it trivial to turn an alert back into a plain tag when it fires too often. Trap: An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. Done when: An alert rule delivers a batched notification containing item titles and links. A rule matching fifty items in an hour delivers one capped notification reporting the count rather than fifty messages. Turning an alert into a tag is one action. A delivery failure is retried and then reported rather than lost. Do not build yet: Import and export come last. ### Phase 6 · Import, export and keeping it running Build: Import a standard subscription list and export one. Export items and stars as JSON. Run the fetcher as a service with a report of feeds that have stopped producing, which is different from feeds that are failing. Trap: A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. Done when: A standard subscription list imports with folders preserved and exports back. Items and stars export as JSON and re-import into an empty installation. Feeds that have failed and feeds that have gone quiet are reported separately. The fetcher runs as a service and survives a restart. Do not build yet: Nothing after this. ### Out of scope (and why) - Feed handling at the scale the hosted product manages, including feeds that need per-publisher workarounds. - Mobile applications with offline sync, which is how most people read. - The integration catalogue that pushes items into other tools. - Full text extraction for feeds that publish only summaries, which is a per-site parsing problem that never finishes. ### README must contain - That fetching uses conditional requests and how to verify you are getting not-modified responses - The item key fallback chain and why identity decides whether the reader is usable - That rules apply going forward and retroactive application is explicit - That alerts are batched and capped, and how to demote one - That failing feeds and quiet feeds are reported separately because the fix differs ===== ARCHITECTURE.md ===== # Architecture · Inoreader ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with SQLite | a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup | | Fetching | Conditional requests with ETag and If-Modified-Since | an unchanged feed should cost a few hundred bytes, and publishers notice when it does not | | Parsing | A library handling RSS, Atom and JSON Feed | three formats with a long tail of malformed variants, none of which is worth writing | | Identity | A stable item key derived per feed | the single thing that decides whether your reader is usable or full of duplicates | | Rules | Ordered rules over title, content, author and feed | filtering is what makes a large subscription list survivable | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Fetch | Polling, conditional requests and backoff. | Any client, provided conditional headers survive. Dropping them is rude and expensive. | | Identity | Deriving stable item keys. | This is the module that decides whether the product works. Its fallback chain should be documented and tested rather than adjusted casually. | | Rules | Tagging, muting and alerting. | Pure evaluation over items, testable with no network. | | Reading | Counts, navigation and search. | Presentation, provided counts stay cheap at realistic archive sizes. | | Alerts | Batched delivery with a cap. | Any transport. The batching and cap are what keep alerts useful. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Feeds, items and rules. One file, and the whole backup. - `DEFAULT_FETCH_MINUTES` · required · Base polling interval. Conditional requests make this cheap, but it is still someone's server. - `MAX_FETCH_MINUTES` · required · Longest interval after repeated failures or a consistently unchanged feed. - `ALERT_BATCH_MINUTES` · required · How often alerts are delivered. Batching is what prevents notification fatigue. - `ALERT_MAX_PER_BATCH` · required · Cap per delivery. Exceeding it reports a count rather than sending everything. - `QUIET_FEED_DAYS` · required · How long without new items before a feed is reported as quiet rather than working. ## 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 · Inoreader product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with SQLite, Conditional requests with ETag and If-Modified-Since, A library handling RSS, Atom and JSON Feed, A stable item key derived per feed, Ordered rules over title, content, author and feed. - 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. ## Known traps - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. - A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. ===== MILESTONES.md ===== # Delivery milestones · Inoreader Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Fetching without being rude Feeds update, and an unchanged feed costs almost nothing. ### Steps 1. Send and store conditional headers ETag and If-Modified-Since from the last response. Handle 304 by doing nothing. Files: `src/fetch.js` 2. Back off on failure and rate limiting Exponential, respecting a Retry-After header when one is sent. Files: `src/backoff.js` 3. Log every fetch Status, bytes, new items. A feed that stopped publishing and one that stopped working look identical without this. Files: `src/log.js` ### Done when - [ ] An unchanged feed returns not-modified and transfers almost nothing - [ ] A failing feed backs off rather than retrying at the same rate - [ ] A persistently failing feed is marked and reported - [ ] Every fetch is logged with status and bytes ### Watch out - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. ## M2 · Item identity, which decides everything An edited post does not reappear as new, and two feeds carrying the same post are two items rather than a mess. ### Steps 1. Derive the key with a documented fallback chain Identifier, then link, then title and date. Record which strategy each feed used. Files: `src/identity.js` 2. Track content separately A content hash so an edit updates the item rather than inserting a second one. Files: `src/items.js` 3. Test the awkward feeds One with no identifiers, one that changes them on edit, one with duplicate titles. All three exist. Files: `test/identity.test.js` ### Done when - [ ] Re-fetching an unchanged feed creates no items - [ ] An edited post updates rather than becoming unread - [ ] A feed without identifiers produces stable keys - [ ] Duplicate titles remain distinct items ### Watch out - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. ## M3 · Reading at volume Three hundred feeds is navigable rather than a wall of unread. ### Steps 1. Generate a realistic archive Three hundred feeds and a couple of hundred thousand items. Every performance claim depends on it. Files: `scripts/generate.js` 2. Make unread counts cheap Incremental or indexed. The sidebar renders on every navigation. Files: `src/counts.js` 3. Add full text search Across titles and content, updated on insert. Files: `src/search.js` ### Done when - [ ] Unread counts render without a pause at realistic scale - [ ] Mark all as read is one undoable action - [ ] Search returns in under a hundred milliseconds - [ ] Scroll-past marking does not cause a jump ### Watch out - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. ## M4 · Rules that tame the volume The things you care about surface and the noise disappears without unsubscribing. ### Steps 1. Evaluate rules on arrival In priority order, recording the acting rule on the item. Files: `src/rules.js` 2. Make mute non-destructive Out of unread, still findable by search. Deleting is a different, rarer intention. Files: `src/rules.js` 3. Report zero-match rules A rule that has never matched is usually a typo, and it is invisible without a report. Files: `src/rules.js` ### Done when - [ ] A rule tags new items and the acting rule is visible - [ ] A mute removes from unread without deleting - [ ] Retroactive application is explicit and reports its count - [ ] A rule matching nothing is flagged ### Watch out - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. ## M5 · Alerts worth being interrupted by You are notified about the few things that matter and not about everything else. ### Steps 1. Batch and cap deliveries One message per period with a maximum. Report when the cap was applied. Files: `src/alerts.js` 2. Include decisive context Titles, feeds and links. Enough to judge without opening anything. Files: `src/alerts.js` 3. Make demotion easy One action from the notification or the rule list. Alert fatigue is the failure mode here. Files: `src/rules.js` ### Done when - [ ] An alert delivers a batched notification with context - [ ] A high-matching rule produces one capped notification - [ ] Demoting an alert to a tag is one action - [ ] A failed delivery is retried then reported ### Watch out - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ## M6 · Import, export and keeping it running (production only) You can arrive with your subscriptions and leave with everything. ### Steps 1. Support the standard subscription format Import and export with folders. It is how people arrive and how they leave. Files: `src/opml.js` 2. Export items and stars JSON, complete, re-importable. Stars in particular represent real curation. Files: `src/export.js` 3. Report quiet feeds separately Failing and quiet are different problems. A quiet feed usually moved. Files: `src/health.js` ### Done when - [ ] A subscription list imports with folders and exports back - [ ] Items and stars round trip into an empty installation - [ ] Failing and quiet feeds are reported separately - [ ] The fetcher survives a restart ### Watch out - A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. ===== OPERATIONS.md ===== # Operations · Inoreader ## Backup One database file holding feeds, items, stars and rules. Stars and rules are the parts representing real work; items are regenerable in part but history is not. ## Restore Restore the file and let the fetcher run. Conditional headers stored with each feed mean the first run after a restore is cheap rather than a full refetch of everything. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failing feeds, quiet feeds and total bytes fetched per day. A rising byte count usually means conditional requests stopped working, which is both a cost and a politeness problem. ## Incident checklist If duplicates appear, the item key strategy for that feed changed, and the fetch log plus the recorded strategy is where to look. If a feed starts returning errors, backoff should already have handled it; a feed still hammering is a bug in the backoff. 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 - [ ] An unchanged feed is confirmed to return not-modified with negligible bytes - [ ] All three awkward feeds produce stable identity across fetches - [ ] Unread counts render without pause on the large fixture - [ ] A high-matching alert rule is confirmed to produce one capped notification - [ ] A subscription list and item export round trip ## Launch constraint Do not market omitted Inoreader 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. Feeds, items and rules. One file, and the whole backup. DB_PATH=./data/reader.db # Required. Base polling interval. Conditional requests make this cheap, but it is still someone's server. DEFAULT_FETCH_MINUTES=30 # Required. Longest interval after repeated failures or a consistently unchanged feed. MAX_FETCH_MINUTES=720 # Required. How often alerts are delivered. Batching is what prevents notification fatigue. ALERT_BATCH_MINUTES=60 # Required. Cap per delivery. Exceeding it reports a count rather than sending everything. ALERT_MAX_PER_BATCH=20 # Required. How long without new items before a feed is reported as quiet rather than working. QUIET_FEED_DAYS=120
# Inoreader · indie build A reader that fetches feeds on a schedule with conditional requests so unchanged feeds cost almost nothing, identifies items stably so an edited post does not reappear as new, applies keyword and author rules to tag or mute, and sends alerts for the handful of things you actually want to be interrupted for. Estimated effort: **weekend**. 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 SQLite | a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup | | Fetching | Conditional requests with ETag and If-Modified-Since | an unchanged feed should cost a few hundred bytes, and publishers notice when it does not | | Parsing | A library handling RSS, Atom and JSON Feed | three formats with a long tail of malformed variants, none of which is worth writing | | Identity | A stable item key derived per feed | the single thing that decides whether your reader is usable or full of duplicates | | Rules | Ordered rules over title, content, author and feed | filtering is what makes a large subscription list survivable | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the fetcher and the reader. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **Your real subscription list** · free - Why: Malformed feeds, feeds without identifiers and feeds that change identifiers on edit all exist in any real list and none of them appear in a handful of test feeds. - Get it: Export from whatever you read now. If you read nothing, collect thirty feeds you would actually subscribe to. - Verify: At least thirty real feeds import - [ ] **Three deliberately awkward feeds** · free - Why: Phase 2's identity work needs one feed without item identifiers, one that changes them on edit, and one with repeated titles. Without them the fallback chain is untested. - Get it: They are common; look through your own list for feeds whose items reappear as unread, which is the symptom. - Verify: All three are identified and their behaviour recorded - [ ] **Somewhere to run the fetcher continuously** (optional) · 5 to 10 US dollars a month, or free on hardware you own - Why: A reader that only fetches while your laptop is open misses things and then fetches a burst, which is both worse for you and ruder to publishers. - Get it: A small virtual server or a machine at home that stays on. - Verify: The fetcher runs on a schedule and survives a reboot - [ ] **An email or webhook destination for alerts** (optional) · free tiers cover this volume easily - Why: Phase 5 only. Everything before it works without any delivery. - Get it: A transactional email provider with a verified domain, or a webhook into a chat tool you already use. - Verify: A test alert arrives ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. - enterprise-scale feed handling - mobile apps - integrations - article translation - polished search - support If one of those is essential to you, that is the reason to keep paying for Inoreader, and the README should say so rather than pretend.
# Build brief · Inoreader 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 feed reader that polls politely, never shows you the same item twice, and filters by rules you wrote to replace Inoreader. Build it in phases, in the order below. Do not write the whole thing 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) - Runtime: Node 22 with SQLite · a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup - Fetching: Conditional requests with ETag and If-Modified-Since · an unchanged feed should cost a few hundred bytes, and publishers notice when it does not - Parsing: A library handling RSS, Atom and JSON Feed · three formats with a long tail of malformed variants, none of which is worth writing - Identity: A stable item key derived per feed · the single thing that decides whether your reader is usable or full of duplicates - Rules: Ordered rules over title, content, author and feed · filtering is what makes a large subscription list survivable ### Data model (create this before Phase 1) - feeds(id, url, site_url, title, etag, last_modified, last_fetched_at, next_fetch_at, failure_count, interval_minutes) - items(id, feed_id, item_key, title, author, url, published_at, fetched_at, content, content_hash) - reads(item_id, read_at), stars(item_id, starred_at) - rules(id, priority, scope_feed_id, match_field, pattern, action, tag) · action is tag, mute or alert - tags(id, name), item_tags(item_id, tag_id) - fetch_log(feed_id, at, status, bytes, items_new, note) · so a feed that quietly stopped is visible - The item key is unique per feed. Choosing it well in Phase 2 is what stops the reader filling with duplicates. ### Phase 1 · Fetching without being rude Build: Fetch feeds on a schedule, sending the stored ETag and last modified date. Store both from every response. Back off on failures and on a rate limit response. Log every fetch with its status and byte count. Trap: Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. Done when: An unchanged feed returns a not-modified response and transfers almost no bytes, verified in the fetch log. A feed returning an error backs off with increasing intervals rather than retrying at the same rate. A feed that has failed many times is marked and reported rather than retried forever. Every fetch is logged with status and bytes. Do not build yet: No item storage, deduplication or rules. ### Phase 2 · Item identity, which decides everything Build: Derive a stable key per item: the feed's own identifier where it provides one, falling back to a hash of the link, falling back to a hash of title and date. Store a content hash separately so an edit updates rather than inserting. Trap: Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. Trap: Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. Done when: Re-fetching an unchanged feed creates no new items. A post edited upstream updates in place and does not become unread again. A feed with no item identifiers produces stable keys across fetches, verified by fetching twice. Two different posts with identical titles in the same feed remain two items. Do not build yet: No reading interface or rules. ### Phase 3 · Reading at volume Build: A reading view by folder, feed and tag, with unread counts. Mark as read on scroll past, and a mark-all with an undo. Full text search across items. Test with a realistic subscription list rather than five feeds. Trap: Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. Done when: A sidebar with three hundred feeds and two hundred thousand items renders unread counts without a visible pause. Marking all as read is one action and is undoable. Search across the archive returns in well under a hundred milliseconds. Scrolling past an item marks it read without a jump. Do not build yet: No rules or alerts yet. ### Phase 4 · Rules that tame the volume Build: Ordered rules matching on title, content, author or feed, with actions to tag, mute or alert. Rules apply to new items on arrival, not retroactively unless you ask. Show which rule acted on an item. Trap: Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. Done when: A rule tags matching new items and each item shows the rule that acted. A mute rule keeps items out of unread counts without deleting them. Changing a rule does not alter the archive until a retroactive run is invoked, and that run reports its count first. A rule matching nothing is visibly flagged rather than silently useless. Do not build yet: No alerts or export yet. ### Phase 5 · Alerts worth being interrupted by Build: Alert rules delivering by email or webhook, batched rather than per item, with a cap per period. Include enough context to decide without opening the reader. Make it trivial to turn an alert back into a plain tag when it fires too often. Trap: An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. Done when: An alert rule delivers a batched notification containing item titles and links. A rule matching fifty items in an hour delivers one capped notification reporting the count rather than fifty messages. Turning an alert into a tag is one action. A delivery failure is retried and then reported rather than lost. Do not build yet: Import and export come last. ### Phase 6 · Import, export and keeping it running Build: Import a standard subscription list and export one. Export items and stars as JSON. Run the fetcher as a service with a report of feeds that have stopped producing, which is different from feeds that are failing. Trap: A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. Done when: A standard subscription list imports with folders preserved and exports back. Items and stars export as JSON and re-import into an empty installation. Feeds that have failed and feeds that have gone quiet are reported separately. The fetcher runs as a service and survives a restart. Do not build yet: Nothing after this. ### Out of scope (and why) - Feed handling at the scale the hosted product manages, including feeds that need per-publisher workarounds. - Mobile applications with offline sync, which is how most people read. - The integration catalogue that pushes items into other tools. - Full text extraction for feeds that publish only summaries, which is a per-site parsing problem that never finishes. ### README must contain - That fetching uses conditional requests and how to verify you are getting not-modified responses - The item key fallback chain and why identity decides whether the reader is usable - That rules apply going forward and retroactive application is explicit - That alerts are batched and capped, and how to demote one - That failing feeds and quiet feeds are reported separately because the fix differs
# Agent instructions · Inoreader indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with SQLite, Conditional requests with ETag and If-Modified-Since, A library handling RSS, Atom and JSON Feed, A stable item key derived per feed, Ordered rules over title, content, author and feed. 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". ## Known traps - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it.
# Build plan · Inoreader A reader that fetches feeds on a schedule with conditional requests so unchanged feeds cost almost nothing, identifies items stably so an edited post does not reappear as new, applies keyword and author rules to tag or mute, and sends alerts for the handful of things you actually want to be interrupted for. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Fetching without being rude Feeds update, and an unchanged feed costs almost nothing. ### Steps 1. Send and store conditional headers ETag and If-Modified-Since from the last response. Handle 304 by doing nothing. Files: `src/fetch.js` 2. Back off on failure and rate limiting Exponential, respecting a Retry-After header when one is sent. Files: `src/backoff.js` 3. Log every fetch Status, bytes, new items. A feed that stopped publishing and one that stopped working look identical without this. Files: `src/log.js` ### Done when - [ ] An unchanged feed returns not-modified and transfers almost nothing - [ ] A failing feed backs off rather than retrying at the same rate - [ ] A persistently failing feed is marked and reported - [ ] Every fetch is logged with status and bytes ### Watch out - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. ## Phase 2 · Item identity, which decides everything An edited post does not reappear as new, and two feeds carrying the same post are two items rather than a mess. ### Steps 1. Derive the key with a documented fallback chain Identifier, then link, then title and date. Record which strategy each feed used. Files: `src/identity.js` 2. Track content separately A content hash so an edit updates the item rather than inserting a second one. Files: `src/items.js` 3. Test the awkward feeds One with no identifiers, one that changes them on edit, one with duplicate titles. All three exist. Files: `test/identity.test.js` ### Done when - [ ] Re-fetching an unchanged feed creates no items - [ ] An edited post updates rather than becoming unread - [ ] A feed without identifiers produces stable keys - [ ] Duplicate titles remain distinct items ### Watch out - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. ## Phase 3 · Reading at volume Three hundred feeds is navigable rather than a wall of unread. ### Steps 1. Generate a realistic archive Three hundred feeds and a couple of hundred thousand items. Every performance claim depends on it. Files: `scripts/generate.js` 2. Make unread counts cheap Incremental or indexed. The sidebar renders on every navigation. Files: `src/counts.js` 3. Add full text search Across titles and content, updated on insert. Files: `src/search.js` ### Done when - [ ] Unread counts render without a pause at realistic scale - [ ] Mark all as read is one undoable action - [ ] Search returns in under a hundred milliseconds - [ ] Scroll-past marking does not cause a jump ### Watch out - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. ## Phase 4 · Rules that tame the volume The things you care about surface and the noise disappears without unsubscribing. ### Steps 1. Evaluate rules on arrival In priority order, recording the acting rule on the item. Files: `src/rules.js` 2. Make mute non-destructive Out of unread, still findable by search. Deleting is a different, rarer intention. Files: `src/rules.js` 3. Report zero-match rules A rule that has never matched is usually a typo, and it is invisible without a report. Files: `src/rules.js` ### Done when - [ ] A rule tags new items and the acting rule is visible - [ ] A mute removes from unread without deleting - [ ] Retroactive application is explicit and reports its count - [ ] A rule matching nothing is flagged ### Watch out - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. ## Phase 5 · Alerts worth being interrupted by You are notified about the few things that matter and not about everything else. ### Steps 1. Batch and cap deliveries One message per period with a maximum. Report when the cap was applied. Files: `src/alerts.js` 2. Include decisive context Titles, feeds and links. Enough to judge without opening anything. Files: `src/alerts.js` 3. Make demotion easy One action from the notification or the rule list. Alert fatigue is the failure mode here. Files: `src/rules.js` ### Done when - [ ] An alert delivers a batched notification with context - [ ] A high-matching rule produces one capped notification - [ ] Demoting an alert to a tag is one action - [ ] A failed delivery is retried then reported ### Watch out - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ## Not in this build - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. ## After v1, if you want it - Full text fetch for a small allowlist of sites you read closely - A daily digest of the highest-priority tags, replacing per-item alerts - Duplicate detection across feeds, since syndicated posts arrive several times
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Feeds, items and rules. One file, and the whole backup. DB_PATH=./data/reader.db # Required. Base polling interval. Conditional requests make this cheap, but it is still someone's server. DEFAULT_FETCH_MINUTES=30 # Required. Longest interval after repeated failures or a consistently unchanged feed. MAX_FETCH_MINUTES=720 # Required. How often alerts are delivered. Batching is what prevents notification fatigue. ALERT_BATCH_MINUTES=60 # Required. Cap per delivery. Exceeding it reports a count rather than sending everything. ALERT_MAX_PER_BATCH=20 # Required. How long without new items before a feed is reported as quiet rather than working. QUIET_FEED_DAYS=120
# Inoreader · product brief ## Problem A single-user RSS reader with rules and alerts can be built quickly on top of existing parsers, though not with Inoreader's scale and polish. ## Product outcome A reader that costs publishers almost nothing to poll, never shows the same item twice, stays fast with a large archive, filters the noise by rules you wrote, and interrupts you only for what you asked to be interrupted about. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - RSS parser - database - background jobs - email/webhook notifications - hosted app or local service ## Explicit non-goals for v1 - Feed handling at hosted scale with per-publisher workarounds. - Mobile applications with offline sync. - The integration catalogue. - Full text extraction for summary-only feeds. - enterprise-scale feed handling - mobile apps - integrations - article translation - polished search - support ## Success criteria - An unchanged feed is confirmed to return not-modified with negligible bytes - All three awkward feeds produce stable identity across fetches - Unread counts render without pause on the large fixture - A high-matching alert rule is confirmed to produce one capped notification - A subscription list and item export round trip
# Build brief · Inoreader 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 feed reader that polls politely, never shows you the same item twice, and filters by rules you wrote to replace Inoreader. Build it in phases, in the order below. Do not write the whole thing 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) - Runtime: Node 22 with SQLite · a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup - Fetching: Conditional requests with ETag and If-Modified-Since · an unchanged feed should cost a few hundred bytes, and publishers notice when it does not - Parsing: A library handling RSS, Atom and JSON Feed · three formats with a long tail of malformed variants, none of which is worth writing - Identity: A stable item key derived per feed · the single thing that decides whether your reader is usable or full of duplicates - Rules: Ordered rules over title, content, author and feed · filtering is what makes a large subscription list survivable ### Data model (create this before Phase 1) - feeds(id, url, site_url, title, etag, last_modified, last_fetched_at, next_fetch_at, failure_count, interval_minutes) - items(id, feed_id, item_key, title, author, url, published_at, fetched_at, content, content_hash) - reads(item_id, read_at), stars(item_id, starred_at) - rules(id, priority, scope_feed_id, match_field, pattern, action, tag) · action is tag, mute or alert - tags(id, name), item_tags(item_id, tag_id) - fetch_log(feed_id, at, status, bytes, items_new, note) · so a feed that quietly stopped is visible - The item key is unique per feed. Choosing it well in Phase 2 is what stops the reader filling with duplicates. ### Phase 1 · Fetching without being rude Build: Fetch feeds on a schedule, sending the stored ETag and last modified date. Store both from every response. Back off on failures and on a rate limit response. Log every fetch with its status and byte count. Trap: Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. Done when: An unchanged feed returns a not-modified response and transfers almost no bytes, verified in the fetch log. A feed returning an error backs off with increasing intervals rather than retrying at the same rate. A feed that has failed many times is marked and reported rather than retried forever. Every fetch is logged with status and bytes. Do not build yet: No item storage, deduplication or rules. ### Phase 2 · Item identity, which decides everything Build: Derive a stable key per item: the feed's own identifier where it provides one, falling back to a hash of the link, falling back to a hash of title and date. Store a content hash separately so an edit updates rather than inserting. Trap: Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. Trap: Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. Done when: Re-fetching an unchanged feed creates no new items. A post edited upstream updates in place and does not become unread again. A feed with no item identifiers produces stable keys across fetches, verified by fetching twice. Two different posts with identical titles in the same feed remain two items. Do not build yet: No reading interface or rules. ### Phase 3 · Reading at volume Build: A reading view by folder, feed and tag, with unread counts. Mark as read on scroll past, and a mark-all with an undo. Full text search across items. Test with a realistic subscription list rather than five feeds. Trap: Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. Done when: A sidebar with three hundred feeds and two hundred thousand items renders unread counts without a visible pause. Marking all as read is one action and is undoable. Search across the archive returns in well under a hundred milliseconds. Scrolling past an item marks it read without a jump. Do not build yet: No rules or alerts yet. ### Phase 4 · Rules that tame the volume Build: Ordered rules matching on title, content, author or feed, with actions to tag, mute or alert. Rules apply to new items on arrival, not retroactively unless you ask. Show which rule acted on an item. Trap: Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. Done when: A rule tags matching new items and each item shows the rule that acted. A mute rule keeps items out of unread counts without deleting them. Changing a rule does not alter the archive until a retroactive run is invoked, and that run reports its count first. A rule matching nothing is visibly flagged rather than silently useless. Do not build yet: No alerts or export yet. ### Phase 5 · Alerts worth being interrupted by Build: Alert rules delivering by email or webhook, batched rather than per item, with a cap per period. Include enough context to decide without opening the reader. Make it trivial to turn an alert back into a plain tag when it fires too often. Trap: An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. Done when: An alert rule delivers a batched notification containing item titles and links. A rule matching fifty items in an hour delivers one capped notification reporting the count rather than fifty messages. Turning an alert into a tag is one action. A delivery failure is retried and then reported rather than lost. Do not build yet: Import and export come last. ### Phase 6 · Import, export and keeping it running Build: Import a standard subscription list and export one. Export items and stars as JSON. Run the fetcher as a service with a report of feeds that have stopped producing, which is different from feeds that are failing. Trap: A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different. Done when: A standard subscription list imports with folders preserved and exports back. Items and stars export as JSON and re-import into an empty installation. Feeds that have failed and feeds that have gone quiet are reported separately. The fetcher runs as a service and survives a restart. Do not build yet: Nothing after this. ### Out of scope (and why) - Feed handling at the scale the hosted product manages, including feeds that need per-publisher workarounds. - Mobile applications with offline sync, which is how most people read. - The integration catalogue that pushes items into other tools. - Full text extraction for feeds that publish only summaries, which is a per-site parsing problem that never finishes. ### README must contain - That fetching uses conditional requests and how to verify you are getting not-modified responses - The item key fallback chain and why identity decides whether the reader is usable - That rules apply going forward and retroactive application is explicit - That alerts are batched and capped, and how to demote one - That failing feeds and quiet feeds are reported separately because the fix differs
# Architecture · Inoreader ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with SQLite | a few hundred feeds and a few hundred thousand items is small, and one file is the whole backup | | Fetching | Conditional requests with ETag and If-Modified-Since | an unchanged feed should cost a few hundred bytes, and publishers notice when it does not | | Parsing | A library handling RSS, Atom and JSON Feed | three formats with a long tail of malformed variants, none of which is worth writing | | Identity | A stable item key derived per feed | the single thing that decides whether your reader is usable or full of duplicates | | Rules | Ordered rules over title, content, author and feed | filtering is what makes a large subscription list survivable | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Fetch | Polling, conditional requests and backoff. | Any client, provided conditional headers survive. Dropping them is rude and expensive. | | Identity | Deriving stable item keys. | This is the module that decides whether the product works. Its fallback chain should be documented and tested rather than adjusted casually. | | Rules | Tagging, muting and alerting. | Pure evaluation over items, testable with no network. | | Reading | Counts, navigation and search. | Presentation, provided counts stay cheap at realistic archive sizes. | | Alerts | Batched delivery with a cap. | Any transport. The batching and cap are what keep alerts useful. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DB_PATH` · required · Feeds, items and rules. One file, and the whole backup. - `DEFAULT_FETCH_MINUTES` · required · Base polling interval. Conditional requests make this cheap, but it is still someone's server. - `MAX_FETCH_MINUTES` · required · Longest interval after repeated failures or a consistently unchanged feed. - `ALERT_BATCH_MINUTES` · required · How often alerts are delivered. Batching is what prevents notification fatigue. - `ALERT_MAX_PER_BATCH` · required · Cap per delivery. Exceeding it reports a count rather than sending everything. - `QUIET_FEED_DAYS` · required · How long without new items before a feed is reported as quiet rather than working. ## 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 · Inoreader product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with SQLite, Conditional requests with ETag and If-Modified-Since, A library handling RSS, Atom and JSON Feed, A stable item key derived per feed, Ordered rules over title, content, author and feed. - 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. ## Known traps - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. - A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different.
# Delivery milestones · Inoreader Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Fetching without being rude Feeds update, and an unchanged feed costs almost nothing. ### Steps 1. Send and store conditional headers ETag and If-Modified-Since from the last response. Handle 304 by doing nothing. Files: `src/fetch.js` 2. Back off on failure and rate limiting Exponential, respecting a Retry-After header when one is sent. Files: `src/backoff.js` 3. Log every fetch Status, bytes, new items. A feed that stopped publishing and one that stopped working look identical without this. Files: `src/log.js` ### Done when - [ ] An unchanged feed returns not-modified and transfers almost nothing - [ ] A failing feed backs off rather than retrying at the same rate - [ ] A persistently failing feed is marked and reported - [ ] Every fetch is logged with status and bytes ### Watch out - Fetching a feed in full every fifteen minutes with no conditional headers wastes the publisher's bandwidth and yours, and enough readers doing it is why some feeds rate limit or block. Send the conditional headers, handle the not-modified response, and confirm you are getting one by looking at the byte counts. ## M2 · Item identity, which decides everything An edited post does not reappear as new, and two feeds carrying the same post are two items rather than a mess. ### Steps 1. Derive the key with a documented fallback chain Identifier, then link, then title and date. Record which strategy each feed used. Files: `src/identity.js` 2. Track content separately A content hash so an edit updates the item rather than inserting a second one. Files: `src/items.js` 3. Test the awkward feeds One with no identifiers, one that changes them on edit, one with duplicate titles. All three exist. Files: `test/identity.test.js` ### Done when - [ ] Re-fetching an unchanged feed creates no items - [ ] An edited post updates rather than becoming unread - [ ] A feed without identifiers produces stable keys - [ ] Duplicate titles remain distinct items ### Watch out - Some feeds change an item's identifier when the post is edited, which makes every correction appear as a new unread item. Prefer the link when the identifier looks unstable, and record which strategy a feed uses so it stays consistent. - Some feeds omit identifiers entirely and some reuse them across different posts. Both exist in the wild and both need a fallback, which is why the key is derived rather than taken. ## M3 · Reading at volume Three hundred feeds is navigable rather than a wall of unread. ### Steps 1. Generate a realistic archive Three hundred feeds and a couple of hundred thousand items. Every performance claim depends on it. Files: `scripts/generate.js` 2. Make unread counts cheap Incremental or indexed. The sidebar renders on every navigation. Files: `src/counts.js` 3. Add full text search Across titles and content, updated on insert. Files: `src/search.js` ### Done when - [ ] Unread counts render without a pause at realistic scale - [ ] Mark all as read is one undoable action - [ ] Search returns in under a hundred milliseconds - [ ] Scroll-past marking does not cause a jump ### Watch out - Unread counts computed by counting rows on every render make the sidebar slow once you have a few hundred thousand items, and the sidebar renders constantly. Maintain counts incrementally or index for them, and measure against a realistic archive rather than a fresh one. ## M4 · Rules that tame the volume The things you care about surface and the noise disappears without unsubscribing. ### Steps 1. Evaluate rules on arrival In priority order, recording the acting rule on the item. Files: `src/rules.js` 2. Make mute non-destructive Out of unread, still findable by search. Deleting is a different, rarer intention. Files: `src/rules.js` 3. Report zero-match rules A rule that has never matched is usually a typo, and it is invisible without a report. Files: `src/rules.js` ### Done when - [ ] A rule tags new items and the acting rule is visible - [ ] A mute removes from unread without deleting - [ ] Retroactive application is explicit and reports its count - [ ] A rule matching nothing is flagged ### Watch out - Silently applying a new rule to the whole archive can hide thousands of already-read items or resurface them, and either is disorienting. Apply going forward, and make a retroactive run an explicit action that reports how many items it will affect. ## M5 · Alerts worth being interrupted by You are notified about the few things that matter and not about everything else. ### Steps 1. Batch and cap deliveries One message per period with a maximum. Report when the cap was applied. Files: `src/alerts.js` 2. Include decisive context Titles, feeds and links. Enough to judge without opening anything. Files: `src/alerts.js` 3. Make demotion easy One action from the notification or the rule list. Alert fatigue is the failure mode here. Files: `src/rules.js` ### Done when - [ ] An alert delivers a batched notification with context - [ ] A high-matching rule produces one capped notification - [ ] Demoting an alert to a tag is one action - [ ] A failed delivery is retried then reported ### Watch out - An alert rule matching more than expected produces a burst of notifications that trains you to ignore all of them, and the rule you actually cared about is lost with them. Batch, cap per period, and report when a rule was capped so you know to narrow it. ## M6 · Import, export and keeping it running (production only) You can arrive with your subscriptions and leave with everything. ### Steps 1. Support the standard subscription format Import and export with folders. It is how people arrive and how they leave. Files: `src/opml.js` 2. Export items and stars JSON, complete, re-importable. Stars in particular represent real curation. Files: `src/export.js` 3. Report quiet feeds separately Failing and quiet are different problems. A quiet feed usually moved. Files: `src/health.js` ### Done when - [ ] A subscription list imports with folders and exports back - [ ] Items and stars round trip into an empty installation - [ ] Failing and quiet feeds are reported separately - [ ] The fetcher survives a restart ### Watch out - A feed that returns success but has published nothing for six months looks identical to a working feed in a status check, and it is usually a site that moved its feed URL. Report quiet feeds separately from failing ones, because the fix is different.
# Operations · Inoreader ## Backup One database file holding feeds, items, stars and rules. Stars and rules are the parts representing real work; items are regenerable in part but history is not. ## Restore Restore the file and let the fetcher run. Conditional headers stored with each feed mean the first run after a restore is cheap rather than a full refetch of everything. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failing feeds, quiet feeds and total bytes fetched per day. A rising byte count usually means conditional requests stopped working, which is both a cost and a politeness problem. ## Incident checklist If duplicates appear, the item key strategy for that feed changed, and the fetch log plus the recorded strategy is where to look. If a feed starts returning errors, backoff should already have handled it; a feed still hammering is a bug in the backoff. 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 - [ ] An unchanged feed is confirmed to return not-modified with negligible bytes - [ ] All three awkward feeds produce stable identity across fetches - [ ] Unread counts render without pause on the large fixture - [ ] A high-matching alert rule is confirmed to produce one capped notification - [ ] A subscription list and item export round trip ## Launch constraint Do not market omitted Inoreader 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. Feeds, items and rules. One file, and the whole backup. DB_PATH=./data/reader.db # Required. Base polling interval. Conditional requests make this cheap, but it is still someone's server. DEFAULT_FETCH_MINUTES=30 # Required. Longest interval after repeated failures or a consistently unchanged feed. MAX_FETCH_MINUTES=720 # Required. How often alerts are delivered. Batching is what prevents notification fatigue. ALERT_BATCH_MINUTES=60 # Required. Cap per delivery. Exceeding it reports a count rather than sending everything. ALERT_MAX_PER_BATCH=20 # Required. How long without new items before a feed is reported as quiet rather than working. QUIET_FEED_DAYS=120
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay for reliability and a power-user rules engine that keeps working while feeds break.
xenterprise-scale feed handling
xmobile apps
xintegrations
xarticle translation
xpolished search
xsupport
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Inoreader →· no votes, no pay-to-list · just what's real
Inoreader pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | Up to 150 RSS subscriptions. |
| pro | $10.38/user | $7.70/user | 2,500 RSS subscriptions; 20 web feeds; 20 newsletters; 30 monitoring feeds; 100 boosted feeds; 30 rules; 50 filters; 10 translations/day; 5 text-to-speech uses/day. |
| custom | custom | — | Custom feed, monitoring and organization limits. |
free tier150 RSS subscriptions
billingmonthly + annual for Pro; Custom quote-based; source prices are in EUR
hidden costspaid add-ons increase web-feed, monitoring and boosted-feed limits; AI/Intelligence can use the user's own third-party API key, which creates separate provider charges
verified 2026-08-13 · source ↗
Vibecode Inoreader
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Inoreader replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Inoreader cost?
Inoreader costs about $10.38/month (Pro, checked 2026-08-13), which is $124.56 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Inoreader?
Honestly: enterprise-scale feed handling; mobile apps; integrations; article translation; polished search; support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Inoreader?
Yes: FreshRSS (Filters, search, tags and extensions without a subscription or hosted convenience.) Miniflux (Regex filters, full-text search and a clean reader; monitoring and automations are basic.) NewsBlur (Trainable feeds, search and newsletters, plus enough infrastructure to qualify as a hobby.) All 3 curated free alternatives are at vibecodeit.com/inoreader/alternatives. The prompt is for when you want it exactly your way.