Thanks for picking this up. mtchat is a small, opinionated project — Deno + Hono on the server, React in the browser, deterministic Q&A retrieval. The plan lives in ROADMAP.md. This file is the practical guide for working in the codebase.
git clone https://github.com/datasketch/mtchat.git
cd mtchat
deno task devThe server boots on http://localhost:8519 (a deliberately uncommon port — won't collide with
React/Vite/Hono defaults). Hot reload is on via --watch. The first run downloads the npm
transitive dependencies (@datasketch/monkeytab, @assistant-ui/react, and their deps via Radix);
subsequent runs are instant.
Open in another terminal:
curl http://localhost:8519/api/qa
curl -X POST http://localhost:8519/api/chat \
-H "content-type: application/json" \
-d '{"query":"What is mtchat?"}'Before you write a line of code, run the project once and skim these files in order. They're small.
| File | What's there | Why it matters |
|---|---|---|
deno.json |
Tasks, imports map, JSX config | The imports map already declares @datasketch/monkeytab, @assistant-ui/react, react, react-dom, and esbuild — you don't need to add anything to start Phase 1 |
main.tsx |
Hono app, route mounting, store init, server bind | Single entry point. Top-level await store.replaceAll(seedEntries) runs once at boot |
lib/store.ts |
QAStore interface + InMemoryQAStore |
Phase 4 swaps in JsonFileQAStore and SqliteQAStore behind the same interface — don't break the interface |
lib/retrieval.ts |
Jaccard token-overlap match() with threshold |
Used by /api/chat. Tunable via threshold and topN options. Phase 6 may replace it with FTS5 or embeddings |
lib/seed.ts |
10 demo Q&A entries | First-run UX. Replace freely once persistence lands |
routes/api/qa.ts |
CRUD endpoints | Thin wrappers around the store |
routes/api/chat.ts |
Chat endpoint | Calls match(), returns { answer, confidence, source, matched?, suggestions? } |
views/Layout.tsx |
HTML shell, top nav, inline base CSS | Server-rendered with hono/jsx precompile. Both pages wrap in this |
views/AdminShell.tsx |
/admin placeholder + <div id="admin-root"> slot |
Phase 1 mounts the React MonkeyTable into #admin-root |
views/ChatShell.tsx |
/chat placeholder + <div id="chat-root"> slot |
Phase 2 mounts the assistant-ui Thread into #chat-root |
The admin page uses MonkeyTab as the editable Q&A grid. It's an embeddable React component that
handles cell editing, sorting, filtering, virtualization, and keyboard navigation out of the box.
You configure it with columns and rows props.
Install: already in deno.json imports map as npm:@datasketch/monkeytab@^0.2.0. No
npm install step — Deno fetches it on first import.
Docs: https://monkeytab.app/docs — read the Get started guide and Reference / overview
before you write client/admin.tsx.
Pattern for Phase 1 (client/admin.tsx sketch):
import { createRoot } from 'react-dom/client';
import { MonkeyTable } from '@datasketch/monkeytab';
import { useEffect, useState } from 'react';
interface QAEntry {
id: string;
question: string;
answer: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
function App() {
const [rows, setRows] = useState<QAEntry[]>([]);
// Initial fetch
useEffect(() => {
fetch('/api/qa')
.then((r) => r.json())
.then((data) => setRows(data.entries));
}, []);
return (
<MonkeyTable
columns={[
{ id: 'question', label: 'Question', type: 'Text', options: { multiline: true } },
{ id: 'answer', label: 'Answer', type: 'Text', options: { multiline: true } },
{
id: 'tags',
label: 'Tags',
type: 'MultiSelect',
options: {
options: [], // populated dynamically from existing tags
},
},
{ id: 'updatedAt', label: 'Updated', type: 'Date', editable: false },
]}
rows={rows}
onCellChange={async (rowId, fieldId, newValue) => {
// PATCH the single field that changed
const res = await fetch(`/api/qa/${rowId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ [fieldId]: newValue }),
});
const { entry } = await res.json();
setRows((prev) => prev.map((r) => (r.id === rowId ? entry : r)));
}}
height='100%'
/>
);
}
const root = createRoot(document.getElementById('admin-root')!);
root.render(<App />);Notes:
- Use
onCellChange(per-cell granular update via PATCH) rather thanonChange(full row diff) so the API stays simple. - The
tagsMultiSelect needs itsoptionspopulated from the union of existing tags across rows. Compute it once on initial fetch. @datasketch/monkeytabships its own minimal CSS — import it from the package, or override with your own styles.- Read the Per-column control section of the docs for
editable,width,sortable,alignper column.
Headless React primitives for AI chat: <Thread>, <Composer>, <Message>, etc. You compose them
yourself, you bring your own runtime adapter to call your backend.
Install: already in deno.json imports map as npm:@assistant-ui/react@^0.7.0. The Radix UI
deps are pulled in transitively.
Docs: https://www.assistant-ui.com/docs — read Getting started and Concepts / Runtimes
before you write client/chat.tsx. The "BYO Backend" runtime adapter pattern is what we want.
Pattern for Phase 2 (client/chat.tsx sketch):
import { createRoot } from 'react-dom/client';
import {
AssistantRuntimeProvider,
type ChatModelAdapter,
useLocalRuntime,
} from '@assistant-ui/react';
import { Thread } from '@assistant-ui/react';
const MtchatModelAdapter: ChatModelAdapter = {
async run({ messages, abortSignal }) {
const lastUser = messages.findLast((m) => m.role === 'user');
const query = lastUser?.content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join(' ') ?? '';
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query,
history: messages.slice(0, -1).map((m) => ({
role: m.role,
content: m.content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join(' '),
})),
}),
signal: abortSignal,
});
const data = await res.json();
return {
content: [{ type: 'text', text: data.answer }],
};
},
};
function App() {
const runtime = useLocalRuntime(MtchatModelAdapter);
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
);
}
const root = createRoot(document.getElementById('chat-root')!);
root.render(<App />);Notes:
- Phase 2 doesn't need streaming — the deterministic backend returns the answer in one shot. Phase 3 (LLM rephrasing) is when streaming becomes useful, and assistant-ui supports it via async iterables in the runtime adapter.
- When
data.source === 'suggestions', surface thedata.suggestionsas clickable chips below the message. Look atdata.matchedto optionally show "Matched: original question" footer like a citation. - assistant-ui's
Threadcomponent has primitive sub-components you can compose to control the look — don't just drop the defaultThreadand call it done. Style it to match the rest of mtchat. - Use the
useLocalRuntimehook (manages messages in React state) for v1. The other runtime types are for streaming-from-backend setups we don't need yet.
client/admin.tsx and client/chat.tsx are React files that need to be bundled into single ES
modules the browser can load. Use esbuild via deno.land/x/esbuild (already in the imports map).
Pattern (scripts/build-client.ts):
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['client/admin.tsx', 'client/chat.tsx'],
bundle: true,
format: 'esm',
outdir: 'static',
jsx: 'automatic',
jsxImportSource: 'react',
define: { 'process.env.NODE_ENV': '"production"' },
loader: { '.tsx': 'tsx', '.ts': 'ts' },
});
esbuild.stop();Add tasks to deno.json:
Then update views/AdminShell.tsx and views/ChatShell.tsx to load the bundles:
<script type='module' src='/static/admin.js'></script>;And add a route in main.tsx to serve files from the static/ directory:
app.get('/static/:file', async (c) => {
const file = c.req.param('file');
try {
const content = await Deno.readFile(`./static/${file}`);
const ext = file.split('.').pop();
const type = ext === 'js' ? 'application/javascript' : 'text/plain';
return new Response(content, { headers: { 'content-type': type } });
} catch {
return c.notFound();
}
});(There's a placeholder route stub in main.tsx that you'll replace.)
- Port: 8519. Hardcoded in
main.tsxas the default. Override withPORT=...env var. - Format:
deno fmt— config indeno.json(single quotes, 100-char lines, 2-space indent, semicolons). - Lint:
deno lint. - Imports: use the imports map (
hono,@datasketch/monkeytab, etc.), not full URLs in source files. - Types: every function gets a return type. Every interface lives next to its primary user.
- Errors: API endpoints return
{ error: 'snake_case_code', detail?: string }with appropriate HTTP status. Seeroutes/api/qa.tsfor the pattern. - No mocks in tests: use the real
InMemoryQAStore, not a stub. The interface is small enough that mocking buys nothing. - One purpose per file:
lib/store.tsis storage,lib/retrieval.tsis matching,lib/seed.tsis data. Don't bundle. - JSX: server JSX uses
hono/jsx(precompile). Browser JSX uses React'sreact-jsxautomatic runtime via esbuild.
deno task testPhase 0 doesn't ship tests yet. The first tests should land alongside Phase 2 —
tests/retrieval.test.ts for the matching logic, tests/store.test.ts for the CRUD invariants. Use
@std/assert (already in the imports map).
Standard GitHub flow:
- Fork, branch, commit, PR.
- Run
deno fmtanddeno lintbefore pushing. - Run
deno task testif your change touches anything tested. - Run
deno task devand smoke-test the routes you touched. - PR description should explain why, not just what.
For larger changes (anything that touches the QAStore interface, the API surface, or pulls in new
dependencies), open an issue first to discuss.
ROADMAP.md is the source of truth for what to build next. Phase 0 is done. Phase 1
(Admin page with @datasketch/monkeytab) is the next thing. The "Done when" criteria at the
bottom of each phase tell you when you've actually finished it — don't claim a phase is done until
those bullets all hold.
If you have questions about a phase that the roadmap doesn't answer, that's a sign the roadmap needs updating — open an issue.