A local-first Q&A chatbot. The admin edits Q&A pairs in a MonkeyTab table; the chat answers user questions by matching against those pairs. Pure Deno + Hono + React, no external services required.
Most chat interfaces assume you have an LLM API key, a vector database, and a RAG pipeline. mtchat is the opposite: a small local-first app where you curate question/answer pairs in an editable table, and end-users ask questions in a chat that returns the closest match deterministically. No vendor key, no embedding API, no drift.
Good fit for:
- Static FAQ chat for a product or landing page
- Internal helpdesks where predictable answers matter
- Support docs where auditability beats open-ended generation
- Any context where you want to curate the answers yourself and know exactly what users will see
| Layer | Choice | Why |
|---|---|---|
| Runtime | Deno | Ships with TypeScript, fmt, lint, test |
| Server | Hono | Tiny, fast, composable, JSX-aware |
| Server JSX | hono/jsx with precompile |
Renders the page shell server-side |
| Browser UI | React 19 | Mounted client-side for /admin and /chat |
| Admin table | @datasketch/monkeytab via npm: |
Drop-in editable React table — cell editing, sort/filter, virtualization out of the box |
| Chat UI | @assistant-ui/react via npm: |
Headless React primitives, streaming-ready, MIT, the consensus answer for React chat in 2026 |
| Bundler | deno bundle (built-in, Deno ≥2.4) |
First-party, ~0 plumbing, bundles client/*.tsx → static/*.js with code splitting and source maps |
| Store | In-memory Map behind a QAStore interface |
Easy to swap for JSON file → SQLite later |
| Retrieval | Token-overlap scoring with threshold | Deterministic, testable, no embeddings API needed |
| Generation | Stub in v1, slot-in interface for LLM later | Useful as a static FAQ even with no LLM key |
mtchat/
├── deno.json # imports + tasks (dev, build:client, start, test)
├── README.md
├── ROADMAP.md # this file
├── main.tsx # Hono entry — API routes + page shells + static
├── routes/
│ ├── api/
│ │ ├── qa.ts # GET / POST / PATCH / DELETE /api/qa[/:id]
│ │ └── chat.ts # POST /api/chat — { query, history } → { answer, ... }
│ ├── admin.tsx # GET /admin — server-rendered shell
│ └── chat.tsx # GET /chat — server-rendered shell, also /
├── lib/
│ ├── store.ts # QAStore interface + InMemoryQAStore impl
│ ├── retrieval.ts # match() — token-overlap scoring + threshold
│ ├── llm.ts # LlmProvider interface + StubProvider
│ └── seed.ts # demo Q&A entries
├── views/ # Hono JSX server shells (no client code)
│ ├── Layout.tsx # <html><head><body> with nav + theme
│ ├── AdminShell.tsx # mounts <div id="admin-root"> + script tag
│ └── ChatShell.tsx # mounts <div id="chat-root"> + script tag
├── client/ # React, bundled for browser by esbuild
│ ├── admin.tsx # imports @datasketch/monkeytab, mounts MonkeyTable
│ └── chat.tsx # imports @assistant-ui/react, mounts Thread
├── static/ # build outputs (gitignored)
│ ├── admin.js # built from client/admin.tsx
│ ├── chat.js # built from client/chat.tsx
│ └── style.css # shared theme
├── scripts/
│ └── build-client.ts # esbuild build script
├── tests/
│ ├── retrieval.test.ts # token scoring, threshold edge cases
│ └── store.test.ts # CRUD invariants
└── data/ # (created by Phase 4) JSON / SQLite persistence
interface QAEntry {
id: string; // ulid
question: string; // the canonical question
answer: string; // markdown allowed
tags?: string[]; // optional, for filtering / future routing
createdAt: string; // ISO
updatedAt: string; // ISO
}
interface ChatRequest {
query: string;
history?: { role: 'user' | 'assistant'; content: string }[];
}
interface ChatResponse {
answer: string; // markdown
confidence: number; // 0..1
source: 'qa' | 'suggestions' | 'none' | 'llm';
matched?: { question: string; score: number };
suggestions?: { text: string }[]; // when below threshold
_debug?: { duration_ms: number; candidates: number };
}| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Redirects to /chat |
GET |
/chat |
Server-rendered chat page shell |
GET |
/admin |
Server-rendered admin page shell |
GET |
/api/qa |
List all Q&A entries (consumed by MonkeyTable) |
POST |
/api/qa |
Create one |
PATCH |
/api/qa/:id |
Update one (called from MonkeyTable onChange) |
DELETE |
/api/qa/:id |
Delete one |
POST |
/api/chat |
Query the Q&A system, returns ChatResponse |
GET |
/static/* |
Built JS bundles + CSS |
Goal: server boots, serves placeholder pages, in-memory store works.
deno.jsonwith imports forhono,@hono/hono/jsx,react,react-dom,@datasketch/monkeytab,@assistant-ui/react, esbuildmain.tsxwith Hono app:/,/admin,/chat,/static/*,/api/qa*,/api/chatlib/store.ts:QAStoreinterface (list,get,create,update,delete) +InMemoryQAStoreimplementationlib/seed.ts: ~10 demo Q&A entries covering mtchat's own features (what is it, how to edit, where data is stored, how retrieval works)routes/api/qa.ts: thin wrappers around the store- Page shells render plain HTML for now — no React mount yet
- Smoke test:
curl localhost:8000/api/qareturns the seed
Done when: all routes respond, seed entries are CRUD-able via curl, no UI yet.
Goal: real editable Q&A table in the browser, persisted in server memory.
client/admin.tsx: fetches/api/qa, renders<MonkeyTable>with columns:question— Text, multiline, editableanswer— Text, multiline, editabletags— MultiSelect, options derived from existing tagsupdatedAt— Date, read-only
onChangePOSTs the diff back to/api/qa/:id(usesonCellChangefor granular updates)scripts/build-client.ts: esbuild bundlesclient/admin.tsx→static/admin.js, externalizing react/react-dom (loaded via CDN in the shell or bundled — TBD based on size)views/AdminShell.tsx: minimal HTML with<div id="admin-root">and<script type="module" src="/static/admin.js">- Hot reload:
deno task devwatchesclient/and rebuilds on save
Done when: opening /admin shows an editable table; edits round-trip; reloading the page shows
the edits (in-memory only — Phase 4 adds persistence).
Goal: a real chat UI that answers questions from the Q&A table.
lib/retrieval.ts:match(query, entries) → { matched, candidates, score }- Tokenize query and questions (lowercase, strip punctuation, split on whitespace)
- Score by token-overlap ratio (intersection / union of token sets) — Jaccard for v1
- Threshold default
0.3, configurable via env var - Returns top match if above threshold, otherwise top-3 candidates as "did you mean"
routes/api/chat.ts:POST /api/chat→ callsretrieval.match, returnsChatResponseclient/chat.tsx: mounts@assistant-ui/react<Thread>with a custom runtime adapter that calls/api/chat- Map
ChatResponse.answer→ assistant message - When
source === 'suggestions', render the suggestion chips below the message - When
source === 'qa', show a smallmatched: "..."footer as a citation
- Map
views/ChatShell.tsx: HTML shell with<div id="chat-root">+/static/chat.js- Greeting message ("Hi! Ask me anything from the knowledge base.") seeded by the runtime
tests/retrieval.test.ts: scoring edge cases (empty query, no matches, exact match, partial match, all stop words)
Done when: typing a question in /chat returns the matching answer; below-threshold queries
show suggestion chips; tests pass.
Goal: optionally let an LLM smooth the deterministic answer into natural prose, without making it required.
lib/llm.ts:LlmProviderinterface with one method:generate(input: { query, matched, history, systemPrompt }): Promise<string>
- Implementations:
StubProvider— returns the matched answer verbatim. Default.AnthropicProvider— calls Claude vianpm:@anthropic-ai/sdk, off by default
- Toggle via
MTCHAT_LLM=stub|anthropicenv var; key fromANTHROPIC_API_KEY - The chat route always asks the provider to format — if it's the stub, you get deterministic output; if it's Anthropic, you get a natural rewording grounded in the matched Q&A
- Respect history (last N turns) for multi-turn context
- Streaming: assistant-ui supports streaming responses via async iterables; the Anthropic provider implements that, the stub returns instant
Done when: flipping the env var swaps between deterministic and LLM output without code changes; the chat still works with no API key set.
Goal: survive restarts.
- Add
JsonFileQAStore implements QAStore— same interface, atomic writes todata/qa.json - Then
SqliteQAStoreviajsr:@db/sqlite— same interface, schema migration handled in constructor - Store selection via
MTCHAT_STORE=memory|json|sqliteenv var, defaultmemory - The QA shape doesn't change — only the storage layer
- Migration utility:
deno task exportanddeno task importfor moving between stores
Done when: flipping MTCHAT_STORE swaps the implementation without touching API or UI code;
tests run against all three stores.
Goal: make it pleasant.
- Tags filter in admin (MonkeyTable already has filter UI — wire it up)
- Multi-conversation history sidebar in chat (named threads, deletable)
- Theme: light/dark, configurable via
?theme=URL param - Keyboard shortcut:
/focuses chat input from anywhere - Export the current Q&A set as Markdown (so you can publish it as a static FAQ alongside the chat)
- Tests: end-to-end Playwright test for both pages
- Accessibility audit (assistant-ui handles most of it; verify the admin page too)
Trigger: when the Q&A corpus passes ~50 entries and false negatives become noticeable.
- Option A: SQLite FTS5 — built-in to
jsr:@db/sqlite, BM25 scoring, multi-word, no external deps. First choice. - Option B: Embeddings — local model via
jsr:@huggingface/transformersor remote via Anthropic/OpenAI. More accurate, more dependencies, more latency. - The
retrieval.tsinterface stays — only the implementation changes.
-
Single Hono server — API + page shells + static bundles all from
main.tsx. No separate API service, no client/server framework split. -
Server-rendered HTML shell + client-mounted React per page, not full SPA. Each page (
/admin,/chat) bundles its own client entry point. Smaller bundles, no router on the client, no hydration mismatch. The Hono JSX shell can stay simple because it doesn't need to render the interactive parts. -
QAStoreinterface from day one — even though Phase 0 is in-memory only. The interface is the contract; storage is an implementation detail. This is the only way to swap to SQLite later without rewriting the API. -
Deterministic Q&A first, LLM optional — mtchat is genuinely useful as a static FAQ chat with no LLM key. The LLM is a layer, not the core. This keeps the project local-first in spirit, makes it testable without API keys, and means the bus factor for keeping the demo working is exactly zero.
-
Headless chat UI library (assistant-ui), not a styled widget. We control the look so mtchat doesn't feel like "another generic chat clone." The library handles auto-scroll, accessibility, markdown, streaming, and the tedious parts. We handle the brand, the layout, and the Q&A integration.
- No vector embeddings, no RAG pipeline (Phase 6 if needed)
- No authentication / multi-user (single-user local app)
- No persistence by default (Phase 4 adds it)
- No streaming markdown rendering tricks beyond what assistant-ui ships
- No hosted deployment (Deno Deploy is fine, but the design is local-first first)
- No agent / tool calling (Phase 6+ if it makes sense)
- No file uploads / attachments
- No conversation memory beyond the current thread (Phase 5 adds a sidebar)
- Should the admin and chat pages share a layout? Probably yes after Phase 2. A simple top nav
with
chat | admin | github. - Where does the Q&A schema get its
id? ulid, uuid, or human-readable slug? Lean ulid for now. - Do we expose the API publicly or keep it localhost-only by default? Localhost-only by default.
Add a
MTCHAT_BINDenv var for Phase 4.