Vibecode Littlebird
track this build4 phases, 12 steps, beginner friendly0%Public detail on this one is thin, so treat this entry as a first pass rather than a teardown. Anything in the "AI assistant that reads and drafts for you" shape is mostly a wrapper: a model API, a place to put your context, and a loop that runs on a schedule. A coding agent can rebuild that shape in an evening and you keep the API bill instead of the subscription. What you will not rebuild in an evening is the polish, the mobile surface, and whatever integrations the paid product ships against your actual accounts. Verdict stays "kinda" until someone confirms whether the real value here is the agent loop or the connectors underneath it.
You are building a lean indie version of Littlebird. 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 ===== # Littlebird · indie build A personal assistant whose hard problems are answering only from retrieved context and saying so when it cannot, keeping a scheduled watcher from spending money on pages that did not change, and noticing when the job that makes it useful has quietly stopped running. 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 20 with TypeScript and Fastify, one process | the scheduler and the web interface sharing a process is the whole simplicity | | Storage | SQLite via better-sqlite3, including the vectors | a separate vector service is an extra thing to keep alive for a few thousand chunks | | Interface | Server-rendered HTML with htmx, plain CSS | there is no front end problem here worth a framework | | Model access | One provider behind one module, key and base URL from the environment | swapping providers should be a configuration change | | Fetching | Conditional requests with stored validators, content extraction, hashed snapshots | a page that did not change must cost nothing | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 20 or newer** · free - Why: Runs the server and the scheduler in one process. - Get it: nodejs.org, or nvm install 20. - Verify: node --version prints v20 or higher - [ ] **A model API key with an embedding model available** · pay per use, typically small for a personal library - Why: Chat, digests and embeddings all go through it, and the cost ledger exists to keep it from surprising you. - Get it: Create an account with a provider, generate a key, and note the chat and embedding model names and their current prices. - Verify: A ten chunk embedding run and a one sentence chat completion both succeed - [ ] **A machine that stays awake** · a few pounds a month - Why: A scheduled watcher on a laptop that sleeps is a digest page that silently stops updating, which is the failure this build is organised around. - Get it: A small VPS, a home server, or any always-on machine. - Verify: A scheduled pass runs overnight and writes a run row - [ ] **Three pages worth watching, of different kinds** · free - Why: One that supports conditional requests, one that does not, and one that changes cosmetically every load. Each exists to make a different gate in phase three fail before it passes. - Get it: Pick from sites you already read, and check their response headers by hand first. - Verify: You know which validators each returns and whether its extracted text is stable - [ ] **Your monthly ceiling and per-call cap** · free - Why: The two numbers that decide whether this stays cheap, and choosing them after a bill means choosing whatever already happened. - Get it: Pick a monthly figure you would not mind, and a character cap per digest call, and write down why. - Verify: Both are in configuration and named in tests ## 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: - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. - Mobile app and notifications; your version is a browser tab - Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against - Someone else tuning prompts and swapping models when a better one ships - Reliability: your cron job dies silently and nobody pages you - Any team or sharing features, if the product has them If one of those is essential to you, that is the reason to keep paying for Littlebird, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Littlebird 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 local assistant that answers from context you gave it and watches a handful of pages for changes worth telling you about to replace Littlebird. 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 20 with TypeScript and Fastify, one process · the scheduler and the web interface sharing a process is the whole simplicity - Storage: SQLite via better-sqlite3, including the vectors · a separate vector service is an extra thing to keep alive for a few thousand chunks - Interface: Server-rendered HTML with htmx, plain CSS · there is no front end problem here worth a framework - Model access: One provider behind one module, key and base URL from the environment · swapping providers should be a configuration change - Fetching: Conditional requests with stored validators, content extraction, hashed snapshots · a page that did not change must cost nothing ### Data model (create this before Phase 1) - sources(id, kind, title, url, added_at, bytes, sha256) · kind is paste, upload or url - chunks(id, source_id, ordinal, text, token_estimate, embedding) · the unit that gets cited - conversations(id, started_at, title) and turns(conversation_id, ordinal, role, content, cited_chunk_ids_json, at) - watch_items(id, name, url, cadence, last_fetch_at, etag, last_modified, last_sha256, last_status, consecutive_failures, paused) - snapshots(watch_item_id, at, extracted_sha256, extracted_path, bytes) · kept for a stated number of versions - digests(watch_item_id, snapshot_at, summary, model_id, tokens_in, tokens_out, created_at) · only written when something changed - calls(id, at, purpose, model_id, tokens_in, tokens_out, latency_ms, estimated_cost) · every model call, no exceptions - runs(id, kind, started_at, finished_at, items_checked, items_changed, calls_made, error_text) · the heartbeat that proves the job is alive - The watcher's cheapest path is the common one: a 304 response ends the check before extraction, hashing or any model call. ### Phase 1 · Context with provenance Build: Accept pasted text, uploaded Markdown and plain text, and URLs fetched server-side and reduced to readable text. Chunk to a configured size with a small overlap, embed, and store the chunk with its source and ordinal. Show every source with its size, chunk count and date added, and allow deleting a source and everything derived from it. Trap: Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. Done when: A document with headings and a table chunks without splitting a table row across chunks, tested with a fixture. Deleting a source removes its chunks and embeddings and they no longer appear in retrieval, tested. A URL fetch that returns something other than a readable article, such as a login page, is rejected with the reason rather than stored as content. Every chunk can be traced to its source and ordinal in the interface. Do not build yet: No chat, watchlist, digest or cost view yet. ### Phase 2 · Answers that cite, or say they cannot Build: Retrieve the top chunks by similarity, pass them with stable ids, and require the reply to cite the ids it used. Render citations as links to the source. If retrieval returns nothing above a configured similarity floor, say there is no relevant context rather than answering from the model's own knowledge. Trap: Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. Done when: A question whose answer is in one source produces a reply citing that source's chunk ids, rendered as links that open the source. A question about something not in the library produces an explicit statement that there is no relevant context, tested with an empty library and with an unrelated one. An answer citing no chunks is displayed with a visible marker rather than as an ordinary reply. Every turn stores which chunk ids were supplied and which were cited, so a wrong answer can be traced. Do not build yet: No watchlist, digest, cost ceiling or heartbeat yet. ### Phase 3 · A watcher that pays only for change Build: Store the validators the server gave you and send them back. RFC 9110 defines ETag as an opaque validator for a representation and If-None-Match as the way a client asks for it only if it differs, with 304 Not Modified as the answer when it has not (checked against the RFC), so send If-None-Match and If-Modified-Since and stop on a 304. If the body did come back, extract the readable text, hash it, and stop if the hash matches. Only past both gates do you diff and call a model. Trap: Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. Done when: A watched page returning 304 results in no extraction, no diff and no model call, verified by counting calls against a fake server. A page returning 200 with byte differences but identical extracted text, such as a rotating advertisement or a timestamp, produces no digest, tested. Validators are stored and resent, and a server that supplies neither still works via the extracted-text hash. A page that has failed repeatedly is paused with its error visible rather than retried forever. Do not build yet: No digest page, cost ceiling or heartbeat yet. ### Phase 4 · A bill you can see before it arrives Build: Log every call with purpose, model, token counts and an estimated cost from rates held in configuration with their checked date. A fetched page becomes input tokens, and a large page is a large number of them, so cap the text sent per digest. Show spend by day and by purpose. Reaching the monthly ceiling pauses scheduled work and says so on the page. Trap: Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. Done when: Every model call appears in the calls table with purpose, tokens and an estimated cost, with no code path that calls a model without logging. A page larger than the configured cap is truncated with a visible note in the digest rather than sent whole. Reaching the monthly ceiling pauses the scheduler and displays the pause with the figure, and manual chat states that it is over the ceiling rather than failing obscurely. The rates in configuration carry the date they were checked and the page shows their age. Do not build yet: No heartbeat or export yet. ### Phase 5 · Noticing when it has stopped Build: Record a run row for every scheduled pass, successful or not. Show on every page when the watcher last completed and go loud when it is older than one cadence plus a margin. Write the digest to a Markdown file on disk after each run so it survives the application. Export sources, chunks, conversations, digests and calls as JSON. Trap: A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. Done when: Stopping the scheduler makes the staleness warning appear within one cadence plus the margin, tested by advancing a clock. Every scheduled pass writes a run row including failures with their error text. The digest Markdown file on disk contains the same content as the page and is readable with no application running. The JSON export covers sources, chunks, conversations, digests and calls, and re-importing it into a fresh install restores the library, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - OAuth connectors to mail, calendar and chat accounts, which are the actual product in this category and are an authentication and upkeep commitment rather than an afternoon. - A mobile application and push notifications, which is where an assistant earns its place in a day and is a separate build entirely. - Multi-user access and sharing, which changes the data model and the security story from the first line. - Autonomous actions taken on your behalf, which needs a permission model and an undo story before it needs a model. ### README must contain - That answers cite the chunks they used and a question with no relevant context gets an explicit refusal - That the watcher uses conditional requests, so an unchanged page costs one request and no model call - That a change in the page body which does not change the extracted text produces no digest - How the cost ceiling works, where the rates came from and when they were checked - That the scheduler's last successful run is shown on every page, because the real failure mode is silence ===== AGENTS.md ===== # Agent instructions · Littlebird indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 20 with TypeScript and Fastify, one process, SQLite via better-sqlite3, including the vectors, Server-rendered HTML with htmx, plain CSS, One provider behind one module, key and base URL from the environment, Conditional requests with stored validators, content extraction, hashed snapshots. 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 - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ===== BUILD_PLAN.md ===== # Build plan · Littlebird A personal assistant whose hard problems are answering only from retrieved context and saying so when it cannot, keeping a scheduled watcher from spending money on pages that did not change, and noticing when the job that makes it useful has quietly stopped running. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Context with provenance Anything the assistant can use came from a source you can point at. ### Steps 1. Chunk on structure, not on character count The difference between a useful retrieval and a confident fragment. Files: `src/ingest/chunk.ts`, `tests/chunk-structure.test.ts` 2. Reject pages that are not articles A stored login page poisons every later answer. Files: `src/ingest/extract.ts` 3. Make deletion reach the embeddings A deleted source that still answers questions is not deleted. Files: `src/sources/delete.ts` ### Done when - [ ] Structured documents chunk without splitting table rows - [ ] Deleting a source removes it from retrieval - [ ] Non-article URLs are rejected with a reason - [ ] Every chunk traces to a source and ordinal ### Watch out - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. ## Phase 2 · Answers that cite, or say they cannot Every reply names the chunks it used, and a question with no relevant context gets an honest refusal. ### Steps 1. Pass stable chunk ids and require citations Ids in, citations out, or the answer is unverifiable. Files: `src/chat/prompt.ts`, `src/chat/parse.ts` 2. Refuse below the similarity floor An honest 'I do not have that' beats a fluent guess. Files: `src/chat/retrieve.ts`, `tests/no-context-refusal.test.ts` 3. Store supplied and cited ids per turn The audit trail for the answer that was wrong. Files: `src/chat/turns.ts` ### Done when - [ ] Answers cite chunk ids rendered as source links - [ ] An out-of-library question gets an explicit refusal - [ ] Uncited answers are visibly marked - [ ] Supplied and cited ids are stored per turn ### Watch out - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. ## Phase 3 · A watcher that pays only for change A daily check on a page that did not change costs one conditional request and nothing else. ### Steps 1. Send the validators back The cheapest possible check, and the polite one. Files: `src/watch/fetch.ts`, `tests/conditional-request.test.ts` 2. Hash the extracted text, not the body Advertisements and timestamps change; the article does not. Files: `src/watch/extract.ts` 3. Pause an item that keeps failing A broken watcher should be visible, not persistent. Files: `src/watch/health.ts` ### Done when - [ ] A 304 produces no extraction, diff or model call - [ ] Cosmetic body changes produce no digest - [ ] Validators are stored and resent, with a hash fallback - [ ] A repeatedly failing item pauses with its error shown ### Watch out - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. ## Phase 4 · A bill you can see before it arrives Every model call is recorded and a ceiling stops the job rather than the credit card. ### Steps 1. Log every call, without exception One wrapper, and no provider call outside it. Files: `src/model/call.ts`, `tests/all-calls-logged.test.ts` 2. Cap what a digest sends Diff first, truncate second, and say that you did. Files: `src/watch/digest.ts` 3. Pause on the ceiling, visibly The alternative is finding out from a statement. Files: `src/scheduler.ts` ### Done when - [ ] No model call happens outside the logging wrapper - [ ] Oversized pages are truncated with a visible note - [ ] The ceiling pauses the scheduler and is displayed - [ ] Rates show their checked date and their age ### Watch out - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ## Not in this build - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. ## After v1, if you want it - A second watch cadence such as hourly for one item, which needs nothing new beyond a column already present - Re-embedding on an embedding model change, once the app detects and reports the mismatch - A weekly rollup of digests, built from rows you already store and one extra call per week ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Sources, chunks, conversations, watch items, digests and the call ledger. DATABASE_PATH=./data/assistant.db # Required. The digest Markdown written after each run, so it survives the process. OUT_DIR=./out # Required · secret. Chat, digest and embedding calls. The only credential here. MODEL_API_KEY=sk-...redacted # Optional. Set it to point at a different provider or a local server. MODEL_BASE_URL=https://api.example.com/v1 # Required. Recorded on every call in the ledger, so a model change is visible in the costs. CHAT_MODEL=a chat model id from your provider # Required. Changing it invalidates existing embeddings, which the app must say rather than silently mix. EMBED_MODEL=an embedding model id from your provider # Required. Maximum characters sent per digest call. Exceeding it truncates with a visible note. DIGEST_CHAR_CAP=20000 # Required. Reaching it pauses scheduled work and says so on every page. MONTHLY_COST_CEILING_USD=10
You are building a lean indie version of Littlebird. 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 ===== # Littlebird · indie build A personal assistant whose hard problems are answering only from retrieved context and saying so when it cannot, keeping a scheduled watcher from spending money on pages that did not change, and noticing when the job that makes it useful has quietly stopped running. 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 20 with TypeScript and Fastify, one process | the scheduler and the web interface sharing a process is the whole simplicity | | Storage | SQLite via better-sqlite3, including the vectors | a separate vector service is an extra thing to keep alive for a few thousand chunks | | Interface | Server-rendered HTML with htmx, plain CSS | there is no front end problem here worth a framework | | Model access | One provider behind one module, key and base URL from the environment | swapping providers should be a configuration change | | Fetching | Conditional requests with stored validators, content extraction, hashed snapshots | a page that did not change must cost nothing | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 20 or newer** · free - Why: Runs the server and the scheduler in one process. - Get it: nodejs.org, or nvm install 20. - Verify: node --version prints v20 or higher - [ ] **A model API key with an embedding model available** · pay per use, typically small for a personal library - Why: Chat, digests and embeddings all go through it, and the cost ledger exists to keep it from surprising you. - Get it: Create an account with a provider, generate a key, and note the chat and embedding model names and their current prices. - Verify: A ten chunk embedding run and a one sentence chat completion both succeed - [ ] **A machine that stays awake** · a few pounds a month - Why: A scheduled watcher on a laptop that sleeps is a digest page that silently stops updating, which is the failure this build is organised around. - Get it: A small VPS, a home server, or any always-on machine. - Verify: A scheduled pass runs overnight and writes a run row - [ ] **Three pages worth watching, of different kinds** · free - Why: One that supports conditional requests, one that does not, and one that changes cosmetically every load. Each exists to make a different gate in phase three fail before it passes. - Get it: Pick from sites you already read, and check their response headers by hand first. - Verify: You know which validators each returns and whether its extracted text is stable - [ ] **Your monthly ceiling and per-call cap** · free - Why: The two numbers that decide whether this stays cheap, and choosing them after a bill means choosing whatever already happened. - Get it: Pick a monthly figure you would not mind, and a character cap per digest call, and write down why. - Verify: Both are in configuration and named in tests ## 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: - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. - Mobile app and notifications; your version is a browser tab - Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against - Someone else tuning prompts and swapping models when a better one ships - Reliability: your cron job dies silently and nobody pages you - Any team or sharing features, if the product has them If one of those is essential to you, that is the reason to keep paying for Littlebird, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Littlebird 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 local assistant that answers from context you gave it and watches a handful of pages for changes worth telling you about to replace Littlebird. 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 20 with TypeScript and Fastify, one process · the scheduler and the web interface sharing a process is the whole simplicity - Storage: SQLite via better-sqlite3, including the vectors · a separate vector service is an extra thing to keep alive for a few thousand chunks - Interface: Server-rendered HTML with htmx, plain CSS · there is no front end problem here worth a framework - Model access: One provider behind one module, key and base URL from the environment · swapping providers should be a configuration change - Fetching: Conditional requests with stored validators, content extraction, hashed snapshots · a page that did not change must cost nothing ### Data model (create this before Phase 1) - sources(id, kind, title, url, added_at, bytes, sha256) · kind is paste, upload or url - chunks(id, source_id, ordinal, text, token_estimate, embedding) · the unit that gets cited - conversations(id, started_at, title) and turns(conversation_id, ordinal, role, content, cited_chunk_ids_json, at) - watch_items(id, name, url, cadence, last_fetch_at, etag, last_modified, last_sha256, last_status, consecutive_failures, paused) - snapshots(watch_item_id, at, extracted_sha256, extracted_path, bytes) · kept for a stated number of versions - digests(watch_item_id, snapshot_at, summary, model_id, tokens_in, tokens_out, created_at) · only written when something changed - calls(id, at, purpose, model_id, tokens_in, tokens_out, latency_ms, estimated_cost) · every model call, no exceptions - runs(id, kind, started_at, finished_at, items_checked, items_changed, calls_made, error_text) · the heartbeat that proves the job is alive - The watcher's cheapest path is the common one: a 304 response ends the check before extraction, hashing or any model call. ### Phase 1 · Context with provenance Build: Accept pasted text, uploaded Markdown and plain text, and URLs fetched server-side and reduced to readable text. Chunk to a configured size with a small overlap, embed, and store the chunk with its source and ordinal. Show every source with its size, chunk count and date added, and allow deleting a source and everything derived from it. Trap: Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. Done when: A document with headings and a table chunks without splitting a table row across chunks, tested with a fixture. Deleting a source removes its chunks and embeddings and they no longer appear in retrieval, tested. A URL fetch that returns something other than a readable article, such as a login page, is rejected with the reason rather than stored as content. Every chunk can be traced to its source and ordinal in the interface. Do not build yet: No chat, watchlist, digest or cost view yet. ### Phase 2 · Answers that cite, or say they cannot Build: Retrieve the top chunks by similarity, pass them with stable ids, and require the reply to cite the ids it used. Render citations as links to the source. If retrieval returns nothing above a configured similarity floor, say there is no relevant context rather than answering from the model's own knowledge. Trap: Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. Done when: A question whose answer is in one source produces a reply citing that source's chunk ids, rendered as links that open the source. A question about something not in the library produces an explicit statement that there is no relevant context, tested with an empty library and with an unrelated one. An answer citing no chunks is displayed with a visible marker rather than as an ordinary reply. Every turn stores which chunk ids were supplied and which were cited, so a wrong answer can be traced. Do not build yet: No watchlist, digest, cost ceiling or heartbeat yet. ### Phase 3 · A watcher that pays only for change Build: Store the validators the server gave you and send them back. RFC 9110 defines ETag as an opaque validator for a representation and If-None-Match as the way a client asks for it only if it differs, with 304 Not Modified as the answer when it has not (checked against the RFC), so send If-None-Match and If-Modified-Since and stop on a 304. If the body did come back, extract the readable text, hash it, and stop if the hash matches. Only past both gates do you diff and call a model. Trap: Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. Done when: A watched page returning 304 results in no extraction, no diff and no model call, verified by counting calls against a fake server. A page returning 200 with byte differences but identical extracted text, such as a rotating advertisement or a timestamp, produces no digest, tested. Validators are stored and resent, and a server that supplies neither still works via the extracted-text hash. A page that has failed repeatedly is paused with its error visible rather than retried forever. Do not build yet: No digest page, cost ceiling or heartbeat yet. ### Phase 4 · A bill you can see before it arrives Build: Log every call with purpose, model, token counts and an estimated cost from rates held in configuration with their checked date. A fetched page becomes input tokens, and a large page is a large number of them, so cap the text sent per digest. Show spend by day and by purpose. Reaching the monthly ceiling pauses scheduled work and says so on the page. Trap: Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. Done when: Every model call appears in the calls table with purpose, tokens and an estimated cost, with no code path that calls a model without logging. A page larger than the configured cap is truncated with a visible note in the digest rather than sent whole. Reaching the monthly ceiling pauses the scheduler and displays the pause with the figure, and manual chat states that it is over the ceiling rather than failing obscurely. The rates in configuration carry the date they were checked and the page shows their age. Do not build yet: No heartbeat or export yet. ### Phase 5 · Noticing when it has stopped Build: Record a run row for every scheduled pass, successful or not. Show on every page when the watcher last completed and go loud when it is older than one cadence plus a margin. Write the digest to a Markdown file on disk after each run so it survives the application. Export sources, chunks, conversations, digests and calls as JSON. Trap: A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. Done when: Stopping the scheduler makes the staleness warning appear within one cadence plus the margin, tested by advancing a clock. Every scheduled pass writes a run row including failures with their error text. The digest Markdown file on disk contains the same content as the page and is readable with no application running. The JSON export covers sources, chunks, conversations, digests and calls, and re-importing it into a fresh install restores the library, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - OAuth connectors to mail, calendar and chat accounts, which are the actual product in this category and are an authentication and upkeep commitment rather than an afternoon. - A mobile application and push notifications, which is where an assistant earns its place in a day and is a separate build entirely. - Multi-user access and sharing, which changes the data model and the security story from the first line. - Autonomous actions taken on your behalf, which needs a permission model and an undo story before it needs a model. ### README must contain - That answers cite the chunks they used and a question with no relevant context gets an explicit refusal - That the watcher uses conditional requests, so an unchanged page costs one request and no model call - That a change in the page body which does not change the extracted text produces no digest - How the cost ceiling works, where the rates came from and when they were checked - That the scheduler's last successful run is shown on every page, because the real failure mode is silence ===== AGENTS.md ===== # Agent instructions · Littlebird indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 20 with TypeScript and Fastify, one process, SQLite via better-sqlite3, including the vectors, Server-rendered HTML with htmx, plain CSS, One provider behind one module, key and base URL from the environment, Conditional requests with stored validators, content extraction, hashed snapshots. 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 - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ===== BUILD_PLAN.md ===== # Build plan · Littlebird A personal assistant whose hard problems are answering only from retrieved context and saying so when it cannot, keeping a scheduled watcher from spending money on pages that did not change, and noticing when the job that makes it useful has quietly stopped running. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Context with provenance Anything the assistant can use came from a source you can point at. ### Steps 1. Chunk on structure, not on character count The difference between a useful retrieval and a confident fragment. Files: `src/ingest/chunk.ts`, `tests/chunk-structure.test.ts` 2. Reject pages that are not articles A stored login page poisons every later answer. Files: `src/ingest/extract.ts` 3. Make deletion reach the embeddings A deleted source that still answers questions is not deleted. Files: `src/sources/delete.ts` ### Done when - [ ] Structured documents chunk without splitting table rows - [ ] Deleting a source removes it from retrieval - [ ] Non-article URLs are rejected with a reason - [ ] Every chunk traces to a source and ordinal ### Watch out - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. ## Phase 2 · Answers that cite, or say they cannot Every reply names the chunks it used, and a question with no relevant context gets an honest refusal. ### Steps 1. Pass stable chunk ids and require citations Ids in, citations out, or the answer is unverifiable. Files: `src/chat/prompt.ts`, `src/chat/parse.ts` 2. Refuse below the similarity floor An honest 'I do not have that' beats a fluent guess. Files: `src/chat/retrieve.ts`, `tests/no-context-refusal.test.ts` 3. Store supplied and cited ids per turn The audit trail for the answer that was wrong. Files: `src/chat/turns.ts` ### Done when - [ ] Answers cite chunk ids rendered as source links - [ ] An out-of-library question gets an explicit refusal - [ ] Uncited answers are visibly marked - [ ] Supplied and cited ids are stored per turn ### Watch out - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. ## Phase 3 · A watcher that pays only for change A daily check on a page that did not change costs one conditional request and nothing else. ### Steps 1. Send the validators back The cheapest possible check, and the polite one. Files: `src/watch/fetch.ts`, `tests/conditional-request.test.ts` 2. Hash the extracted text, not the body Advertisements and timestamps change; the article does not. Files: `src/watch/extract.ts` 3. Pause an item that keeps failing A broken watcher should be visible, not persistent. Files: `src/watch/health.ts` ### Done when - [ ] A 304 produces no extraction, diff or model call - [ ] Cosmetic body changes produce no digest - [ ] Validators are stored and resent, with a hash fallback - [ ] A repeatedly failing item pauses with its error shown ### Watch out - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. ## Phase 4 · A bill you can see before it arrives Every model call is recorded and a ceiling stops the job rather than the credit card. ### Steps 1. Log every call, without exception One wrapper, and no provider call outside it. Files: `src/model/call.ts`, `tests/all-calls-logged.test.ts` 2. Cap what a digest sends Diff first, truncate second, and say that you did. Files: `src/watch/digest.ts` 3. Pause on the ceiling, visibly The alternative is finding out from a statement. Files: `src/scheduler.ts` ### Done when - [ ] No model call happens outside the logging wrapper - [ ] Oversized pages are truncated with a visible note - [ ] The ceiling pauses the scheduler and is displayed - [ ] Rates show their checked date and their age ### Watch out - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ## Not in this build - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. ## After v1, if you want it - A second watch cadence such as hourly for one item, which needs nothing new beyond a column already present - Re-embedding on an embedding model change, once the app detects and reports the mismatch - A weekly rollup of digests, built from rows you already store and one extra call per week ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Sources, chunks, conversations, watch items, digests and the call ledger. DATABASE_PATH=./data/assistant.db # Required. The digest Markdown written after each run, so it survives the process. OUT_DIR=./out # Required · secret. Chat, digest and embedding calls. The only credential here. MODEL_API_KEY=sk-...redacted # Optional. Set it to point at a different provider or a local server. MODEL_BASE_URL=https://api.example.com/v1 # Required. Recorded on every call in the ledger, so a model change is visible in the costs. CHAT_MODEL=a chat model id from your provider # Required. Changing it invalidates existing embeddings, which the app must say rather than silently mix. EMBED_MODEL=an embedding model id from your provider # Required. Maximum characters sent per digest call. Exceeding it truncates with a visible note. DIGEST_CHAR_CAP=20000 # Required. Reaching it pauses scheduled work and says so on every page. MONTHLY_COST_CEILING_USD=10
You are building a production product version of Littlebird. 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 ===== # Littlebird · product brief ## Problem Public detail on this one is thin, so treat this entry as a first pass rather than a teardown. Anything in the "AI assistant that reads and drafts for you" shape is mostly a wrapper: a model API, a place to put your context, and a loop that runs on a schedule. A coding agent can rebuild that shape in an evening and you keep the API bill instead of the subscription. What you will not rebuild in an evening is the polish, the mobile surface, and whatever integrations the paid product ships against your actual accounts. Verdict stays "kinda" until someone confirms whether the real value here is the agent loop or the connectors underneath it. ## Product outcome An assistant that only answers from what you gave it, watches pages without paying for the days they did not change, shows you what it is costing, and tells you loudly when it has stopped running. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An LLM API key (OpenAI or Anthropic) in .env - Node 20+ and a machine or small VPS that stays awake for the cron job - Willingness to paste your own context in instead of having connectors do it ## Explicit non-goals for v1 - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. - Mobile app and notifications; your version is a browser tab - Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against - Someone else tuning prompts and swapping models when a better one ships - Reliability: your cron job dies silently and nobody pages you - Any team or sharing features, if the product has them ## Success criteria - A 304 is confirmed to produce no model call - A cosmetic change is confirmed to produce no digest - No model call is confirmed to bypass the ledger - The ceiling is confirmed to pause the scheduler visibly - A stopped scheduler is confirmed to raise a staleness warning in time ===== BRIEF.md ===== # Build brief · Littlebird 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 local assistant that answers from context you gave it and watches a handful of pages for changes worth telling you about to replace Littlebird. 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 20 with TypeScript and Fastify, one process · the scheduler and the web interface sharing a process is the whole simplicity - Storage: SQLite via better-sqlite3, including the vectors · a separate vector service is an extra thing to keep alive for a few thousand chunks - Interface: Server-rendered HTML with htmx, plain CSS · there is no front end problem here worth a framework - Model access: One provider behind one module, key and base URL from the environment · swapping providers should be a configuration change - Fetching: Conditional requests with stored validators, content extraction, hashed snapshots · a page that did not change must cost nothing ### Data model (create this before Phase 1) - sources(id, kind, title, url, added_at, bytes, sha256) · kind is paste, upload or url - chunks(id, source_id, ordinal, text, token_estimate, embedding) · the unit that gets cited - conversations(id, started_at, title) and turns(conversation_id, ordinal, role, content, cited_chunk_ids_json, at) - watch_items(id, name, url, cadence, last_fetch_at, etag, last_modified, last_sha256, last_status, consecutive_failures, paused) - snapshots(watch_item_id, at, extracted_sha256, extracted_path, bytes) · kept for a stated number of versions - digests(watch_item_id, snapshot_at, summary, model_id, tokens_in, tokens_out, created_at) · only written when something changed - calls(id, at, purpose, model_id, tokens_in, tokens_out, latency_ms, estimated_cost) · every model call, no exceptions - runs(id, kind, started_at, finished_at, items_checked, items_changed, calls_made, error_text) · the heartbeat that proves the job is alive - The watcher's cheapest path is the common one: a 304 response ends the check before extraction, hashing or any model call. ### Phase 1 · Context with provenance Build: Accept pasted text, uploaded Markdown and plain text, and URLs fetched server-side and reduced to readable text. Chunk to a configured size with a small overlap, embed, and store the chunk with its source and ordinal. Show every source with its size, chunk count and date added, and allow deleting a source and everything derived from it. Trap: Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. Done when: A document with headings and a table chunks without splitting a table row across chunks, tested with a fixture. Deleting a source removes its chunks and embeddings and they no longer appear in retrieval, tested. A URL fetch that returns something other than a readable article, such as a login page, is rejected with the reason rather than stored as content. Every chunk can be traced to its source and ordinal in the interface. Do not build yet: No chat, watchlist, digest or cost view yet. ### Phase 2 · Answers that cite, or say they cannot Build: Retrieve the top chunks by similarity, pass them with stable ids, and require the reply to cite the ids it used. Render citations as links to the source. If retrieval returns nothing above a configured similarity floor, say there is no relevant context rather than answering from the model's own knowledge. Trap: Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. Done when: A question whose answer is in one source produces a reply citing that source's chunk ids, rendered as links that open the source. A question about something not in the library produces an explicit statement that there is no relevant context, tested with an empty library and with an unrelated one. An answer citing no chunks is displayed with a visible marker rather than as an ordinary reply. Every turn stores which chunk ids were supplied and which were cited, so a wrong answer can be traced. Do not build yet: No watchlist, digest, cost ceiling or heartbeat yet. ### Phase 3 · A watcher that pays only for change Build: Store the validators the server gave you and send them back. RFC 9110 defines ETag as an opaque validator for a representation and If-None-Match as the way a client asks for it only if it differs, with 304 Not Modified as the answer when it has not (checked against the RFC), so send If-None-Match and If-Modified-Since and stop on a 304. If the body did come back, extract the readable text, hash it, and stop if the hash matches. Only past both gates do you diff and call a model. Trap: Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. Done when: A watched page returning 304 results in no extraction, no diff and no model call, verified by counting calls against a fake server. A page returning 200 with byte differences but identical extracted text, such as a rotating advertisement or a timestamp, produces no digest, tested. Validators are stored and resent, and a server that supplies neither still works via the extracted-text hash. A page that has failed repeatedly is paused with its error visible rather than retried forever. Do not build yet: No digest page, cost ceiling or heartbeat yet. ### Phase 4 · A bill you can see before it arrives Build: Log every call with purpose, model, token counts and an estimated cost from rates held in configuration with their checked date. A fetched page becomes input tokens, and a large page is a large number of them, so cap the text sent per digest. Show spend by day and by purpose. Reaching the monthly ceiling pauses scheduled work and says so on the page. Trap: Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. Done when: Every model call appears in the calls table with purpose, tokens and an estimated cost, with no code path that calls a model without logging. A page larger than the configured cap is truncated with a visible note in the digest rather than sent whole. Reaching the monthly ceiling pauses the scheduler and displays the pause with the figure, and manual chat states that it is over the ceiling rather than failing obscurely. The rates in configuration carry the date they were checked and the page shows their age. Do not build yet: No heartbeat or export yet. ### Phase 5 · Noticing when it has stopped Build: Record a run row for every scheduled pass, successful or not. Show on every page when the watcher last completed and go loud when it is older than one cadence plus a margin. Write the digest to a Markdown file on disk after each run so it survives the application. Export sources, chunks, conversations, digests and calls as JSON. Trap: A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. Done when: Stopping the scheduler makes the staleness warning appear within one cadence plus the margin, tested by advancing a clock. Every scheduled pass writes a run row including failures with their error text. The digest Markdown file on disk contains the same content as the page and is readable with no application running. The JSON export covers sources, chunks, conversations, digests and calls, and re-importing it into a fresh install restores the library, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - OAuth connectors to mail, calendar and chat accounts, which are the actual product in this category and are an authentication and upkeep commitment rather than an afternoon. - A mobile application and push notifications, which is where an assistant earns its place in a day and is a separate build entirely. - Multi-user access and sharing, which changes the data model and the security story from the first line. - Autonomous actions taken on your behalf, which needs a permission model and an undo story before it needs a model. ### README must contain - That answers cite the chunks they used and a question with no relevant context gets an explicit refusal - That the watcher uses conditional requests, so an unchanged page costs one request and no model call - That a change in the page body which does not change the extracted text produces no digest - How the cost ceiling works, where the rates came from and when they were checked - That the scheduler's last successful run is shown on every page, because the real failure mode is silence ===== ARCHITECTURE.md ===== # Architecture · Littlebird ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 with TypeScript and Fastify, one process | the scheduler and the web interface sharing a process is the whole simplicity | | Storage | SQLite via better-sqlite3, including the vectors | a separate vector service is an extra thing to keep alive for a few thousand chunks | | Interface | Server-rendered HTML with htmx, plain CSS | there is no front end problem here worth a framework | | Model access | One provider behind one module, key and base URL from the environment | swapping providers should be a configuration change | | Fetching | Conditional requests with stored validators, content extraction, hashed snapshots | a page that did not change must cost nothing | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Structure-aware chunking and provenance. | Any extractor, provided non-article pages are rejected. | | Chat | Retrieval, citation and the refusal floor. | Any model, provided uncited answers stay visibly marked. | | Watch | Conditional requests and the two change gates. | Any fetcher, provided a 304 still ends the check. | | Ledger | Call logging, estimates and the ceiling. | Any pricing source, provided rates carry a checked date. | | Heartbeat | Run rows and the staleness warning. | Not swappable for trusting the scheduler, which is the failure mode. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Sources, chunks, conversations, watch items, digests and the call ledger. - `OUT_DIR` · required · The digest Markdown written after each run, so it survives the process. - `MODEL_API_KEY` · required, secret · Chat, digest and embedding calls. The only credential here. - `MODEL_BASE_URL` · optional · Set it to point at a different provider or a local server. - `CHAT_MODEL` · required · Recorded on every call in the ledger, so a model change is visible in the costs. - `EMBED_MODEL` · required · Changing it invalidates existing embeddings, which the app must say rather than silently mix. - `DIGEST_CHAR_CAP` · required · Maximum characters sent per digest call. Exceeding it truncates with a visible note. - `MONTHLY_COST_CEILING_USD` · required · Reaching it pauses scheduled work and says so on every page. ## 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 · Littlebird product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 20 with TypeScript and Fastify, one process, SQLite via better-sqlite3, including the vectors, Server-rendered HTML with htmx, plain CSS, One provider behind one module, key and base URL from the environment, Conditional requests with stored validators, content extraction, hashed snapshots. - 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 - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. - A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. ===== MILESTONES.md ===== # Delivery milestones · Littlebird Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Context with provenance Anything the assistant can use came from a source you can point at. ### Steps 1. Chunk on structure, not on character count The difference between a useful retrieval and a confident fragment. Files: `src/ingest/chunk.ts`, `tests/chunk-structure.test.ts` 2. Reject pages that are not articles A stored login page poisons every later answer. Files: `src/ingest/extract.ts` 3. Make deletion reach the embeddings A deleted source that still answers questions is not deleted. Files: `src/sources/delete.ts` ### Done when - [ ] Structured documents chunk without splitting table rows - [ ] Deleting a source removes it from retrieval - [ ] Non-article URLs are rejected with a reason - [ ] Every chunk traces to a source and ordinal ### Watch out - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. ## M2 · Answers that cite, or say they cannot Every reply names the chunks it used, and a question with no relevant context gets an honest refusal. ### Steps 1. Pass stable chunk ids and require citations Ids in, citations out, or the answer is unverifiable. Files: `src/chat/prompt.ts`, `src/chat/parse.ts` 2. Refuse below the similarity floor An honest 'I do not have that' beats a fluent guess. Files: `src/chat/retrieve.ts`, `tests/no-context-refusal.test.ts` 3. Store supplied and cited ids per turn The audit trail for the answer that was wrong. Files: `src/chat/turns.ts` ### Done when - [ ] Answers cite chunk ids rendered as source links - [ ] An out-of-library question gets an explicit refusal - [ ] Uncited answers are visibly marked - [ ] Supplied and cited ids are stored per turn ### Watch out - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. ## M3 · A watcher that pays only for change A daily check on a page that did not change costs one conditional request and nothing else. ### Steps 1. Send the validators back The cheapest possible check, and the polite one. Files: `src/watch/fetch.ts`, `tests/conditional-request.test.ts` 2. Hash the extracted text, not the body Advertisements and timestamps change; the article does not. Files: `src/watch/extract.ts` 3. Pause an item that keeps failing A broken watcher should be visible, not persistent. Files: `src/watch/health.ts` ### Done when - [ ] A 304 produces no extraction, diff or model call - [ ] Cosmetic body changes produce no digest - [ ] Validators are stored and resent, with a hash fallback - [ ] A repeatedly failing item pauses with its error shown ### Watch out - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. ## M4 · A bill you can see before it arrives Every model call is recorded and a ceiling stops the job rather than the credit card. ### Steps 1. Log every call, without exception One wrapper, and no provider call outside it. Files: `src/model/call.ts`, `tests/all-calls-logged.test.ts` 2. Cap what a digest sends Diff first, truncate second, and say that you did. Files: `src/watch/digest.ts` 3. Pause on the ceiling, visibly The alternative is finding out from a statement. Files: `src/scheduler.ts` ### Done when - [ ] No model call happens outside the logging wrapper - [ ] Oversized pages are truncated with a visible note - [ ] The ceiling pauses the scheduler and is displayed - [ ] Rates show their checked date and their age ### Watch out - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ## M5 · Noticing when it has stopped (production only) A dead scheduler is visible within one cadence, and everything on disk is readable without this app. ### Steps 1. Write a run row for every pass Including the ones that failed, especially those. Files: `src/scheduler.ts` 2. Show the heartbeat everywhere On the digest page, on the chat page, on the source list. Files: `src/views/layout.tsx`, `tests/heartbeat-stale.test.ts` 3. Write the digest to disk So a dead process still leaves something greppable. Files: `src/digest/write.ts` ### Done when - [ ] A stopped scheduler shows a staleness warning in time - [ ] Every pass writes a run row, failures included - [ ] The digest file on disk matches the page - [ ] The JSON export round trips into a fresh install ### Watch out - A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. ===== OPERATIONS.md ===== # Operations · Littlebird ## Backup The database and the output directory. Sources can be re-added but conversations, digests and the call ledger cannot, and the ledger is what tells you whether a cost change came from a model swap or from a chattier watcher. ## Restore Restore and run one scheduled pass: it should produce mostly 304 responses and few model calls, which confirms the validators survived. A pass that suddenly digests everything means the stored validators or hashes were lost. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring The heartbeat itself, spend against the ceiling, consecutive failures per watch item, and the proportion of passes that reached a model call. A rising proportion means a watched page has become noisy and should be narrowed or dropped. ## Incident checklist Silence is the incident. The run table answers whether the scheduler stopped, failed or found nothing, and those three look identical from the digest page unless you record them separately, which is why the run row has a kind and an error field. 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 - [ ] A 304 is confirmed to produce no model call - [ ] A cosmetic change is confirmed to produce no digest - [ ] No model call is confirmed to bypass the ledger - [ ] The ceiling is confirmed to pause the scheduler visibly - [ ] A stopped scheduler is confirmed to raise a staleness warning in time ## Launch constraint Do not market omitted Littlebird 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. Sources, chunks, conversations, watch items, digests and the call ledger. DATABASE_PATH=./data/assistant.db # Required. The digest Markdown written after each run, so it survives the process. OUT_DIR=./out # Required · secret. Chat, digest and embedding calls. The only credential here. MODEL_API_KEY=sk-...redacted # Optional. Set it to point at a different provider or a local server. MODEL_BASE_URL=https://api.example.com/v1 # Required. Recorded on every call in the ledger, so a model change is visible in the costs. CHAT_MODEL=a chat model id from your provider # Required. Changing it invalidates existing embeddings, which the app must say rather than silently mix. EMBED_MODEL=an embedding model id from your provider # Required. Maximum characters sent per digest call. Exceeding it truncates with a visible note. DIGEST_CHAR_CAP=20000 # Required. Reaching it pauses scheduled work and says so on every page. MONTHLY_COST_CEILING_USD=10
# Littlebird · indie build A personal assistant whose hard problems are answering only from retrieved context and saying so when it cannot, keeping a scheduled watcher from spending money on pages that did not change, and noticing when the job that makes it useful has quietly stopped running. 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 20 with TypeScript and Fastify, one process | the scheduler and the web interface sharing a process is the whole simplicity | | Storage | SQLite via better-sqlite3, including the vectors | a separate vector service is an extra thing to keep alive for a few thousand chunks | | Interface | Server-rendered HTML with htmx, plain CSS | there is no front end problem here worth a framework | | Model access | One provider behind one module, key and base URL from the environment | swapping providers should be a configuration change | | Fetching | Conditional requests with stored validators, content extraction, hashed snapshots | a page that did not change must cost nothing | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 20 or newer** · free - Why: Runs the server and the scheduler in one process. - Get it: nodejs.org, or nvm install 20. - Verify: node --version prints v20 or higher - [ ] **A model API key with an embedding model available** · pay per use, typically small for a personal library - Why: Chat, digests and embeddings all go through it, and the cost ledger exists to keep it from surprising you. - Get it: Create an account with a provider, generate a key, and note the chat and embedding model names and their current prices. - Verify: A ten chunk embedding run and a one sentence chat completion both succeed - [ ] **A machine that stays awake** · a few pounds a month - Why: A scheduled watcher on a laptop that sleeps is a digest page that silently stops updating, which is the failure this build is organised around. - Get it: A small VPS, a home server, or any always-on machine. - Verify: A scheduled pass runs overnight and writes a run row - [ ] **Three pages worth watching, of different kinds** · free - Why: One that supports conditional requests, one that does not, and one that changes cosmetically every load. Each exists to make a different gate in phase three fail before it passes. - Get it: Pick from sites you already read, and check their response headers by hand first. - Verify: You know which validators each returns and whether its extracted text is stable - [ ] **Your monthly ceiling and per-call cap** · free - Why: The two numbers that decide whether this stays cheap, and choosing them after a bill means choosing whatever already happened. - Get it: Pick a monthly figure you would not mind, and a character cap per digest call, and write down why. - Verify: Both are in configuration and named in tests ## 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: - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. - Mobile app and notifications; your version is a browser tab - Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against - Someone else tuning prompts and swapping models when a better one ships - Reliability: your cron job dies silently and nobody pages you - Any team or sharing features, if the product has them If one of those is essential to you, that is the reason to keep paying for Littlebird, and the README should say so rather than pretend.
# Build brief · Littlebird 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 local assistant that answers from context you gave it and watches a handful of pages for changes worth telling you about to replace Littlebird. 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 20 with TypeScript and Fastify, one process · the scheduler and the web interface sharing a process is the whole simplicity - Storage: SQLite via better-sqlite3, including the vectors · a separate vector service is an extra thing to keep alive for a few thousand chunks - Interface: Server-rendered HTML with htmx, plain CSS · there is no front end problem here worth a framework - Model access: One provider behind one module, key and base URL from the environment · swapping providers should be a configuration change - Fetching: Conditional requests with stored validators, content extraction, hashed snapshots · a page that did not change must cost nothing ### Data model (create this before Phase 1) - sources(id, kind, title, url, added_at, bytes, sha256) · kind is paste, upload or url - chunks(id, source_id, ordinal, text, token_estimate, embedding) · the unit that gets cited - conversations(id, started_at, title) and turns(conversation_id, ordinal, role, content, cited_chunk_ids_json, at) - watch_items(id, name, url, cadence, last_fetch_at, etag, last_modified, last_sha256, last_status, consecutive_failures, paused) - snapshots(watch_item_id, at, extracted_sha256, extracted_path, bytes) · kept for a stated number of versions - digests(watch_item_id, snapshot_at, summary, model_id, tokens_in, tokens_out, created_at) · only written when something changed - calls(id, at, purpose, model_id, tokens_in, tokens_out, latency_ms, estimated_cost) · every model call, no exceptions - runs(id, kind, started_at, finished_at, items_checked, items_changed, calls_made, error_text) · the heartbeat that proves the job is alive - The watcher's cheapest path is the common one: a 304 response ends the check before extraction, hashing or any model call. ### Phase 1 · Context with provenance Build: Accept pasted text, uploaded Markdown and plain text, and URLs fetched server-side and reduced to readable text. Chunk to a configured size with a small overlap, embed, and store the chunk with its source and ordinal. Show every source with its size, chunk count and date added, and allow deleting a source and everything derived from it. Trap: Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. Done when: A document with headings and a table chunks without splitting a table row across chunks, tested with a fixture. Deleting a source removes its chunks and embeddings and they no longer appear in retrieval, tested. A URL fetch that returns something other than a readable article, such as a login page, is rejected with the reason rather than stored as content. Every chunk can be traced to its source and ordinal in the interface. Do not build yet: No chat, watchlist, digest or cost view yet. ### Phase 2 · Answers that cite, or say they cannot Build: Retrieve the top chunks by similarity, pass them with stable ids, and require the reply to cite the ids it used. Render citations as links to the source. If retrieval returns nothing above a configured similarity floor, say there is no relevant context rather than answering from the model's own knowledge. Trap: Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. Done when: A question whose answer is in one source produces a reply citing that source's chunk ids, rendered as links that open the source. A question about something not in the library produces an explicit statement that there is no relevant context, tested with an empty library and with an unrelated one. An answer citing no chunks is displayed with a visible marker rather than as an ordinary reply. Every turn stores which chunk ids were supplied and which were cited, so a wrong answer can be traced. Do not build yet: No watchlist, digest, cost ceiling or heartbeat yet. ### Phase 3 · A watcher that pays only for change Build: Store the validators the server gave you and send them back. RFC 9110 defines ETag as an opaque validator for a representation and If-None-Match as the way a client asks for it only if it differs, with 304 Not Modified as the answer when it has not (checked against the RFC), so send If-None-Match and If-Modified-Since and stop on a 304. If the body did come back, extract the readable text, hash it, and stop if the hash matches. Only past both gates do you diff and call a model. Trap: Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. Done when: A watched page returning 304 results in no extraction, no diff and no model call, verified by counting calls against a fake server. A page returning 200 with byte differences but identical extracted text, such as a rotating advertisement or a timestamp, produces no digest, tested. Validators are stored and resent, and a server that supplies neither still works via the extracted-text hash. A page that has failed repeatedly is paused with its error visible rather than retried forever. Do not build yet: No digest page, cost ceiling or heartbeat yet. ### Phase 4 · A bill you can see before it arrives Build: Log every call with purpose, model, token counts and an estimated cost from rates held in configuration with their checked date. A fetched page becomes input tokens, and a large page is a large number of them, so cap the text sent per digest. Show spend by day and by purpose. Reaching the monthly ceiling pauses scheduled work and says so on the page. Trap: Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. Done when: Every model call appears in the calls table with purpose, tokens and an estimated cost, with no code path that calls a model without logging. A page larger than the configured cap is truncated with a visible note in the digest rather than sent whole. Reaching the monthly ceiling pauses the scheduler and displays the pause with the figure, and manual chat states that it is over the ceiling rather than failing obscurely. The rates in configuration carry the date they were checked and the page shows their age. Do not build yet: No heartbeat or export yet. ### Phase 5 · Noticing when it has stopped Build: Record a run row for every scheduled pass, successful or not. Show on every page when the watcher last completed and go loud when it is older than one cadence plus a margin. Write the digest to a Markdown file on disk after each run so it survives the application. Export sources, chunks, conversations, digests and calls as JSON. Trap: A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. Done when: Stopping the scheduler makes the staleness warning appear within one cadence plus the margin, tested by advancing a clock. Every scheduled pass writes a run row including failures with their error text. The digest Markdown file on disk contains the same content as the page and is readable with no application running. The JSON export covers sources, chunks, conversations, digests and calls, and re-importing it into a fresh install restores the library, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - OAuth connectors to mail, calendar and chat accounts, which are the actual product in this category and are an authentication and upkeep commitment rather than an afternoon. - A mobile application and push notifications, which is where an assistant earns its place in a day and is a separate build entirely. - Multi-user access and sharing, which changes the data model and the security story from the first line. - Autonomous actions taken on your behalf, which needs a permission model and an undo story before it needs a model. ### README must contain - That answers cite the chunks they used and a question with no relevant context gets an explicit refusal - That the watcher uses conditional requests, so an unchanged page costs one request and no model call - That a change in the page body which does not change the extracted text produces no digest - How the cost ceiling works, where the rates came from and when they were checked - That the scheduler's last successful run is shown on every page, because the real failure mode is silence
# Agent instructions · Littlebird indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 20 with TypeScript and Fastify, one process, SQLite via better-sqlite3, including the vectors, Server-rendered HTML with htmx, plain CSS, One provider behind one module, key and base URL from the environment, Conditional requests with stored validators, content extraction, hashed snapshots. 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 - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at.
# Build plan · Littlebird A personal assistant whose hard problems are answering only from retrieved context and saying so when it cannot, keeping a scheduled watcher from spending money on pages that did not change, and noticing when the job that makes it useful has quietly stopped running. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Context with provenance Anything the assistant can use came from a source you can point at. ### Steps 1. Chunk on structure, not on character count The difference between a useful retrieval and a confident fragment. Files: `src/ingest/chunk.ts`, `tests/chunk-structure.test.ts` 2. Reject pages that are not articles A stored login page poisons every later answer. Files: `src/ingest/extract.ts` 3. Make deletion reach the embeddings A deleted source that still answers questions is not deleted. Files: `src/sources/delete.ts` ### Done when - [ ] Structured documents chunk without splitting table rows - [ ] Deleting a source removes it from retrieval - [ ] Non-article URLs are rejected with a reason - [ ] Every chunk traces to a source and ordinal ### Watch out - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. ## Phase 2 · Answers that cite, or say they cannot Every reply names the chunks it used, and a question with no relevant context gets an honest refusal. ### Steps 1. Pass stable chunk ids and require citations Ids in, citations out, or the answer is unverifiable. Files: `src/chat/prompt.ts`, `src/chat/parse.ts` 2. Refuse below the similarity floor An honest 'I do not have that' beats a fluent guess. Files: `src/chat/retrieve.ts`, `tests/no-context-refusal.test.ts` 3. Store supplied and cited ids per turn The audit trail for the answer that was wrong. Files: `src/chat/turns.ts` ### Done when - [ ] Answers cite chunk ids rendered as source links - [ ] An out-of-library question gets an explicit refusal - [ ] Uncited answers are visibly marked - [ ] Supplied and cited ids are stored per turn ### Watch out - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. ## Phase 3 · A watcher that pays only for change A daily check on a page that did not change costs one conditional request and nothing else. ### Steps 1. Send the validators back The cheapest possible check, and the polite one. Files: `src/watch/fetch.ts`, `tests/conditional-request.test.ts` 2. Hash the extracted text, not the body Advertisements and timestamps change; the article does not. Files: `src/watch/extract.ts` 3. Pause an item that keeps failing A broken watcher should be visible, not persistent. Files: `src/watch/health.ts` ### Done when - [ ] A 304 produces no extraction, diff or model call - [ ] Cosmetic body changes produce no digest - [ ] Validators are stored and resent, with a hash fallback - [ ] A repeatedly failing item pauses with its error shown ### Watch out - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. ## Phase 4 · A bill you can see before it arrives Every model call is recorded and a ceiling stops the job rather than the credit card. ### Steps 1. Log every call, without exception One wrapper, and no provider call outside it. Files: `src/model/call.ts`, `tests/all-calls-logged.test.ts` 2. Cap what a digest sends Diff first, truncate second, and say that you did. Files: `src/watch/digest.ts` 3. Pause on the ceiling, visibly The alternative is finding out from a statement. Files: `src/scheduler.ts` ### Done when - [ ] No model call happens outside the logging wrapper - [ ] Oversized pages are truncated with a visible note - [ ] The ceiling pauses the scheduler and is displayed - [ ] Rates show their checked date and their age ### Watch out - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ## Not in this build - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. ## After v1, if you want it - A second watch cadence such as hourly for one item, which needs nothing new beyond a column already present - Re-embedding on an embedding model change, once the app detects and reports the mismatch - A weekly rollup of digests, built from rows you already store and one extra call per week
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Sources, chunks, conversations, watch items, digests and the call ledger. DATABASE_PATH=./data/assistant.db # Required. The digest Markdown written after each run, so it survives the process. OUT_DIR=./out # Required · secret. Chat, digest and embedding calls. The only credential here. MODEL_API_KEY=sk-...redacted # Optional. Set it to point at a different provider or a local server. MODEL_BASE_URL=https://api.example.com/v1 # Required. Recorded on every call in the ledger, so a model change is visible in the costs. CHAT_MODEL=a chat model id from your provider # Required. Changing it invalidates existing embeddings, which the app must say rather than silently mix. EMBED_MODEL=an embedding model id from your provider # Required. Maximum characters sent per digest call. Exceeding it truncates with a visible note. DIGEST_CHAR_CAP=20000 # Required. Reaching it pauses scheduled work and says so on every page. MONTHLY_COST_CEILING_USD=10
# Littlebird · product brief ## Problem Public detail on this one is thin, so treat this entry as a first pass rather than a teardown. Anything in the "AI assistant that reads and drafts for you" shape is mostly a wrapper: a model API, a place to put your context, and a loop that runs on a schedule. A coding agent can rebuild that shape in an evening and you keep the API bill instead of the subscription. What you will not rebuild in an evening is the polish, the mobile surface, and whatever integrations the paid product ships against your actual accounts. Verdict stays "kinda" until someone confirms whether the real value here is the agent loop or the connectors underneath it. ## Product outcome An assistant that only answers from what you gave it, watches pages without paying for the days they did not change, shows you what it is costing, and tells you loudly when it has stopped running. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An LLM API key (OpenAI or Anthropic) in .env - Node 20+ and a machine or small VPS that stays awake for the cron job - Willingness to paste your own context in instead of having connectors do it ## Explicit non-goals for v1 - OAuth connectors to third-party accounts. - A mobile app and push notifications. - Multi-user access and sharing. - Autonomous actions on your behalf. - Mobile app and notifications; your version is a browser tab - Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against - Someone else tuning prompts and swapping models when a better one ships - Reliability: your cron job dies silently and nobody pages you - Any team or sharing features, if the product has them ## Success criteria - A 304 is confirmed to produce no model call - A cosmetic change is confirmed to produce no digest - No model call is confirmed to bypass the ledger - The ceiling is confirmed to pause the scheduler visibly - A stopped scheduler is confirmed to raise a staleness warning in time
# Build brief · Littlebird 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 local assistant that answers from context you gave it and watches a handful of pages for changes worth telling you about to replace Littlebird. 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 20 with TypeScript and Fastify, one process · the scheduler and the web interface sharing a process is the whole simplicity - Storage: SQLite via better-sqlite3, including the vectors · a separate vector service is an extra thing to keep alive for a few thousand chunks - Interface: Server-rendered HTML with htmx, plain CSS · there is no front end problem here worth a framework - Model access: One provider behind one module, key and base URL from the environment · swapping providers should be a configuration change - Fetching: Conditional requests with stored validators, content extraction, hashed snapshots · a page that did not change must cost nothing ### Data model (create this before Phase 1) - sources(id, kind, title, url, added_at, bytes, sha256) · kind is paste, upload or url - chunks(id, source_id, ordinal, text, token_estimate, embedding) · the unit that gets cited - conversations(id, started_at, title) and turns(conversation_id, ordinal, role, content, cited_chunk_ids_json, at) - watch_items(id, name, url, cadence, last_fetch_at, etag, last_modified, last_sha256, last_status, consecutive_failures, paused) - snapshots(watch_item_id, at, extracted_sha256, extracted_path, bytes) · kept for a stated number of versions - digests(watch_item_id, snapshot_at, summary, model_id, tokens_in, tokens_out, created_at) · only written when something changed - calls(id, at, purpose, model_id, tokens_in, tokens_out, latency_ms, estimated_cost) · every model call, no exceptions - runs(id, kind, started_at, finished_at, items_checked, items_changed, calls_made, error_text) · the heartbeat that proves the job is alive - The watcher's cheapest path is the common one: a 304 response ends the check before extraction, hashing or any model call. ### Phase 1 · Context with provenance Build: Accept pasted text, uploaded Markdown and plain text, and URLs fetched server-side and reduced to readable text. Chunk to a configured size with a small overlap, embed, and store the chunk with its source and ordinal. Show every source with its size, chunk count and date added, and allow deleting a source and everything derived from it. Trap: Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. Done when: A document with headings and a table chunks without splitting a table row across chunks, tested with a fixture. Deleting a source removes its chunks and embeddings and they no longer appear in retrieval, tested. A URL fetch that returns something other than a readable article, such as a login page, is rejected with the reason rather than stored as content. Every chunk can be traced to its source and ordinal in the interface. Do not build yet: No chat, watchlist, digest or cost view yet. ### Phase 2 · Answers that cite, or say they cannot Build: Retrieve the top chunks by similarity, pass them with stable ids, and require the reply to cite the ids it used. Render citations as links to the source. If retrieval returns nothing above a configured similarity floor, say there is no relevant context rather than answering from the model's own knowledge. Trap: Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. Done when: A question whose answer is in one source produces a reply citing that source's chunk ids, rendered as links that open the source. A question about something not in the library produces an explicit statement that there is no relevant context, tested with an empty library and with an unrelated one. An answer citing no chunks is displayed with a visible marker rather than as an ordinary reply. Every turn stores which chunk ids were supplied and which were cited, so a wrong answer can be traced. Do not build yet: No watchlist, digest, cost ceiling or heartbeat yet. ### Phase 3 · A watcher that pays only for change Build: Store the validators the server gave you and send them back. RFC 9110 defines ETag as an opaque validator for a representation and If-None-Match as the way a client asks for it only if it differs, with 304 Not Modified as the answer when it has not (checked against the RFC), so send If-None-Match and If-Modified-Since and stop on a 304. If the body did come back, extract the readable text, hash it, and stop if the hash matches. Only past both gates do you diff and call a model. Trap: Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. Done when: A watched page returning 304 results in no extraction, no diff and no model call, verified by counting calls against a fake server. A page returning 200 with byte differences but identical extracted text, such as a rotating advertisement or a timestamp, produces no digest, tested. Validators are stored and resent, and a server that supplies neither still works via the extracted-text hash. A page that has failed repeatedly is paused with its error visible rather than retried forever. Do not build yet: No digest page, cost ceiling or heartbeat yet. ### Phase 4 · A bill you can see before it arrives Build: Log every call with purpose, model, token counts and an estimated cost from rates held in configuration with their checked date. A fetched page becomes input tokens, and a large page is a large number of them, so cap the text sent per digest. Show spend by day and by purpose. Reaching the monthly ceiling pauses scheduled work and says so on the page. Trap: Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. Done when: Every model call appears in the calls table with purpose, tokens and an estimated cost, with no code path that calls a model without logging. A page larger than the configured cap is truncated with a visible note in the digest rather than sent whole. Reaching the monthly ceiling pauses the scheduler and displays the pause with the figure, and manual chat states that it is over the ceiling rather than failing obscurely. The rates in configuration carry the date they were checked and the page shows their age. Do not build yet: No heartbeat or export yet. ### Phase 5 · Noticing when it has stopped Build: Record a run row for every scheduled pass, successful or not. Show on every page when the watcher last completed and go loud when it is older than one cadence plus a margin. Write the digest to a Markdown file on disk after each run so it survives the application. Export sources, chunks, conversations, digests and calls as JSON. Trap: A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss. Done when: Stopping the scheduler makes the staleness warning appear within one cadence plus the margin, tested by advancing a clock. Every scheduled pass writes a run row including failures with their error text. The digest Markdown file on disk contains the same content as the page and is readable with no application running. The JSON export covers sources, chunks, conversations, digests and calls, and re-importing it into a fresh install restores the library, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - OAuth connectors to mail, calendar and chat accounts, which are the actual product in this category and are an authentication and upkeep commitment rather than an afternoon. - A mobile application and push notifications, which is where an assistant earns its place in a day and is a separate build entirely. - Multi-user access and sharing, which changes the data model and the security story from the first line. - Autonomous actions taken on your behalf, which needs a permission model and an undo story before it needs a model. ### README must contain - That answers cite the chunks they used and a question with no relevant context gets an explicit refusal - That the watcher uses conditional requests, so an unchanged page costs one request and no model call - That a change in the page body which does not change the extracted text produces no digest - How the cost ceiling works, where the rates came from and when they were checked - That the scheduler's last successful run is shown on every page, because the real failure mode is silence
# Architecture · Littlebird ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 with TypeScript and Fastify, one process | the scheduler and the web interface sharing a process is the whole simplicity | | Storage | SQLite via better-sqlite3, including the vectors | a separate vector service is an extra thing to keep alive for a few thousand chunks | | Interface | Server-rendered HTML with htmx, plain CSS | there is no front end problem here worth a framework | | Model access | One provider behind one module, key and base URL from the environment | swapping providers should be a configuration change | | Fetching | Conditional requests with stored validators, content extraction, hashed snapshots | a page that did not change must cost nothing | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Structure-aware chunking and provenance. | Any extractor, provided non-article pages are rejected. | | Chat | Retrieval, citation and the refusal floor. | Any model, provided uncited answers stay visibly marked. | | Watch | Conditional requests and the two change gates. | Any fetcher, provided a 304 still ends the check. | | Ledger | Call logging, estimates and the ceiling. | Any pricing source, provided rates carry a checked date. | | Heartbeat | Run rows and the staleness warning. | Not swappable for trusting the scheduler, which is the failure mode. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Sources, chunks, conversations, watch items, digests and the call ledger. - `OUT_DIR` · required · The digest Markdown written after each run, so it survives the process. - `MODEL_API_KEY` · required, secret · Chat, digest and embedding calls. The only credential here. - `MODEL_BASE_URL` · optional · Set it to point at a different provider or a local server. - `CHAT_MODEL` · required · Recorded on every call in the ledger, so a model change is visible in the costs. - `EMBED_MODEL` · required · Changing it invalidates existing embeddings, which the app must say rather than silently mix. - `DIGEST_CHAR_CAP` · required · Maximum characters sent per digest call. Exceeding it truncates with a visible note. - `MONTHLY_COST_CEILING_USD` · required · Reaching it pauses scheduled work and says so on every page. ## 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 · Littlebird product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 20 with TypeScript and Fastify, one process, SQLite via better-sqlite3, including the vectors, Server-rendered HTML with htmx, plain CSS, One provider behind one module, key and base URL from the environment, Conditional requests with stored validators, content extraction, hashed snapshots. - 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 - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. - A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss.
# Delivery milestones · Littlebird Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Context with provenance Anything the assistant can use came from a source you can point at. ### Steps 1. Chunk on structure, not on character count The difference between a useful retrieval and a confident fragment. Files: `src/ingest/chunk.ts`, `tests/chunk-structure.test.ts` 2. Reject pages that are not articles A stored login page poisons every later answer. Files: `src/ingest/extract.ts` 3. Make deletion reach the embeddings A deleted source that still answers questions is not deleted. Files: `src/sources/delete.ts` ### Done when - [ ] Structured documents chunk without splitting table rows - [ ] Deleting a source removes it from retrieval - [ ] Non-article URLs are rejected with a reason - [ ] Every chunk traces to a source and ordinal ### Watch out - Chunking by a fixed character count cuts sentences and tables in half, so retrieval returns fragments that read as nonsense and the answers built on them are subtly wrong with no visible cause. Chunk on structure first, paragraphs and headings, falling back to size only when a block is too large, and store enough overlap that a split sentence appears whole in one of the two chunks. ## M2 · Answers that cite, or say they cannot Every reply names the chunks it used, and a question with no relevant context gets an honest refusal. ### Steps 1. Pass stable chunk ids and require citations Ids in, citations out, or the answer is unverifiable. Files: `src/chat/prompt.ts`, `src/chat/parse.ts` 2. Refuse below the similarity floor An honest 'I do not have that' beats a fluent guess. Files: `src/chat/retrieve.ts`, `tests/no-context-refusal.test.ts` 3. Store supplied and cited ids per turn The audit trail for the answer that was wrong. Files: `src/chat/turns.ts` ### Done when - [ ] Answers cite chunk ids rendered as source links - [ ] An out-of-library question gets an explicit refusal - [ ] Uncited answers are visibly marked - [ ] Supplied and cited ids are stored per turn ### Watch out - Dropping retrieved text into the prompt without ids and rendering whatever comes back produces answers that mix your documents with the model's general knowledge, indistinguishably, so you cannot tell which sentences your own notes support. Pass chunk ids, require citations, and show an answer citing nothing as exactly that. ## M3 · A watcher that pays only for change A daily check on a page that did not change costs one conditional request and nothing else. ### Steps 1. Send the validators back The cheapest possible check, and the polite one. Files: `src/watch/fetch.ts`, `tests/conditional-request.test.ts` 2. Hash the extracted text, not the body Advertisements and timestamps change; the article does not. Files: `src/watch/extract.ts` 3. Pause an item that keeps failing A broken watcher should be visible, not persistent. Files: `src/watch/health.ts` ### Done when - [ ] A 304 produces no extraction, diff or model call - [ ] Cosmetic body changes produce no digest - [ ] Validators are stored and resent, with a hash fallback - [ ] A repeatedly failing item pauses with its error shown ### Watch out - Refetching every watched page daily and asking a model what changed spends real money on pages that did not change at all, and since page bodies are large, a handful of items becomes a monthly bill out of all proportion to the value. Send conditional requests, then compare the extracted text hash, and treat a model call as the last resort rather than the first step. ## M4 · A bill you can see before it arrives Every model call is recorded and a ceiling stops the job rather than the credit card. ### Steps 1. Log every call, without exception One wrapper, and no provider call outside it. Files: `src/model/call.ts`, `tests/all-calls-logged.test.ts` 2. Cap what a digest sends Diff first, truncate second, and say that you did. Files: `src/watch/digest.ts` 3. Pause on the ceiling, visibly The alternative is finding out from a statement. Files: `src/scheduler.ts` ### Done when - [ ] No model call happens outside the logging wrapper - [ ] Oversized pages are truncated with a visible note - [ ] The ceiling pauses the scheduler and is displayed - [ ] Rates show their checked date and their age ### Watch out - Sending an entire long page into a summarisation call each time it changes turns one watched documentation site into the majority of your bill, and because each individual call looks reasonable the pattern is invisible until the month ends. Cap the characters sent per call, send the diff rather than the whole page where you can, and put spend by purpose on a page you actually look at. ## M5 · Noticing when it has stopped (production only) A dead scheduler is visible within one cadence, and everything on disk is readable without this app. ### Steps 1. Write a run row for every pass Including the ones that failed, especially those. Files: `src/scheduler.ts` 2. Show the heartbeat everywhere On the digest page, on the chat page, on the source list. Files: `src/views/layout.tsx`, `tests/heartbeat-stale.test.ts` 3. Write the digest to disk So a dead process still leaves something greppable. Files: `src/digest/write.ts` ### Done when - [ ] A stopped scheduler shows a staleness warning in time - [ ] Every pass writes a run row, failures included - [ ] The digest file on disk matches the page - [ ] The JSON export round trips into a fresh install ### Watch out - A personal assistant does not fail with an error, it fails by quietly not running: the process dies after a reboot, the digest page keeps showing last week's summaries, and you carry on believing it is watching until you notice the dates. Record every run, show the last successful one on every page, and make a stale heartbeat impossible to miss.
# Operations · Littlebird ## Backup The database and the output directory. Sources can be re-added but conversations, digests and the call ledger cannot, and the ledger is what tells you whether a cost change came from a model swap or from a chattier watcher. ## Restore Restore and run one scheduled pass: it should produce mostly 304 responses and few model calls, which confirms the validators survived. A pass that suddenly digests everything means the stored validators or hashes were lost. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring The heartbeat itself, spend against the ceiling, consecutive failures per watch item, and the proportion of passes that reached a model call. A rising proportion means a watched page has become noisy and should be narrowed or dropped. ## Incident checklist Silence is the incident. The run table answers whether the scheduler stopped, failed or found nothing, and those three look identical from the digest page unless you record them separately, which is why the run row has a kind and an error field. 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 - [ ] A 304 is confirmed to produce no model call - [ ] A cosmetic change is confirmed to produce no digest - [ ] No model call is confirmed to bypass the ledger - [ ] The ceiling is confirmed to pause the scheduler visibly - [ ] A stopped scheduler is confirmed to raise a staleness warning in time ## Launch constraint Do not market omitted Littlebird 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. Sources, chunks, conversations, watch items, digests and the call ledger. DATABASE_PATH=./data/assistant.db # Required. The digest Markdown written after each run, so it survives the process. OUT_DIR=./out # Required · secret. Chat, digest and embedding calls. The only credential here. MODEL_API_KEY=sk-...redacted # Optional. Set it to point at a different provider or a local server. MODEL_BASE_URL=https://api.example.com/v1 # Required. Recorded on every call in the ledger, so a model change is visible in the costs. CHAT_MODEL=a chat model id from your provider # Required. Changing it invalidates existing embeddings, which the app must say rather than silently mix. EMBED_MODEL=an embedding model id from your provider # Required. Maximum characters sent per digest call. Exceeding it truncates with a visible note. DIGEST_CHAR_CAP=20000 # Required. Reaching it pauses scheduled work and says so on every page. MONTHLY_COST_CEILING_USD=10
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because assembling the loop is the easy 20 percent and living with it is the other 80. A paid assistant already has the auth flows, the retry logic, the mobile push, and a prompt someone iterated on for months against real complaints. A self-hosted clone works great for two weeks and then you stop opening the tab, which is the actual failure mode of every personal AI build.
xMobile app and notifications; your version is a browser tab
xPrebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against
xSomeone else tuning prompts and swapping models when a better one ships
xReliability: your cron job dies silently and nobody pages you
xAny team or sharing features, if the product has them
Nothing worth pointing at. That's why the prompt exists.
Vibecode Littlebird
Kinda. The core of Littlebird is buildable in a weekend with the prompt on this page, but there are real gaps: Mobile app and notifications; your version is a browser tab, Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against. Read the honest list above before committing.
How much does Littlebird cost?
Littlebird costs about $20/month (Plus, checked 2026-08-18), which is $240 per year.
What do I lose by replacing Littlebird?
Honestly: Mobile app and notifications; your version is a browser tab; Prebuilt connectors to email, calendar, Slack and whatever else the paid product authenticates against; Someone else tuning prompts and swapping models when a better one ships; Reliability: your cron job dies silently and nobody pages you; Any team or sharing features, if the product has them. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Littlebird?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.