Build Animam.ai
KINDAreplaces $33.52/mosaves $402.24/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the ingest script and the streaming server.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - have readyfree
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 - have readya 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 - API keypay 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 - have readyfree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
MODEL_API_KEYsecret | required | sk-...redacted | Server side only. It must never appear in the widget bundle. |
MODEL_ID | required | a streaming text model identifier | Recorded on every message, because answers differ by model. |
DATABASE_PATH | required | ./data/chat.db | Pages, sessions, messages, retrievals and refusals. |
SITEMAP_URL | required | https://example.com/sitemap.xml | The one site this installation answers about. |
ALLOWED_ORIGINS | required | https://example.com | Where the widget may be embedded. Not a wildcard in production. |
PASSAGES_PER_ANSWER | required | 5 | Fixed and enforced. More passages is not more accuracy. |
SESSION_RATE_LIMIT_PER_MINUTE | required | 6 | Degrades to a message rather than an error. |
MONTHLY_COST_CEILING | required | 20.00 | At the ceiling the widget offers to take a message instead. |
The build, in order
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.
The second crawl is where this design either holds or does not.
Files
scripts/ingest.tstests/ingest.test.tsSo a re-crawl is a report rather than a hope.
Files
src/crawl/report.tsOne redirect loop should not cost you the whole corpus.
Files
scripts/ingest.ts
done when · tick each as it passeswatch 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.
Retrieval you can explain to somebody annoyed
Every answer records which passages it used and which terms matched.
An explanation beats a score nobody can read.
Files
src/search.tstests/search.test.tsThis table is how you debug a bad answer.
Files
src/db/retrievals.tsA refusal is cheaper and more honest than a guess.
Files
src/answer.ts
done when · tick each as it passeswatch 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.
An answer that cannot leave its context
The model writes sentences from the passages and refuses everything else, including instructions hidden in those passages.
And say in the system message that it is never instruction.
Files
src/prompt.tstests/injection.test.tsThe model's URL is a guess; your passage list is a fact.
Files
src/answer.tstests/citation.test.tsRefusals are the list of things your site fails to say.
Files
src/db/refusals.ts
done when · tick each as it passeswatch 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.
A widget that cannot become the vulnerability
Installing this on a page adds one script of yours and no new attack surface.
The content came from the open web by way of a model.
Files
public/widget.jstests/widget-xss.test.tsNever from the model's output.
Files
public/widget.jssrc/answer.tsBoth of which degrade to the message form, not to an error.
Files
src/ratelimit.tssrc/cost.ts
done when · tick each as it passeswatch 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.
Leaving it in front of customersproduct builder
You can read what it said, re-crawl on a schedule, switch it off, and delete somebody's conversation.
That list is what the site is missing, written by customers.
Files
src/review.tsA citation to rewritten text is a wrong answer with a footnote.
Files
src/crawl/flag.tstests/flag.test.tsSo it survives the deploy that caused the problem.
Files
src/killswitch.tstests/killswitch.test.ts
done when · tick each as it passeswatch 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.
That is the whole plan for Animam.ai. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more no-code apps.