A minimal clone you can build in 45 minutes with Node, TypeScript, and SQLite
Welcome to another official UVF IT build guide! In this tutorial we’ll craft a tiny persistent context store for AI agents, inspired by Claude‑Mem. The clone will let you save, retrieve, and search short memory snippets without any paid services.
You’ll use plain Node.js, TypeScript, and a local SQLite database – all free‑tier, all runnable on your laptop. By the end you’ll have a working HTTP API you can call from any AI agent or script.
We start by creating a fresh folder and turning it into a Node.js workspace that understands TypeScript. This gives us type‑checking, modern ES‑module imports, and a simple build pipeline without extra tooling.
Next we add a tiny SQLite driver (better-sqlite3) that works synchronously and has zero‑dependency binaries – perfect for a quick prototype. All commands are run from the terminal, so you can follow along on any OS.
mkdir claude-mem-clone
cd claude-mem-clone
npm init -y
npm install --save-dev typescript @types/node
npm install better-sqlite3
npx tsc --init --rootDir src --outDir dist --esModuleInterop true --module es2020 --moduleResolution node --target es2020Now we create a small SQLite helper that opens (or creates) a mem.db file and ensures a table exists to store memory entries. Each entry has an auto‑incrementing id, a text payload, and a created_at timestamp for ordering.
The helper also exports a simple run and all wrapper so later phases can insert, query, and search without worrying about raw SQL boilerplate.
src/db.ts
import Database from 'better-sqlite3';
// Open (or create) the SQLite file in the project root
const db = new Database('mem.db');
// Ensure the table exists – id, text, created_at (ISO string)
const init = `
CREATE TABLE IF NOT EXISTS memory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
`;
db.exec(init);
// Tiny wrappers for later use
export const run = (sql: string, params: any[] = []) => db.prepare(sql).run(...params);
export const all = (sql: string, params: any[] = []) => db.prepare(sql).all(...params);
// Export the raw db for any advanced queries
export default db;In this step we wire up a tiny HTTP server that talks to the SQLite store you created earlier. The server will expose two routes: a POST /mem endpoint that accepts a JSON body with a content field and saves it, and a GET /mem?search=word endpoint that returns any snippets containing the query term. Keeping the API minimal makes it easy to test with curl and later plug into any LLM prompt chain.
We’ll use Node’s built‑in http module so you don’t need extra dependencies, and the sqlite3 package you already installed. All logic lives in a single file (src/server.ts) so you can see the whole flow at a glance.
src/server.ts
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { parse } from 'url';
import { Database } from 'sqlite3';
// Initialise SQLite (creates file if missing)
const db = new Database('mem.db');
db.run('CREATE TABLE IF NOT EXISTS snippets (id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL)');
// Helper to collect request body
function collectBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
req.on('data', chunk => (data += chunk));
req.on('end', () => resolve(data));
req.on('error', err => reject(err));
});
}
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const { pathname, query } = parse(req.url || '', true);
if (req.method === 'POST' && pathname === '/mem') {
try {
const body = await collectBody(req);
const { content } = JSON.parse(body);
if (!content) throw new Error('Missing content');
db.run('INSERT INTO snippets (content) VALUES (?)', [content], function (err) {
if (err) {
res.writeHead(500);
return res.end(JSON.stringify({ status: 'error', message: err.message }));
}
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'saved', id: this.lastID }));
});
} catch (e: any) {
res.writeHead(400);
res.end(JSON.stringify({ status: 'error', message: e.message }));
}
return;
}
if (req.method === 'GET' && pathname === '/mem') {
const term = (query?.search as string) || '';
const sql = term ? 'SELECT * FROM snippets WHERE content LIKE ?' : 'SELECT * FROM snippets';
const param = term ? [`%${term}%`] : [];
db.all(sql, param, (err, rows) => {
if (err) {
res.writeHead(500);
return res.end(JSON.stringify({ status: 'error', message: err.message }));
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(rows));
});
return;
}
// Fallback for unknown routes
res.writeHead(404);
res.end(JSON.stringify({ status: 'error', message: 'Not found' }));
});
const PORT = 3000;
server.listen(PORT, () => console.log(`🚀 listening on http://localhost:${PORT}`));Now that the server code is ready, we’ll launch it with ts-node, which compiles TypeScript on the fly. This avoids a separate build step and keeps the workflow fast for experimentation. After the server is up, we’ll use curl to hit both endpoints and see real JSON responses.
If you prefer a compiled version later, you can run tsc and start the compiled JavaScript with Node – the API behavior will stay identical.
# Install ts-node if you haven’t already
npm install -D ts-node @types/node
# Start the server (it will listen on http://localhost:3000)
ts-node src/server.ts &
SERVER_PID=$!
echo "Server PID: $SERVER_PID"
# Give it a second to start
sleep 1
# POST a snippet
curl -s -X POST http://localhost:3000/mem \
-H "Content-Type: application/json" \
-d '{"content":"Hello AI"}'
# GET snippets containing the word "Hello"
curl -s "http://localhost:3000/mem?search=Hello"
# Stop the server when done
kill $SERVER_PIDAfter the demo calls, you should see a JSON object confirming the save and an array with the stored snippet when you query. This proves the minimal clone works end‑to‑end. From here you can start extending: add authentication, pagination, or swap the SQLite backend for PostgreSQL if you need more scalability.
You can also explore persisting the DB file (mem.db) across restarts, or integrate vector embeddings later to turn plain text snippets into searchable vectors.
# Inspect the raw SQLite file (optional, requires sqlite3 CLI)
sqlite3 mem.db "SELECT * FROM snippets;"
# Example output should show something like:
# 1|Hello AI| Check | Command / Why it matters |
|---|---|
| Server starts without errors | Terminal should show “🚀 listening on http://localhost:3000” |
| POST request returns 201 and JSON {"status":"saved"} | curl command from Phase 4 should output that JSON |
| GET request returns an array with the saved snippet | curl with ?search=Hello should show [{"id":1,"content":"Hello AI",...}] |
Your minimal Claude‑Mem clone is up and running. Feel free to hook it into any LLM prompt chain, add vector embeddings later, or deploy it to a free Vercel/Render instance.