Vibecode Animam.ai
track this build4 phases, 12 steps, beginner friendly0%The chat is a weekend. Everything that makes it safe to point at customers is not. Ingest a site, search it, stream an answer · that part is commoditised and the prompt below really does it. What resists is the boring half: an amount computed by the server and never by the model, a visitor email verified before it triggers anything, signed webhooks with retries, and a sending domain whose reputation you did not build in an afternoon.
You are building a lean indie version of Animam.ai. 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 ===== # Animam.ai · indie build A grounded site chat whose hard problems are retrieval you can explain, an answer that cannot step outside the pages it was given even when those pages contain instructions, and a widget that cannot become the vulnerability on somebody else's site. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small HTTP framework, one process | this is an ingest script, a search function and a streaming endpoint | | Storage | SQLite holding pages, sessions, messages and the retrieval log | you will want to read back what people asked and why each passage was chosen | | Retrieval | Keyword matching over stored text, ranked, with the matched terms recorded | under a few hundred pages this is instant, free and inspectable | | Model | One streaming call per question with the retrieved passages as quoted data | the model writes the sentence and never supplies the fact | | Widget | An inline script building a shadow root, writing text only, served from your own domain | the text it renders came from pages a model read | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the ingest script and the streaming server. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A site you control, with a sitemap** · free - Why: This build ingests one site and the permission question has to be settled before the first crawl rather than after it. - Get it: Your own site, or one you have written permission to index, with a reachable sitemap. - Verify: The sitemap lists the pages you expect and the crawl report matches - [ ] **A small always-on server with a certificate** · a few units of currency a month - Why: The widget must be served from a domain you control and the streaming endpoint has to be up whenever the site is. - Get it: A small virtual server, or any host that runs a long-lived Node process. - Verify: The widget loads over HTTPS from your own domain and streams an answer - [ ] **An API key for a streaming text model** · pay per question; the ceiling in this build is what keeps it bounded - Why: One call per question, and the only recurring cost besides the server. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A question over your own corpus streams an answer with a resolvable citation - [ ] **Twenty real questions with known answers** · free - Why: Retrieval quality is only measurable against questions whose answers you already know, and choosing them afterwards means choosing the ones it got right. - Get it: Write them down before you build the search, from support mail or from the site's own pages. - Verify: The suite runs them and reports how many retrieved the right page ## 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: - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. - Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number - Visitor identity verified by one-time code before any server-to-server action runs - Signed outgoing webhooks with exponential backoff, delivery log and manual redelivery - SSRF guard with DNS resolution on every URL the agent is allowed to call - Per-tenant secrets encrypted at rest, and tenant isolation you did not have to think about - Email deliverability · a warmed sending domain is not something you prompt into existence - The evening the model provider changes a default and your widget starts making things up If one of those is essential to you, that is the reason to keep paying for Animam.ai, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Animam.ai 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 website chat widget that answers only from the site's own pages, cites the page it used, and refuses everything else to replace Animam.ai. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small HTTP framework, one process · this is an ingest script, a search function and a streaming endpoint - Storage: SQLite holding pages, sessions, messages and the retrieval log · you will want to read back what people asked and why each passage was chosen - Retrieval: Keyword matching over stored text, ranked, with the matched terms recorded · under a few hundred pages this is instant, free and inspectable - Model: One streaming call per question with the retrieved passages as quoted data · the model writes the sentence and never supplies the fact - Widget: An inline script building a shadow root, writing text only, served from your own domain · the text it renders came from pages a model read ### Data model (create this before Phase 1) - pages(url PRIMARY KEY, title, body, content_hash, fetched_at, gone_at) · the URL is the key so a re-crawl updates rather than duplicates - crawls(id, started_at, finished_at, added, changed, removed, errors_json) · each ingest run is a row you can compare against the last - sessions(id, created_at, last_seen_at, origin, blocked_at) · one per visitor, with the origin it was served to - messages(id, session_id, role, text, at, model_id, prompt_version) - retrievals(id, message_id, url, rank, matched_terms_json, excerpt_start, excerpt_len) · why that passage, for that answer - refusals(id, message_id, reason, question_text, at) · the most useful table in the application - There is no table of prices, stock levels or dates. If a number is not written on a page, this application does not have it. ### Phase 1 · Ingest a site you are allowed to ingest, and know what changed Build: Read the sitemap of a site you control or have written permission for, fetch each page with modest concurrency, skip non-HTML, strip navigation, header, footer and script elements, and convert what remains to plain text. Upsert by URL with a content hash. Mark pages absent from the sitemap as gone. Produce a report of added, changed, removed and failed pages after every run. Trap: Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. Done when: Running ingest twice over an unchanged site adds no rows and reports zero changes, tested against a fixture server. A page removed from the sitemap is marked gone and stops being retrieved, tested. Non-HTML responses and fetch failures are recorded in the crawl report and do not abort the run. Every stored page carries a fetch time and a content hash. Do not build yet: No search, answers, widget or logging of conversations yet. ### Phase 2 · Retrieval you can explain to somebody annoyed Build: Take the most distinctive words from the question, match them against stored page text, rank by how many matched and how close together, and return a small fixed number of passages. Record the passages, their ranks and the matched terms against the message. A question that matches nothing goes down the refusal path rather than to the model. Trap: Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. Done when: Every answer stores its retrieved passages with ranks and matched terms, checked in the database after a conversation. Re-running retrieval for the same question against the same corpus returns the same passages in the same order, tested. A question matching no passage produces a refusal and no model call, tested. The number of passages passed to the model is fixed and enforced rather than incidental. Do not build yet: No answering, widget, rate limiting or review tooling yet. ### Phase 3 · An answer that cannot leave its context Build: Put the passages into the prompt inside a delimiter the system message names as quoted data that is never to be followed as instruction. Require a citation by passage number, resolve that number to a URL you actually retrieved, and refuse the answer if it does not resolve. Forbid stating any price, date or availability that is not present in the passages. Record every refusal with a reason. Trap: The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. Trap: A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. Done when: A fixture page containing an instruction telling the model to ignore its rules does not change the answer or the citation, tested end to end. An answer citing a passage number that does not resolve is refused and logged rather than displayed, tested by stubbing the model. A pricing question with no figure in any passage produces a refusal and an offer to take a message, tested. Every refusal writes a row with its reason and the question. Do not build yet: No widget hardening, rate limits or review tooling yet. ### Phase 4 · A widget that cannot become the vulnerability Build: One script served from your own domain, building a shadow root and writing every message with text nodes only. Construct links yourself from the retrieved URL list rather than from anything the model wrote. Keep the session identifier in local storage. Rate limit per session and per address, and cap the monthly model spend with the take-a-message form as the fallback. Trap: Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. Done when: A stubbed model response containing script markup is displayed as visible text and executes nothing, tested in a browser. Links in an answer are constructed from the retrieved URL list and a URL invented by the model never becomes a link, tested. A session over the rate limit receives a message rather than an error, tested at the boundary. Reaching the spend ceiling disables answering and shows the take-a-message form, tested. Do not build yet: No conversation review, re-crawl schedule or deletion yet. ### Phase 5 · Leaving it in front of customers Build: A daily review listing refusals first, then answers and their citations. A scheduled re-crawl that mails you its report and flags answers whose cited pages have changed since. A global off switch stored in the database. Export of conversations, and deletion of a session's messages on request with a receipt. Trap: Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. Done when: The daily review lists refusals first with counts, then answers with their citations, checked against the database. A re-crawl that changes a page flags every prior answer citing it, tested. The off switch stops answering within one request and survives a restart, tested. Deleting a session removes its messages, retrievals and refusals and produces a receipt with counts, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Quoting an amount, which must be computed by a server from a price list and never produced by a model, and is a week of work rather than a line. - Booking on a real calendar and taking payment, both of which need verified visitor identity before anything is written anywhere. - Transactional email that reaches an inbox, which is a sending reputation you cannot build in a weekend and a maintenance job afterwards. - Multi-tenancy and per-customer secrets, which turns a weekend project into an obligation to other people's data. ### README must contain - That this ingests only a site you control or have written permission to ingest, and how the sitemap is chosen - That retrieval is keyword based on purpose, what gets logged per answer, and at what corpus size you should reconsider - That retrieved page text is treated as quoted data and never as instruction, and what is stripped at ingest - That the widget writes text nodes only and constructs links from the retrieved list rather than from the model - Where the refusal list lives, why it is the most useful screen, and how to delete a visitor's conversation ===== AGENTS.md ===== # Agent instructions · Animam.ai indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and a small HTTP framework, one process, SQLite holding pages, sessions, messages and the retrieval log, Keyword matching over stored text, ranked, with the matched terms recorded, One streaming call per question with the retrieved passages as quoted data, An inline script building a shadow root, writing text only, served from your own domain. 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 - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ===== BUILD_PLAN.md ===== # Build plan · Animam.ai A grounded site chat whose hard problems are retrieval you can explain, an answer that cannot step outside the pages it was given even when those pages contain instructions, and a widget that cannot become the vulnerability on somebody else's site. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Ingest a site you are allowed to ingest, and know what changed A re-crawl updates the corpus and reports what moved rather than quietly doubling it. ### Steps 1. Make the URL the primary key and upsert The second crawl is where this design either holds or does not. Files: `scripts/ingest.ts`, `tests/ingest.test.ts` 2. Store a content hash and compare runs So a re-crawl is a report rather than a hope. Files: `src/crawl/report.ts` 3. Record failures instead of aborting One redirect loop should not cost you the whole corpus. Files: `scripts/ingest.ts` ### Done when - [ ] A second identical crawl adds nothing - [ ] Removed pages are marked gone and stop being retrieved - [ ] Failures are recorded and the run continues - [ ] Pages carry fetch time and content hash ### Watch out - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. ## Phase 2 · Retrieval you can explain to somebody annoyed Every answer records which passages it used and which terms matched. ### Steps 1. Rank on matches you can print An explanation beats a score nobody can read. Files: `src/search.ts`, `tests/search.test.ts` 2. Log passages and matched terms per message This table is how you debug a bad answer. Files: `src/db/retrievals.ts` 3. Send nothing to the model when nothing matched A refusal is cheaper and more honest than a guess. Files: `src/answer.ts` ### Done when - [ ] Retrievals are recorded with ranks and terms - [ ] The same question returns the same passages - [ ] No match means no model call - [ ] The passage count is fixed and enforced ### Watch out - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. ## Phase 3 · An answer that cannot leave its context The model writes sentences from the passages and refuses everything else, including instructions hidden in those passages. ### Steps 1. Delimit retrieved text as quoted data And say in the system message that it is never instruction. Files: `src/prompt.ts`, `tests/injection.test.ts` 2. Resolve citations against what you sent The model's URL is a guess; your passage list is a fact. Files: `src/answer.ts`, `tests/citation.test.ts` 3. Make refusals a logged event Refusals are the list of things your site fails to say. Files: `src/db/refusals.ts` ### Done when - [ ] An injected instruction in a page changes nothing - [ ] An unresolvable citation is refused, not displayed - [ ] A figure absent from the passages is refused - [ ] Refusals are logged with a reason ### Watch out - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. ## Phase 4 · A widget that cannot become the vulnerability Installing this on a page adds one script of yours and no new attack surface. ### Steps 1. Write messages with text nodes only The content came from the open web by way of a model. Files: `public/widget.js`, `tests/widget-xss.test.ts` 2. Build links from your URL list Never from the model's output. Files: `public/widget.js`, `src/answer.ts` 3. Rate limit and cap the spend Both of which degrade to the message form, not to an error. Files: `src/ratelimit.ts`, `src/cost.ts` ### Done when - [ ] Script markup in a response renders as text - [ ] Links come only from the retrieved URL list - [ ] Rate limited sessions get a message - [ ] The ceiling degrades to the message form ### Watch out - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ## Not in this build - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. ## After v1, if you want it - A curated synonym list built from the refusal log, which is the cheapest retrieval improvement available - A human handover that opens a conversation in whatever inbox you already read - A page suggestion report: the questions refused most often, written as a list of pages to publish ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Server side only. It must never appear in the widget bundle. MODEL_API_KEY=sk-...redacted # Required. Recorded on every message, because answers differ by model. MODEL_ID=a streaming text model identifier # Required. Pages, sessions, messages, retrievals and refusals. DATABASE_PATH=./data/chat.db # Required. The one site this installation answers about. SITEMAP_URL=https://example.com/sitemap.xml # Required. Where the widget may be embedded. Not a wildcard in production. ALLOWED_ORIGINS=https://example.com # Required. Fixed and enforced. More passages is not more accuracy. PASSAGES_PER_ANSWER=5 # Required. Degrades to a message rather than an error. SESSION_RATE_LIMIT_PER_MINUTE=6 # Required. At the ceiling the widget offers to take a message instead. MONTHLY_COST_CEILING=20.00
You are building a lean indie version of Animam.ai. 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 ===== # Animam.ai · indie build A grounded site chat whose hard problems are retrieval you can explain, an answer that cannot step outside the pages it was given even when those pages contain instructions, and a widget that cannot become the vulnerability on somebody else's site. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small HTTP framework, one process | this is an ingest script, a search function and a streaming endpoint | | Storage | SQLite holding pages, sessions, messages and the retrieval log | you will want to read back what people asked and why each passage was chosen | | Retrieval | Keyword matching over stored text, ranked, with the matched terms recorded | under a few hundred pages this is instant, free and inspectable | | Model | One streaming call per question with the retrieved passages as quoted data | the model writes the sentence and never supplies the fact | | Widget | An inline script building a shadow root, writing text only, served from your own domain | the text it renders came from pages a model read | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the ingest script and the streaming server. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A site you control, with a sitemap** · free - Why: This build ingests one site and the permission question has to be settled before the first crawl rather than after it. - Get it: Your own site, or one you have written permission to index, with a reachable sitemap. - Verify: The sitemap lists the pages you expect and the crawl report matches - [ ] **A small always-on server with a certificate** · a few units of currency a month - Why: The widget must be served from a domain you control and the streaming endpoint has to be up whenever the site is. - Get it: A small virtual server, or any host that runs a long-lived Node process. - Verify: The widget loads over HTTPS from your own domain and streams an answer - [ ] **An API key for a streaming text model** · pay per question; the ceiling in this build is what keeps it bounded - Why: One call per question, and the only recurring cost besides the server. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A question over your own corpus streams an answer with a resolvable citation - [ ] **Twenty real questions with known answers** · free - Why: Retrieval quality is only measurable against questions whose answers you already know, and choosing them afterwards means choosing the ones it got right. - Get it: Write them down before you build the search, from support mail or from the site's own pages. - Verify: The suite runs them and reports how many retrieved the right page ## 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: - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. - Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number - Visitor identity verified by one-time code before any server-to-server action runs - Signed outgoing webhooks with exponential backoff, delivery log and manual redelivery - SSRF guard with DNS resolution on every URL the agent is allowed to call - Per-tenant secrets encrypted at rest, and tenant isolation you did not have to think about - Email deliverability · a warmed sending domain is not something you prompt into existence - The evening the model provider changes a default and your widget starts making things up If one of those is essential to you, that is the reason to keep paying for Animam.ai, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Animam.ai 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 website chat widget that answers only from the site's own pages, cites the page it used, and refuses everything else to replace Animam.ai. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small HTTP framework, one process · this is an ingest script, a search function and a streaming endpoint - Storage: SQLite holding pages, sessions, messages and the retrieval log · you will want to read back what people asked and why each passage was chosen - Retrieval: Keyword matching over stored text, ranked, with the matched terms recorded · under a few hundred pages this is instant, free and inspectable - Model: One streaming call per question with the retrieved passages as quoted data · the model writes the sentence and never supplies the fact - Widget: An inline script building a shadow root, writing text only, served from your own domain · the text it renders came from pages a model read ### Data model (create this before Phase 1) - pages(url PRIMARY KEY, title, body, content_hash, fetched_at, gone_at) · the URL is the key so a re-crawl updates rather than duplicates - crawls(id, started_at, finished_at, added, changed, removed, errors_json) · each ingest run is a row you can compare against the last - sessions(id, created_at, last_seen_at, origin, blocked_at) · one per visitor, with the origin it was served to - messages(id, session_id, role, text, at, model_id, prompt_version) - retrievals(id, message_id, url, rank, matched_terms_json, excerpt_start, excerpt_len) · why that passage, for that answer - refusals(id, message_id, reason, question_text, at) · the most useful table in the application - There is no table of prices, stock levels or dates. If a number is not written on a page, this application does not have it. ### Phase 1 · Ingest a site you are allowed to ingest, and know what changed Build: Read the sitemap of a site you control or have written permission for, fetch each page with modest concurrency, skip non-HTML, strip navigation, header, footer and script elements, and convert what remains to plain text. Upsert by URL with a content hash. Mark pages absent from the sitemap as gone. Produce a report of added, changed, removed and failed pages after every run. Trap: Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. Done when: Running ingest twice over an unchanged site adds no rows and reports zero changes, tested against a fixture server. A page removed from the sitemap is marked gone and stops being retrieved, tested. Non-HTML responses and fetch failures are recorded in the crawl report and do not abort the run. Every stored page carries a fetch time and a content hash. Do not build yet: No search, answers, widget or logging of conversations yet. ### Phase 2 · Retrieval you can explain to somebody annoyed Build: Take the most distinctive words from the question, match them against stored page text, rank by how many matched and how close together, and return a small fixed number of passages. Record the passages, their ranks and the matched terms against the message. A question that matches nothing goes down the refusal path rather than to the model. Trap: Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. Done when: Every answer stores its retrieved passages with ranks and matched terms, checked in the database after a conversation. Re-running retrieval for the same question against the same corpus returns the same passages in the same order, tested. A question matching no passage produces a refusal and no model call, tested. The number of passages passed to the model is fixed and enforced rather than incidental. Do not build yet: No answering, widget, rate limiting or review tooling yet. ### Phase 3 · An answer that cannot leave its context Build: Put the passages into the prompt inside a delimiter the system message names as quoted data that is never to be followed as instruction. Require a citation by passage number, resolve that number to a URL you actually retrieved, and refuse the answer if it does not resolve. Forbid stating any price, date or availability that is not present in the passages. Record every refusal with a reason. Trap: The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. Trap: A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. Done when: A fixture page containing an instruction telling the model to ignore its rules does not change the answer or the citation, tested end to end. An answer citing a passage number that does not resolve is refused and logged rather than displayed, tested by stubbing the model. A pricing question with no figure in any passage produces a refusal and an offer to take a message, tested. Every refusal writes a row with its reason and the question. Do not build yet: No widget hardening, rate limits or review tooling yet. ### Phase 4 · A widget that cannot become the vulnerability Build: One script served from your own domain, building a shadow root and writing every message with text nodes only. Construct links yourself from the retrieved URL list rather than from anything the model wrote. Keep the session identifier in local storage. Rate limit per session and per address, and cap the monthly model spend with the take-a-message form as the fallback. Trap: Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. Done when: A stubbed model response containing script markup is displayed as visible text and executes nothing, tested in a browser. Links in an answer are constructed from the retrieved URL list and a URL invented by the model never becomes a link, tested. A session over the rate limit receives a message rather than an error, tested at the boundary. Reaching the spend ceiling disables answering and shows the take-a-message form, tested. Do not build yet: No conversation review, re-crawl schedule or deletion yet. ### Phase 5 · Leaving it in front of customers Build: A daily review listing refusals first, then answers and their citations. A scheduled re-crawl that mails you its report and flags answers whose cited pages have changed since. A global off switch stored in the database. Export of conversations, and deletion of a session's messages on request with a receipt. Trap: Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. Done when: The daily review lists refusals first with counts, then answers with their citations, checked against the database. A re-crawl that changes a page flags every prior answer citing it, tested. The off switch stops answering within one request and survives a restart, tested. Deleting a session removes its messages, retrievals and refusals and produces a receipt with counts, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Quoting an amount, which must be computed by a server from a price list and never produced by a model, and is a week of work rather than a line. - Booking on a real calendar and taking payment, both of which need verified visitor identity before anything is written anywhere. - Transactional email that reaches an inbox, which is a sending reputation you cannot build in a weekend and a maintenance job afterwards. - Multi-tenancy and per-customer secrets, which turns a weekend project into an obligation to other people's data. ### README must contain - That this ingests only a site you control or have written permission to ingest, and how the sitemap is chosen - That retrieval is keyword based on purpose, what gets logged per answer, and at what corpus size you should reconsider - That retrieved page text is treated as quoted data and never as instruction, and what is stripped at ingest - That the widget writes text nodes only and constructs links from the retrieved list rather than from the model - Where the refusal list lives, why it is the most useful screen, and how to delete a visitor's conversation ===== AGENTS.md ===== # Agent instructions · Animam.ai indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and a small HTTP framework, one process, SQLite holding pages, sessions, messages and the retrieval log, Keyword matching over stored text, ranked, with the matched terms recorded, One streaming call per question with the retrieved passages as quoted data, An inline script building a shadow root, writing text only, served from your own domain. 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 - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ===== BUILD_PLAN.md ===== # Build plan · Animam.ai A grounded site chat whose hard problems are retrieval you can explain, an answer that cannot step outside the pages it was given even when those pages contain instructions, and a widget that cannot become the vulnerability on somebody else's site. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Ingest a site you are allowed to ingest, and know what changed A re-crawl updates the corpus and reports what moved rather than quietly doubling it. ### Steps 1. Make the URL the primary key and upsert The second crawl is where this design either holds or does not. Files: `scripts/ingest.ts`, `tests/ingest.test.ts` 2. Store a content hash and compare runs So a re-crawl is a report rather than a hope. Files: `src/crawl/report.ts` 3. Record failures instead of aborting One redirect loop should not cost you the whole corpus. Files: `scripts/ingest.ts` ### Done when - [ ] A second identical crawl adds nothing - [ ] Removed pages are marked gone and stop being retrieved - [ ] Failures are recorded and the run continues - [ ] Pages carry fetch time and content hash ### Watch out - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. ## Phase 2 · Retrieval you can explain to somebody annoyed Every answer records which passages it used and which terms matched. ### Steps 1. Rank on matches you can print An explanation beats a score nobody can read. Files: `src/search.ts`, `tests/search.test.ts` 2. Log passages and matched terms per message This table is how you debug a bad answer. Files: `src/db/retrievals.ts` 3. Send nothing to the model when nothing matched A refusal is cheaper and more honest than a guess. Files: `src/answer.ts` ### Done when - [ ] Retrievals are recorded with ranks and terms - [ ] The same question returns the same passages - [ ] No match means no model call - [ ] The passage count is fixed and enforced ### Watch out - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. ## Phase 3 · An answer that cannot leave its context The model writes sentences from the passages and refuses everything else, including instructions hidden in those passages. ### Steps 1. Delimit retrieved text as quoted data And say in the system message that it is never instruction. Files: `src/prompt.ts`, `tests/injection.test.ts` 2. Resolve citations against what you sent The model's URL is a guess; your passage list is a fact. Files: `src/answer.ts`, `tests/citation.test.ts` 3. Make refusals a logged event Refusals are the list of things your site fails to say. Files: `src/db/refusals.ts` ### Done when - [ ] An injected instruction in a page changes nothing - [ ] An unresolvable citation is refused, not displayed - [ ] A figure absent from the passages is refused - [ ] Refusals are logged with a reason ### Watch out - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. ## Phase 4 · A widget that cannot become the vulnerability Installing this on a page adds one script of yours and no new attack surface. ### Steps 1. Write messages with text nodes only The content came from the open web by way of a model. Files: `public/widget.js`, `tests/widget-xss.test.ts` 2. Build links from your URL list Never from the model's output. Files: `public/widget.js`, `src/answer.ts` 3. Rate limit and cap the spend Both of which degrade to the message form, not to an error. Files: `src/ratelimit.ts`, `src/cost.ts` ### Done when - [ ] Script markup in a response renders as text - [ ] Links come only from the retrieved URL list - [ ] Rate limited sessions get a message - [ ] The ceiling degrades to the message form ### Watch out - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ## Not in this build - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. ## After v1, if you want it - A curated synonym list built from the refusal log, which is the cheapest retrieval improvement available - A human handover that opens a conversation in whatever inbox you already read - A page suggestion report: the questions refused most often, written as a list of pages to publish ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Server side only. It must never appear in the widget bundle. MODEL_API_KEY=sk-...redacted # Required. Recorded on every message, because answers differ by model. MODEL_ID=a streaming text model identifier # Required. Pages, sessions, messages, retrievals and refusals. DATABASE_PATH=./data/chat.db # Required. The one site this installation answers about. SITEMAP_URL=https://example.com/sitemap.xml # Required. Where the widget may be embedded. Not a wildcard in production. ALLOWED_ORIGINS=https://example.com # Required. Fixed and enforced. More passages is not more accuracy. PASSAGES_PER_ANSWER=5 # Required. Degrades to a message rather than an error. SESSION_RATE_LIMIT_PER_MINUTE=6 # Required. At the ceiling the widget offers to take a message instead. MONTHLY_COST_CEILING=20.00
You are building a production product version of Animam.ai. 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 ===== # Animam.ai · product brief ## Problem The chat is a weekend. Everything that makes it safe to point at customers is not. Ingest a site, search it, stream an answer · that part is commoditised and the prompt below really does it. What resists is the boring half: an amount computed by the server and never by the model, a visitor email verified before it triggers anything, signed webhooks with retries, and a sending domain whose reputation you did not build in an afternoon. ## Product outcome A widget that answers from your pages, shows which page it used, refuses anything it cannot cite, ignores instructions hidden in the content it reads, and hands you a daily list of what it could not answer. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An LLM API key (Anthropic, OpenAI, or a local model) - A place to run a small Node server (a 5 EUR VPS is enough) - SQLite, or any database you already run - A domain you can serve the widget from ## Explicit non-goals for v1 - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. - Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number - Visitor identity verified by one-time code before any server-to-server action runs - Signed outgoing webhooks with exponential backoff, delivery log and manual redelivery - SSRF guard with DNS resolution on every URL the agent is allowed to call - Per-tenant secrets encrypted at rest, and tenant isolation you did not have to think about - Email deliverability · a warmed sending domain is not something you prompt into existence - The evening the model provider changes a default and your widget starts making things up ## Success criteria - A second identical crawl is confirmed to add nothing - An injected instruction in a page is confirmed to change nothing - An unresolvable citation is confirmed to be refused - Script markup in a response is confirmed to render as text - The off switch is confirmed to survive a restart ===== BRIEF.md ===== # Build brief · Animam.ai 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 website chat widget that answers only from the site's own pages, cites the page it used, and refuses everything else to replace Animam.ai. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small HTTP framework, one process · this is an ingest script, a search function and a streaming endpoint - Storage: SQLite holding pages, sessions, messages and the retrieval log · you will want to read back what people asked and why each passage was chosen - Retrieval: Keyword matching over stored text, ranked, with the matched terms recorded · under a few hundred pages this is instant, free and inspectable - Model: One streaming call per question with the retrieved passages as quoted data · the model writes the sentence and never supplies the fact - Widget: An inline script building a shadow root, writing text only, served from your own domain · the text it renders came from pages a model read ### Data model (create this before Phase 1) - pages(url PRIMARY KEY, title, body, content_hash, fetched_at, gone_at) · the URL is the key so a re-crawl updates rather than duplicates - crawls(id, started_at, finished_at, added, changed, removed, errors_json) · each ingest run is a row you can compare against the last - sessions(id, created_at, last_seen_at, origin, blocked_at) · one per visitor, with the origin it was served to - messages(id, session_id, role, text, at, model_id, prompt_version) - retrievals(id, message_id, url, rank, matched_terms_json, excerpt_start, excerpt_len) · why that passage, for that answer - refusals(id, message_id, reason, question_text, at) · the most useful table in the application - There is no table of prices, stock levels or dates. If a number is not written on a page, this application does not have it. ### Phase 1 · Ingest a site you are allowed to ingest, and know what changed Build: Read the sitemap of a site you control or have written permission for, fetch each page with modest concurrency, skip non-HTML, strip navigation, header, footer and script elements, and convert what remains to plain text. Upsert by URL with a content hash. Mark pages absent from the sitemap as gone. Produce a report of added, changed, removed and failed pages after every run. Trap: Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. Done when: Running ingest twice over an unchanged site adds no rows and reports zero changes, tested against a fixture server. A page removed from the sitemap is marked gone and stops being retrieved, tested. Non-HTML responses and fetch failures are recorded in the crawl report and do not abort the run. Every stored page carries a fetch time and a content hash. Do not build yet: No search, answers, widget or logging of conversations yet. ### Phase 2 · Retrieval you can explain to somebody annoyed Build: Take the most distinctive words from the question, match them against stored page text, rank by how many matched and how close together, and return a small fixed number of passages. Record the passages, their ranks and the matched terms against the message. A question that matches nothing goes down the refusal path rather than to the model. Trap: Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. Done when: Every answer stores its retrieved passages with ranks and matched terms, checked in the database after a conversation. Re-running retrieval for the same question against the same corpus returns the same passages in the same order, tested. A question matching no passage produces a refusal and no model call, tested. The number of passages passed to the model is fixed and enforced rather than incidental. Do not build yet: No answering, widget, rate limiting or review tooling yet. ### Phase 3 · An answer that cannot leave its context Build: Put the passages into the prompt inside a delimiter the system message names as quoted data that is never to be followed as instruction. Require a citation by passage number, resolve that number to a URL you actually retrieved, and refuse the answer if it does not resolve. Forbid stating any price, date or availability that is not present in the passages. Record every refusal with a reason. Trap: The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. Trap: A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. Done when: A fixture page containing an instruction telling the model to ignore its rules does not change the answer or the citation, tested end to end. An answer citing a passage number that does not resolve is refused and logged rather than displayed, tested by stubbing the model. A pricing question with no figure in any passage produces a refusal and an offer to take a message, tested. Every refusal writes a row with its reason and the question. Do not build yet: No widget hardening, rate limits or review tooling yet. ### Phase 4 · A widget that cannot become the vulnerability Build: One script served from your own domain, building a shadow root and writing every message with text nodes only. Construct links yourself from the retrieved URL list rather than from anything the model wrote. Keep the session identifier in local storage. Rate limit per session and per address, and cap the monthly model spend with the take-a-message form as the fallback. Trap: Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. Done when: A stubbed model response containing script markup is displayed as visible text and executes nothing, tested in a browser. Links in an answer are constructed from the retrieved URL list and a URL invented by the model never becomes a link, tested. A session over the rate limit receives a message rather than an error, tested at the boundary. Reaching the spend ceiling disables answering and shows the take-a-message form, tested. Do not build yet: No conversation review, re-crawl schedule or deletion yet. ### Phase 5 · Leaving it in front of customers Build: A daily review listing refusals first, then answers and their citations. A scheduled re-crawl that mails you its report and flags answers whose cited pages have changed since. A global off switch stored in the database. Export of conversations, and deletion of a session's messages on request with a receipt. Trap: Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. Done when: The daily review lists refusals first with counts, then answers with their citations, checked against the database. A re-crawl that changes a page flags every prior answer citing it, tested. The off switch stops answering within one request and survives a restart, tested. Deleting a session removes its messages, retrievals and refusals and produces a receipt with counts, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Quoting an amount, which must be computed by a server from a price list and never produced by a model, and is a week of work rather than a line. - Booking on a real calendar and taking payment, both of which need verified visitor identity before anything is written anywhere. - Transactional email that reaches an inbox, which is a sending reputation you cannot build in a weekend and a maintenance job afterwards. - Multi-tenancy and per-customer secrets, which turns a weekend project into an obligation to other people's data. ### README must contain - That this ingests only a site you control or have written permission to ingest, and how the sitemap is chosen - That retrieval is keyword based on purpose, what gets logged per answer, and at what corpus size you should reconsider - That retrieved page text is treated as quoted data and never as instruction, and what is stripped at ingest - That the widget writes text nodes only and constructs links from the retrieved list rather than from the model - Where the refusal list lives, why it is the most useful screen, and how to delete a visitor's conversation ===== ARCHITECTURE.md ===== # Architecture · Animam.ai ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small HTTP framework, one process | this is an ingest script, a search function and a streaming endpoint | | Storage | SQLite holding pages, sessions, messages and the retrieval log | you will want to read back what people asked and why each passage was chosen | | Retrieval | Keyword matching over stored text, ranked, with the matched terms recorded | under a few hundred pages this is instant, free and inspectable | | Model | One streaming call per question with the retrieved passages as quoted data | the model writes the sentence and never supplies the fact | | Widget | An inline script building a shadow root, writing text only, served from your own domain | the text it renders came from pages a model read | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Crawl, extraction, upsert and the change report. | Any extractor, provided the URL stays the key and a hash is stored. | | Retrieval | Matching, ranking and the retrieval log. | Any ranking, provided the reason for a passage is recorded. | | Answer | Prompt construction, citation resolution and refusals. | Any model, provided citations resolve against what you sent. | | Widget | Shadow root, text-only rendering and link construction. | Any front end, provided nothing from the model becomes markup. | | Review | Refusal list, change flags, off switch and deletion. | Any presentation, provided refusals lead it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `MODEL_API_KEY` · required, secret · Server side only. It must never appear in the widget bundle. - `MODEL_ID` · required · Recorded on every message, because answers differ by model. - `DATABASE_PATH` · required · Pages, sessions, messages, retrievals and refusals. - `SITEMAP_URL` · required · The one site this installation answers about. - `ALLOWED_ORIGINS` · required · Where the widget may be embedded. Not a wildcard in production. - `PASSAGES_PER_ANSWER` · required · Fixed and enforced. More passages is not more accuracy. - `SESSION_RATE_LIMIT_PER_MINUTE` · required · Degrades to a message rather than an error. - `MONTHLY_COST_CEILING` · required · At the ceiling the widget offers to take a message instead. ## 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 · Animam.ai product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript and a small HTTP framework, one process, SQLite holding pages, sessions, messages and the retrieval log, Keyword matching over stored text, ranked, with the matched terms recorded, One streaming call per question with the retrieved passages as quoted data, An inline script building a shadow root, writing text only, served from your own domain. - 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 - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. - Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. ===== MILESTONES.md ===== # Delivery milestones · Animam.ai Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Ingest a site you are allowed to ingest, and know what changed A re-crawl updates the corpus and reports what moved rather than quietly doubling it. ### Steps 1. Make the URL the primary key and upsert The second crawl is where this design either holds or does not. Files: `scripts/ingest.ts`, `tests/ingest.test.ts` 2. Store a content hash and compare runs So a re-crawl is a report rather than a hope. Files: `src/crawl/report.ts` 3. Record failures instead of aborting One redirect loop should not cost you the whole corpus. Files: `scripts/ingest.ts` ### Done when - [ ] A second identical crawl adds nothing - [ ] Removed pages are marked gone and stop being retrieved - [ ] Failures are recorded and the run continues - [ ] Pages carry fetch time and content hash ### Watch out - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. ## M2 · Retrieval you can explain to somebody annoyed Every answer records which passages it used and which terms matched. ### Steps 1. Rank on matches you can print An explanation beats a score nobody can read. Files: `src/search.ts`, `tests/search.test.ts` 2. Log passages and matched terms per message This table is how you debug a bad answer. Files: `src/db/retrievals.ts` 3. Send nothing to the model when nothing matched A refusal is cheaper and more honest than a guess. Files: `src/answer.ts` ### Done when - [ ] Retrievals are recorded with ranks and terms - [ ] The same question returns the same passages - [ ] No match means no model call - [ ] The passage count is fixed and enforced ### Watch out - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. ## M3 · An answer that cannot leave its context The model writes sentences from the passages and refuses everything else, including instructions hidden in those passages. ### Steps 1. Delimit retrieved text as quoted data And say in the system message that it is never instruction. Files: `src/prompt.ts`, `tests/injection.test.ts` 2. Resolve citations against what you sent The model's URL is a guess; your passage list is a fact. Files: `src/answer.ts`, `tests/citation.test.ts` 3. Make refusals a logged event Refusals are the list of things your site fails to say. Files: `src/db/refusals.ts` ### Done when - [ ] An injected instruction in a page changes nothing - [ ] An unresolvable citation is refused, not displayed - [ ] A figure absent from the passages is refused - [ ] Refusals are logged with a reason ### Watch out - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. ## M4 · A widget that cannot become the vulnerability Installing this on a page adds one script of yours and no new attack surface. ### Steps 1. Write messages with text nodes only The content came from the open web by way of a model. Files: `public/widget.js`, `tests/widget-xss.test.ts` 2. Build links from your URL list Never from the model's output. Files: `public/widget.js`, `src/answer.ts` 3. Rate limit and cap the spend Both of which degrade to the message form, not to an error. Files: `src/ratelimit.ts`, `src/cost.ts` ### Done when - [ ] Script markup in a response renders as text - [ ] Links come only from the retrieved URL list - [ ] Rate limited sessions get a message - [ ] The ceiling degrades to the message form ### Watch out - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ## M5 · Leaving it in front of customers (production only) You can read what it said, re-crawl on a schedule, switch it off, and delete somebody's conversation. ### Steps 1. Put refusals at the top of the review That list is what the site is missing, written by customers. Files: `src/review.ts` 2. Flag answers whose cited page has changed A citation to rewritten text is a wrong answer with a footnote. Files: `src/crawl/flag.ts`, `tests/flag.test.ts` 3. Make the off switch a database row So it survives the deploy that caused the problem. Files: `src/killswitch.ts`, `tests/killswitch.test.ts` ### Done when - [ ] Refusals lead the daily review - [ ] Changed pages flag the answers that cited them - [ ] The off switch survives a restart - [ ] Session deletion covers messages, retrievals and refusals ### Watch out - Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. ===== OPERATIONS.md ===== # Operations · Animam.ai ## Backup The SQLite file. The pages can be re-crawled, but the conversations, retrievals and refusals are a record of what customers asked and what your site failed to say, and that cannot be regenerated. ## Restore Restore the file, run an ingest to bring the corpus current, then re-run the known-question suite and compare the pass count to the one you recorded. A drop means the corpus came back different, not that the model changed. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Refusals per day and their top reasons, answers citing pages changed since, crawl failures per run, spend against the ceiling, and rate limit hits. A sudden fall in refusals usually means retrieval got looser rather than better. ## Incident checklist The incident is an answer that committed you to something. The message row carries the model, the prompt version and the exact passages retrieved, so you can see whether the passage said it, the model invented it, or somebody put it on a page the crawler picked up. 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 second identical crawl is confirmed to add nothing - [ ] An injected instruction in a page is confirmed to change nothing - [ ] An unresolvable citation is confirmed to be refused - [ ] Script markup in a response is confirmed to render as text - [ ] The off switch is confirmed to survive a restart ## Launch constraint Do not market omitted Animam.ai 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 · secret. Server side only. It must never appear in the widget bundle. MODEL_API_KEY=sk-...redacted # Required. Recorded on every message, because answers differ by model. MODEL_ID=a streaming text model identifier # Required. Pages, sessions, messages, retrievals and refusals. DATABASE_PATH=./data/chat.db # Required. The one site this installation answers about. SITEMAP_URL=https://example.com/sitemap.xml # Required. Where the widget may be embedded. Not a wildcard in production. ALLOWED_ORIGINS=https://example.com # Required. Fixed and enforced. More passages is not more accuracy. PASSAGES_PER_ANSWER=5 # Required. Degrades to a message rather than an error. SESSION_RATE_LIMIT_PER_MINUTE=6 # Required. At the ceiling the widget offers to take a message instead. MONTHLY_COST_CEILING=20.00
# Animam.ai · indie build A grounded site chat whose hard problems are retrieval you can explain, an answer that cannot step outside the pages it was given even when those pages contain instructions, and a widget that cannot become the vulnerability on somebody else's site. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small HTTP framework, one process | this is an ingest script, a search function and a streaming endpoint | | Storage | SQLite holding pages, sessions, messages and the retrieval log | you will want to read back what people asked and why each passage was chosen | | Retrieval | Keyword matching over stored text, ranked, with the matched terms recorded | under a few hundred pages this is instant, free and inspectable | | Model | One streaming call per question with the retrieved passages as quoted data | the model writes the sentence and never supplies the fact | | Widget | An inline script building a shadow root, writing text only, served from your own domain | the text it renders came from pages a model read | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the ingest script and the streaming server. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A site you control, with a sitemap** · free - Why: This build ingests one site and the permission question has to be settled before the first crawl rather than after it. - Get it: Your own site, or one you have written permission to index, with a reachable sitemap. - Verify: The sitemap lists the pages you expect and the crawl report matches - [ ] **A small always-on server with a certificate** · a few units of currency a month - Why: The widget must be served from a domain you control and the streaming endpoint has to be up whenever the site is. - Get it: A small virtual server, or any host that runs a long-lived Node process. - Verify: The widget loads over HTTPS from your own domain and streams an answer - [ ] **An API key for a streaming text model** · pay per question; the ceiling in this build is what keeps it bounded - Why: One call per question, and the only recurring cost besides the server. - Get it: Create a key with your preferred provider and set a spend limit in their console. - Verify: A question over your own corpus streams an answer with a resolvable citation - [ ] **Twenty real questions with known answers** · free - Why: Retrieval quality is only measurable against questions whose answers you already know, and choosing them afterwards means choosing the ones it got right. - Get it: Write them down before you build the search, from support mail or from the site's own pages. - Verify: The suite runs them and reports how many retrieved the right page ## 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: - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. - Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number - Visitor identity verified by one-time code before any server-to-server action runs - Signed outgoing webhooks with exponential backoff, delivery log and manual redelivery - SSRF guard with DNS resolution on every URL the agent is allowed to call - Per-tenant secrets encrypted at rest, and tenant isolation you did not have to think about - Email deliverability · a warmed sending domain is not something you prompt into existence - The evening the model provider changes a default and your widget starts making things up If one of those is essential to you, that is the reason to keep paying for Animam.ai, and the README should say so rather than pretend.
# Build brief · Animam.ai 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 website chat widget that answers only from the site's own pages, cites the page it used, and refuses everything else to replace Animam.ai. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small HTTP framework, one process · this is an ingest script, a search function and a streaming endpoint - Storage: SQLite holding pages, sessions, messages and the retrieval log · you will want to read back what people asked and why each passage was chosen - Retrieval: Keyword matching over stored text, ranked, with the matched terms recorded · under a few hundred pages this is instant, free and inspectable - Model: One streaming call per question with the retrieved passages as quoted data · the model writes the sentence and never supplies the fact - Widget: An inline script building a shadow root, writing text only, served from your own domain · the text it renders came from pages a model read ### Data model (create this before Phase 1) - pages(url PRIMARY KEY, title, body, content_hash, fetched_at, gone_at) · the URL is the key so a re-crawl updates rather than duplicates - crawls(id, started_at, finished_at, added, changed, removed, errors_json) · each ingest run is a row you can compare against the last - sessions(id, created_at, last_seen_at, origin, blocked_at) · one per visitor, with the origin it was served to - messages(id, session_id, role, text, at, model_id, prompt_version) - retrievals(id, message_id, url, rank, matched_terms_json, excerpt_start, excerpt_len) · why that passage, for that answer - refusals(id, message_id, reason, question_text, at) · the most useful table in the application - There is no table of prices, stock levels or dates. If a number is not written on a page, this application does not have it. ### Phase 1 · Ingest a site you are allowed to ingest, and know what changed Build: Read the sitemap of a site you control or have written permission for, fetch each page with modest concurrency, skip non-HTML, strip navigation, header, footer and script elements, and convert what remains to plain text. Upsert by URL with a content hash. Mark pages absent from the sitemap as gone. Produce a report of added, changed, removed and failed pages after every run. Trap: Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. Done when: Running ingest twice over an unchanged site adds no rows and reports zero changes, tested against a fixture server. A page removed from the sitemap is marked gone and stops being retrieved, tested. Non-HTML responses and fetch failures are recorded in the crawl report and do not abort the run. Every stored page carries a fetch time and a content hash. Do not build yet: No search, answers, widget or logging of conversations yet. ### Phase 2 · Retrieval you can explain to somebody annoyed Build: Take the most distinctive words from the question, match them against stored page text, rank by how many matched and how close together, and return a small fixed number of passages. Record the passages, their ranks and the matched terms against the message. A question that matches nothing goes down the refusal path rather than to the model. Trap: Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. Done when: Every answer stores its retrieved passages with ranks and matched terms, checked in the database after a conversation. Re-running retrieval for the same question against the same corpus returns the same passages in the same order, tested. A question matching no passage produces a refusal and no model call, tested. The number of passages passed to the model is fixed and enforced rather than incidental. Do not build yet: No answering, widget, rate limiting or review tooling yet. ### Phase 3 · An answer that cannot leave its context Build: Put the passages into the prompt inside a delimiter the system message names as quoted data that is never to be followed as instruction. Require a citation by passage number, resolve that number to a URL you actually retrieved, and refuse the answer if it does not resolve. Forbid stating any price, date or availability that is not present in the passages. Record every refusal with a reason. Trap: The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. Trap: A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. Done when: A fixture page containing an instruction telling the model to ignore its rules does not change the answer or the citation, tested end to end. An answer citing a passage number that does not resolve is refused and logged rather than displayed, tested by stubbing the model. A pricing question with no figure in any passage produces a refusal and an offer to take a message, tested. Every refusal writes a row with its reason and the question. Do not build yet: No widget hardening, rate limits or review tooling yet. ### Phase 4 · A widget that cannot become the vulnerability Build: One script served from your own domain, building a shadow root and writing every message with text nodes only. Construct links yourself from the retrieved URL list rather than from anything the model wrote. Keep the session identifier in local storage. Rate limit per session and per address, and cap the monthly model spend with the take-a-message form as the fallback. Trap: Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. Done when: A stubbed model response containing script markup is displayed as visible text and executes nothing, tested in a browser. Links in an answer are constructed from the retrieved URL list and a URL invented by the model never becomes a link, tested. A session over the rate limit receives a message rather than an error, tested at the boundary. Reaching the spend ceiling disables answering and shows the take-a-message form, tested. Do not build yet: No conversation review, re-crawl schedule or deletion yet. ### Phase 5 · Leaving it in front of customers Build: A daily review listing refusals first, then answers and their citations. A scheduled re-crawl that mails you its report and flags answers whose cited pages have changed since. A global off switch stored in the database. Export of conversations, and deletion of a session's messages on request with a receipt. Trap: Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. Done when: The daily review lists refusals first with counts, then answers with their citations, checked against the database. A re-crawl that changes a page flags every prior answer citing it, tested. The off switch stops answering within one request and survives a restart, tested. Deleting a session removes its messages, retrievals and refusals and produces a receipt with counts, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Quoting an amount, which must be computed by a server from a price list and never produced by a model, and is a week of work rather than a line. - Booking on a real calendar and taking payment, both of which need verified visitor identity before anything is written anywhere. - Transactional email that reaches an inbox, which is a sending reputation you cannot build in a weekend and a maintenance job afterwards. - Multi-tenancy and per-customer secrets, which turns a weekend project into an obligation to other people's data. ### README must contain - That this ingests only a site you control or have written permission to ingest, and how the sitemap is chosen - That retrieval is keyword based on purpose, what gets logged per answer, and at what corpus size you should reconsider - That retrieved page text is treated as quoted data and never as instruction, and what is stripped at ingest - That the widget writes text nodes only and constructs links from the retrieved list rather than from the model - Where the refusal list lives, why it is the most useful screen, and how to delete a visitor's conversation
# Agent instructions · Animam.ai indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript and a small HTTP framework, one process, SQLite holding pages, sessions, messages and the retrieval log, Keyword matching over stored text, ranked, with the matched terms recorded, One streaming call per question with the retrieved passages as quoted data, An inline script building a shadow root, writing text only, served from your own domain. 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 - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own.
# Build plan · Animam.ai A grounded site chat whose hard problems are retrieval you can explain, an answer that cannot step outside the pages it was given even when those pages contain instructions, and a widget that cannot become the vulnerability on somebody else's site. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Ingest a site you are allowed to ingest, and know what changed A re-crawl updates the corpus and reports what moved rather than quietly doubling it. ### Steps 1. Make the URL the primary key and upsert The second crawl is where this design either holds or does not. Files: `scripts/ingest.ts`, `tests/ingest.test.ts` 2. Store a content hash and compare runs So a re-crawl is a report rather than a hope. Files: `src/crawl/report.ts` 3. Record failures instead of aborting One redirect loop should not cost you the whole corpus. Files: `scripts/ingest.ts` ### Done when - [ ] A second identical crawl adds nothing - [ ] Removed pages are marked gone and stop being retrieved - [ ] Failures are recorded and the run continues - [ ] Pages carry fetch time and content hash ### Watch out - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. ## Phase 2 · Retrieval you can explain to somebody annoyed Every answer records which passages it used and which terms matched. ### Steps 1. Rank on matches you can print An explanation beats a score nobody can read. Files: `src/search.ts`, `tests/search.test.ts` 2. Log passages and matched terms per message This table is how you debug a bad answer. Files: `src/db/retrievals.ts` 3. Send nothing to the model when nothing matched A refusal is cheaper and more honest than a guess. Files: `src/answer.ts` ### Done when - [ ] Retrievals are recorded with ranks and terms - [ ] The same question returns the same passages - [ ] No match means no model call - [ ] The passage count is fixed and enforced ### Watch out - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. ## Phase 3 · An answer that cannot leave its context The model writes sentences from the passages and refuses everything else, including instructions hidden in those passages. ### Steps 1. Delimit retrieved text as quoted data And say in the system message that it is never instruction. Files: `src/prompt.ts`, `tests/injection.test.ts` 2. Resolve citations against what you sent The model's URL is a guess; your passage list is a fact. Files: `src/answer.ts`, `tests/citation.test.ts` 3. Make refusals a logged event Refusals are the list of things your site fails to say. Files: `src/db/refusals.ts` ### Done when - [ ] An injected instruction in a page changes nothing - [ ] An unresolvable citation is refused, not displayed - [ ] A figure absent from the passages is refused - [ ] Refusals are logged with a reason ### Watch out - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. ## Phase 4 · A widget that cannot become the vulnerability Installing this on a page adds one script of yours and no new attack surface. ### Steps 1. Write messages with text nodes only The content came from the open web by way of a model. Files: `public/widget.js`, `tests/widget-xss.test.ts` 2. Build links from your URL list Never from the model's output. Files: `public/widget.js`, `src/answer.ts` 3. Rate limit and cap the spend Both of which degrade to the message form, not to an error. Files: `src/ratelimit.ts`, `src/cost.ts` ### Done when - [ ] Script markup in a response renders as text - [ ] Links come only from the retrieved URL list - [ ] Rate limited sessions get a message - [ ] The ceiling degrades to the message form ### Watch out - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ## Not in this build - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. ## After v1, if you want it - A curated synonym list built from the refusal log, which is the cheapest retrieval improvement available - A human handover that opens a conversation in whatever inbox you already read - A page suggestion report: the questions refused most often, written as a list of pages to publish
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Server side only. It must never appear in the widget bundle. MODEL_API_KEY=sk-...redacted # Required. Recorded on every message, because answers differ by model. MODEL_ID=a streaming text model identifier # Required. Pages, sessions, messages, retrievals and refusals. DATABASE_PATH=./data/chat.db # Required. The one site this installation answers about. SITEMAP_URL=https://example.com/sitemap.xml # Required. Where the widget may be embedded. Not a wildcard in production. ALLOWED_ORIGINS=https://example.com # Required. Fixed and enforced. More passages is not more accuracy. PASSAGES_PER_ANSWER=5 # Required. Degrades to a message rather than an error. SESSION_RATE_LIMIT_PER_MINUTE=6 # Required. At the ceiling the widget offers to take a message instead. MONTHLY_COST_CEILING=20.00
# Animam.ai · product brief ## Problem The chat is a weekend. Everything that makes it safe to point at customers is not. Ingest a site, search it, stream an answer · that part is commoditised and the prompt below really does it. What resists is the boring half: an amount computed by the server and never by the model, a visitor email verified before it triggers anything, signed webhooks with retries, and a sending domain whose reputation you did not build in an afternoon. ## Product outcome A widget that answers from your pages, shows which page it used, refuses anything it cannot cite, ignores instructions hidden in the content it reads, and hands you a daily list of what it could not answer. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - An LLM API key (Anthropic, OpenAI, or a local model) - A place to run a small Node server (a 5 EUR VPS is enough) - SQLite, or any database you already run - A domain you can serve the widget from ## Explicit non-goals for v1 - Quoting an amount computed by the model. - Calendar booking and payments. - Transactional email with deliverability. - Multi-tenancy and per-customer secrets. - Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number - Visitor identity verified by one-time code before any server-to-server action runs - Signed outgoing webhooks with exponential backoff, delivery log and manual redelivery - SSRF guard with DNS resolution on every URL the agent is allowed to call - Per-tenant secrets encrypted at rest, and tenant isolation you did not have to think about - Email deliverability · a warmed sending domain is not something you prompt into existence - The evening the model provider changes a default and your widget starts making things up ## Success criteria - A second identical crawl is confirmed to add nothing - An injected instruction in a page is confirmed to change nothing - An unresolvable citation is confirmed to be refused - Script markup in a response is confirmed to render as text - The off switch is confirmed to survive a restart
# Build brief · Animam.ai 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 website chat widget that answers only from the site's own pages, cites the page it used, and refuses everything else to replace Animam.ai. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript and a small HTTP framework, one process · this is an ingest script, a search function and a streaming endpoint - Storage: SQLite holding pages, sessions, messages and the retrieval log · you will want to read back what people asked and why each passage was chosen - Retrieval: Keyword matching over stored text, ranked, with the matched terms recorded · under a few hundred pages this is instant, free and inspectable - Model: One streaming call per question with the retrieved passages as quoted data · the model writes the sentence and never supplies the fact - Widget: An inline script building a shadow root, writing text only, served from your own domain · the text it renders came from pages a model read ### Data model (create this before Phase 1) - pages(url PRIMARY KEY, title, body, content_hash, fetched_at, gone_at) · the URL is the key so a re-crawl updates rather than duplicates - crawls(id, started_at, finished_at, added, changed, removed, errors_json) · each ingest run is a row you can compare against the last - sessions(id, created_at, last_seen_at, origin, blocked_at) · one per visitor, with the origin it was served to - messages(id, session_id, role, text, at, model_id, prompt_version) - retrievals(id, message_id, url, rank, matched_terms_json, excerpt_start, excerpt_len) · why that passage, for that answer - refusals(id, message_id, reason, question_text, at) · the most useful table in the application - There is no table of prices, stock levels or dates. If a number is not written on a page, this application does not have it. ### Phase 1 · Ingest a site you are allowed to ingest, and know what changed Build: Read the sitemap of a site you control or have written permission for, fetch each page with modest concurrency, skip non-HTML, strip navigation, header, footer and script elements, and convert what remains to plain text. Upsert by URL with a content hash. Mark pages absent from the sitemap as gone. Produce a report of added, changed, removed and failed pages after every run. Trap: Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. Done when: Running ingest twice over an unchanged site adds no rows and reports zero changes, tested against a fixture server. A page removed from the sitemap is marked gone and stops being retrieved, tested. Non-HTML responses and fetch failures are recorded in the crawl report and do not abort the run. Every stored page carries a fetch time and a content hash. Do not build yet: No search, answers, widget or logging of conversations yet. ### Phase 2 · Retrieval you can explain to somebody annoyed Build: Take the most distinctive words from the question, match them against stored page text, rank by how many matched and how close together, and return a small fixed number of passages. Record the passages, their ranks and the matched terms against the message. A question that matches nothing goes down the refusal path rather than to the model. Trap: Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. Done when: Every answer stores its retrieved passages with ranks and matched terms, checked in the database after a conversation. Re-running retrieval for the same question against the same corpus returns the same passages in the same order, tested. A question matching no passage produces a refusal and no model call, tested. The number of passages passed to the model is fixed and enforced rather than incidental. Do not build yet: No answering, widget, rate limiting or review tooling yet. ### Phase 3 · An answer that cannot leave its context Build: Put the passages into the prompt inside a delimiter the system message names as quoted data that is never to be followed as instruction. Require a citation by passage number, resolve that number to a URL you actually retrieved, and refuse the answer if it does not resolve. Forbid stating any price, date or availability that is not present in the passages. Record every refusal with a reason. Trap: The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. Trap: A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. Done when: A fixture page containing an instruction telling the model to ignore its rules does not change the answer or the citation, tested end to end. An answer citing a passage number that does not resolve is refused and logged rather than displayed, tested by stubbing the model. A pricing question with no figure in any passage produces a refusal and an offer to take a message, tested. Every refusal writes a row with its reason and the question. Do not build yet: No widget hardening, rate limits or review tooling yet. ### Phase 4 · A widget that cannot become the vulnerability Build: One script served from your own domain, building a shadow root and writing every message with text nodes only. Construct links yourself from the retrieved URL list rather than from anything the model wrote. Keep the session identifier in local storage. Rate limit per session and per address, and cap the monthly model spend with the take-a-message form as the fallback. Trap: Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. Done when: A stubbed model response containing script markup is displayed as visible text and executes nothing, tested in a browser. Links in an answer are constructed from the retrieved URL list and a URL invented by the model never becomes a link, tested. A session over the rate limit receives a message rather than an error, tested at the boundary. Reaching the spend ceiling disables answering and shows the take-a-message form, tested. Do not build yet: No conversation review, re-crawl schedule or deletion yet. ### Phase 5 · Leaving it in front of customers Build: A daily review listing refusals first, then answers and their citations. A scheduled re-crawl that mails you its report and flags answers whose cited pages have changed since. A global off switch stored in the database. Export of conversations, and deletion of a session's messages on request with a receipt. Trap: Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation. Done when: The daily review lists refusals first with counts, then answers with their citations, checked against the database. A re-crawl that changes a page flags every prior answer citing it, tested. The off switch stops answering within one request and survives a restart, tested. Deleting a session removes its messages, retrievals and refusals and produces a receipt with counts, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Quoting an amount, which must be computed by a server from a price list and never produced by a model, and is a week of work rather than a line. - Booking on a real calendar and taking payment, both of which need verified visitor identity before anything is written anywhere. - Transactional email that reaches an inbox, which is a sending reputation you cannot build in a weekend and a maintenance job afterwards. - Multi-tenancy and per-customer secrets, which turns a weekend project into an obligation to other people's data. ### README must contain - That this ingests only a site you control or have written permission to ingest, and how the sitemap is chosen - That retrieval is keyword based on purpose, what gets logged per answer, and at what corpus size you should reconsider - That retrieved page text is treated as quoted data and never as instruction, and what is stripped at ingest - That the widget writes text nodes only and constructs links from the retrieved list rather than from the model - Where the refusal list lives, why it is the most useful screen, and how to delete a visitor's conversation
# Architecture · Animam.ai ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript and a small HTTP framework, one process | this is an ingest script, a search function and a streaming endpoint | | Storage | SQLite holding pages, sessions, messages and the retrieval log | you will want to read back what people asked and why each passage was chosen | | Retrieval | Keyword matching over stored text, ranked, with the matched terms recorded | under a few hundred pages this is instant, free and inspectable | | Model | One streaming call per question with the retrieved passages as quoted data | the model writes the sentence and never supplies the fact | | Widget | An inline script building a shadow root, writing text only, served from your own domain | the text it renders came from pages a model read | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Crawl, extraction, upsert and the change report. | Any extractor, provided the URL stays the key and a hash is stored. | | Retrieval | Matching, ranking and the retrieval log. | Any ranking, provided the reason for a passage is recorded. | | Answer | Prompt construction, citation resolution and refusals. | Any model, provided citations resolve against what you sent. | | Widget | Shadow root, text-only rendering and link construction. | Any front end, provided nothing from the model becomes markup. | | Review | Refusal list, change flags, off switch and deletion. | Any presentation, provided refusals lead it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `MODEL_API_KEY` · required, secret · Server side only. It must never appear in the widget bundle. - `MODEL_ID` · required · Recorded on every message, because answers differ by model. - `DATABASE_PATH` · required · Pages, sessions, messages, retrievals and refusals. - `SITEMAP_URL` · required · The one site this installation answers about. - `ALLOWED_ORIGINS` · required · Where the widget may be embedded. Not a wildcard in production. - `PASSAGES_PER_ANSWER` · required · Fixed and enforced. More passages is not more accuracy. - `SESSION_RATE_LIMIT_PER_MINUTE` · required · Degrades to a message rather than an error. - `MONTHLY_COST_CEILING` · required · At the ceiling the widget offers to take a message instead. ## 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 · Animam.ai product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript and a small HTTP framework, one process, SQLite holding pages, sessions, messages and the retrieval log, Keyword matching over stored text, ranked, with the matched terms recorded, One streaming call per question with the retrieved passages as quoted data, An inline script building a shadow root, writing text only, served from your own domain. - 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 - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. - Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation.
# Delivery milestones · Animam.ai Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Ingest a site you are allowed to ingest, and know what changed A re-crawl updates the corpus and reports what moved rather than quietly doubling it. ### Steps 1. Make the URL the primary key and upsert The second crawl is where this design either holds or does not. Files: `scripts/ingest.ts`, `tests/ingest.test.ts` 2. Store a content hash and compare runs So a re-crawl is a report rather than a hope. Files: `src/crawl/report.ts` 3. Record failures instead of aborting One redirect loop should not cost you the whole corpus. Files: `scripts/ingest.ts` ### Done when - [ ] A second identical crawl adds nothing - [ ] Removed pages are marked gone and stop being retrieved - [ ] Failures are recorded and the run continues - [ ] Pages carry fetch time and content hash ### Watch out - Inserting rather than upserting doubles the corpus on the second run, and then the five best passages for a question are five copies of the same paragraph and the answer looks confidently repetitive; without a content hash you also cannot tell whether last night's crawl changed anything at all. Make the URL the primary key, store a hash of the extracted text, and make the crawl report the thing you look at rather than the log. ## M2 · Retrieval you can explain to somebody annoyed Every answer records which passages it used and which terms matched. ### Steps 1. Rank on matches you can print An explanation beats a score nobody can read. Files: `src/search.ts`, `tests/search.test.ts` 2. Log passages and matched terms per message This table is how you debug a bad answer. Files: `src/db/retrievals.ts` 3. Send nothing to the model when nothing matched A refusal is cheaper and more honest than a guess. Files: `src/answer.ts` ### Done when - [ ] Retrievals are recorded with ranks and terms - [ ] The same question returns the same passages - [ ] No match means no model call - [ ] The passage count is fixed and enforced ### Watch out - Reaching for embeddings first buys a vector store, an embedding bill and a ranking nobody can explain, on a corpus of a few hundred pages where a keyword match is instant and readable; and the failure you actually hit is a question whose words do not appear on the page that answers it, which a curated synonym list fixes and a model does not. Start with matching you can print, log the matched terms with every answer, and add embeddings only once you have a written list of questions this demonstrably fails. ## M3 · An answer that cannot leave its context The model writes sentences from the passages and refuses everything else, including instructions hidden in those passages. ### Steps 1. Delimit retrieved text as quoted data And say in the system message that it is never instruction. Files: `src/prompt.ts`, `tests/injection.test.ts` 2. Resolve citations against what you sent The model's URL is a guess; your passage list is a fact. Files: `src/answer.ts`, `tests/citation.test.ts` 3. Make refusals a logged event Refusals are the list of things your site fails to say. Files: `src/db/refusals.ts` ### Done when - [ ] An injected instruction in a page changes nothing - [ ] An unresolvable citation is refused, not displayed - [ ] A figure absent from the passages is refused - [ ] Refusals are logged with a reason ### Watch out - The pages you ingest become part of the prompt, and Greshake and colleagues showed in their indirect prompt injection work that text placed where a model will retrieve it can act on that model as instruction rather than as data, which stops being theoretical the moment your site has a comments section, a profile page or a document somebody else can edit. Delimit retrieved text as quoted data, strip instruction-shaped content at ingest with a rule you can read, and validate the citation against the passages you actually sent rather than trusting the model's URL. - A grounded chat that answers a pricing question by combining two passages will invent a total, and the customer will hold you to it. Refuse any figure not written verbatim in a passage, make that refusal a logged event rather than a silent softening, and offer to take a message instead. ## M4 · A widget that cannot become the vulnerability Installing this on a page adds one script of yours and no new attack surface. ### Steps 1. Write messages with text nodes only The content came from the open web by way of a model. Files: `public/widget.js`, `tests/widget-xss.test.ts` 2. Build links from your URL list Never from the model's output. Files: `public/widget.js`, `src/answer.ts` 3. Rate limit and cap the spend Both of which degrade to the message form, not to an error. Files: `src/ratelimit.ts`, `src/cost.ts` ### Done when - [ ] Script markup in a response renders as text - [ ] Links come only from the retrieved URL list - [ ] Rate limited sessions get a message - [ ] The ceiling degrades to the message form ### Watch out - Rendering the answer with an HTML assignment so a link comes out clickable turns any string that reached the model into markup executing on your customer's page, and that string originated in pages the model read rather than in anything you wrote. Write with text nodes, build links from your own retrieved URL list, and let the widget load no script other than its own. ## M5 · Leaving it in front of customers (production only) You can read what it said, re-crawl on a schedule, switch it off, and delete somebody's conversation. ### Steps 1. Put refusals at the top of the review That list is what the site is missing, written by customers. Files: `src/review.ts` 2. Flag answers whose cited page has changed A citation to rewritten text is a wrong answer with a footnote. Files: `src/crawl/flag.ts`, `tests/flag.test.ts` 3. Make the off switch a database row So it survives the deploy that caused the problem. Files: `src/killswitch.ts`, `tests/killswitch.test.ts` ### Done when - [ ] Refusals lead the daily review - [ ] Changed pages flag the answers that cited them - [ ] The off switch survives a restart - [ ] Session deletion covers messages, retrievals and refusals ### Watch out - Everybody watches the answered-question count and nobody watches the refusals, but the refusal list is the actual product of this thing: it is the list of questions your site does not answer, written by your customers, sorted by frequency. Put refusals at the top of the daily review, and flag answers whose cited page has changed since, because a correct answer citing a page that has been rewritten is now a wrong answer with a citation.
# Operations · Animam.ai ## Backup The SQLite file. The pages can be re-crawled, but the conversations, retrievals and refusals are a record of what customers asked and what your site failed to say, and that cannot be regenerated. ## Restore Restore the file, run an ingest to bring the corpus current, then re-run the known-question suite and compare the pass count to the one you recorded. A drop means the corpus came back different, not that the model changed. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Refusals per day and their top reasons, answers citing pages changed since, crawl failures per run, spend against the ceiling, and rate limit hits. A sudden fall in refusals usually means retrieval got looser rather than better. ## Incident checklist The incident is an answer that committed you to something. The message row carries the model, the prompt version and the exact passages retrieved, so you can see whether the passage said it, the model invented it, or somebody put it on a page the crawler picked up. 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 second identical crawl is confirmed to add nothing - [ ] An injected instruction in a page is confirmed to change nothing - [ ] An unresolvable citation is confirmed to be refused - [ ] Script markup in a response is confirmed to render as text - [ ] The off switch is confirmed to survive a restart ## Launch constraint Do not market omitted Animam.ai 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 · secret. Server side only. It must never appear in the widget bundle. MODEL_API_KEY=sk-...redacted # Required. Recorded on every message, because answers differ by model. MODEL_ID=a streaming text model identifier # Required. Pages, sessions, messages, retrievals and refusals. DATABASE_PATH=./data/chat.db # Required. The one site this installation answers about. SITEMAP_URL=https://example.com/sitemap.xml # Required. Where the widget may be embedded. Not a wildcard in production. ALLOWED_ORIGINS=https://example.com # Required. Fixed and enforced. More passages is not more accuracy. PASSAGES_PER_ANSWER=5 # Required. Degrades to a message rather than an error. SESSION_RATE_LIMIT_PER_MINUTE=6 # Required. At the ceiling the widget offers to take a message instead. MONTHLY_COST_CEILING=20.00
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the demo is the easy half. A chat that answers is a weekend; a chat you can leave in front of customers for a year is an operations job · someone watches deliverability, re-crawls the site, keeps the model from inventing a price, and is there the day it breaks. People pay for the second half, and they are right to.
xAmounts computed server-side from a price grid · the model picks the SKU, it never produces the number
xVisitor identity verified by one-time code before any server-to-server action runs
xSigned outgoing webhooks with exponential backoff, delivery log and manual redelivery
xSSRF guard with DNS resolution on every URL the agent is allowed to call
xPer-tenant secrets encrypted at rest, and tenant isolation you did not have to think about
xEmail deliverability · a warmed sending domain is not something you prompt into existence
xThe evening the model provider changes a default and your widget starts making things up
Animam.ai pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $33.52/workspace | — | 600 conversations/month; 3 segments; 20 corpus entries; 1 bot |
| builder | $56.64/workspace | — | 1,200 conversations/month; 10 segments; 100 corpus entries; 2 bots |
| pro | $91.32/workspace | — | 600 conversations/month; 10 segments; 100 corpus entries; 1 bot |
| agency | $230.04/workspace | — | 6,000 conversations/month; unlimited segments and corpus entries; 10 bots |
| enterprise | custom | — | Custom multi-bot fleet, white-label, SSO and SLA terms |
free tierno free tier; an unauthenticated live-site demo is available, but Starter is the first subscription at 600 conversations/month
billingmonthly only; no annual plan; card billing via Polar with prorated plan changes
hidden costsNew conversations are blocked at the monthly cap; each bot also has a 20-conversation/day anti-abuse allocation, extra bots cost €15/month, and à-la-carte add-ons cost €9/month for Meetings or Quotes and €19/month for Payments or Digest; BYOK triples the conversation allocation but adds the model provider's API bill
verified 2026-08-14 · source ↗
Vibecode Animam.ai
Kinda. The core of Animam.ai is buildable in a weekend with the prompt on this page, but there are real gaps: Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number, Visitor identity verified by one-time code before any server-to-server action runs. Read the honest list above before committing.
How much does Animam.ai cost?
Animam.ai costs about $33.52/month (Starter, checked 2026-08-14), which is $402.24 per year.
What do I lose by replacing Animam.ai?
Honestly: Amounts computed server-side from a price grid · the model picks the SKU, it never produces the number; Visitor identity verified by one-time code before any server-to-server action runs; Signed outgoing webhooks with exponential backoff, delivery log and manual redelivery; SSRF guard with DNS resolution on every URL the agent is allowed to call; Per-tenant secrets encrypted at rest, and tenant isolation you did not have to think about; Email deliverability · a warmed sending domain is not something you prompt into existence; The evening the model provider changes a default and your widget starts making things up. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Animam.ai?
Yes: Chatwoot (Self-hosted support inbox with a website widget), Typebot (Open-source conversational forms and chat flows), Onyx (ex-Danswer) (Open-source RAG chat over your own documents). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.