Ask how to build a personal AI assistant and you get a diagram. A chat bot on one box. A ticket queue on another. A memory store next to it. A vector database for search. An MCP server to expose the tools. Five services, five things to deploy, five things to monitor, five things to keep in sync, and a network between every pair of them where data can go missing.
M.O.T. does the same job with one Next.js process and one SQLite file.
It tracks tickets, logs every conversation turn, runs keyword search, summarizes sessions, keeps an entity graph and a set of "how Robin works" notes, and exposes all of it as 26 tools to any agent that asks. It has run in production for months. The entire backend is one process talking to one file on disk.
I am not claiming the five-box diagram is wrong. I am claiming it is a choice, and most of the time nobody chose it on purpose.
The diagram you didn't draw
Piece by piece, the default architecture looks reasonable. Tickets are state, so they want a database. Memory is different from tickets, so it wants its own store. Search needs an index, and the reflex answer in 2026 is a vector database. The tools have to be reachable by a model, so you stand up an MCP server. The chat surface is a bot, so that is its own thing too.
Every box has a clean justification. The trouble is the sum. Now you run five deployments, five sets of credentials, five failure modes, and a release process that has to land changes across all of them without drift. You also inherit a class of bug that did not exist before you split things up: the memory store and the ticket store disagree, because a write landed in one and the network ate the other.
The assumption underneath the whole diagram is that separate concerns need separate services. They don't. They need separate code, which is a far cheaper requirement.
What actually runs
Here is the real shape. One Next.js 14 process (App Router, TypeScript strict). One mot.db SQLite file, opened through better-sqlite3 in WAL mode. One append-only ontology/graph.jsonl file beside it. One route at /api/mcp exposing 26 MCP tools over JSON-RPC.
Everything the assistant needs lives in that one database, or the one file next to it:
- Tickets, with a dedup layer that reopens or groups a repeat signal instead of duplicating it.
- An append-only conversation ledger of every turn.
- Full-text keyword search through SQLite's FTS5.
- Structural session digests.
- An entity graph (people, projects, deadlines, preferences, facts) in the JSONL file.
- Procedural notes and durable memory items, back in SQLite.
No second database. No vector service. No standalone MCP server. The tickets UI and the agent tools read and write the same tables through the same code.
There is exactly one second service, and it is deliberate: the Telegram bot, "Rheo," lives in its own repo and talks to M.O.T. over HTTP. It is split off because it has a different deploy cadence and different dependencies, not because the assistant needed a service boundary there. The backend itself is one process.
Why SQLite carries it
The reason the simple version is safe comes down to one fact: M.O.T. is single-user. There is one writer, ever.
That fact unlocks better-sqlite3's synchronous API, and the synchronous API is what keeps the code small. Every write is a plain in-process transaction. No connection pool. No async coordination between a write that started and a read that needs it. No second network hop to a database living somewhere else. The dedup logic runs its lookup and its write inside one synchronous transaction on one connection, so the read sees the open transaction's own writes for free.
WAL mode handles the read-while-write pattern, so the UI can read while a write is in flight. Search is FTS5, SQLite's built-in full-text index, porter-stemmed so "renewing" matches "renewal." Nothing to run, nothing to keep in sync with the source rows.
One sharp edge is worth naming. A raw user query cannot go straight into an FTS5 MATCH. FTS5 has its own small query grammar, so a bare hyphenated token like "follow-up" parses as a NOT expression and the query errors out. The fix is four lines: a helper called ftsPhrase wraps the string in double quotes, escaping any embedded quotes, so the whole thing matches as one literal phrase. That is the texture of SQLite's costs. Not "stand up another service," just "remember to escape the input."
The append-only bets
Two of the design choices bet on append-only storage, and both delete whole classes of bug.
The conversation ledger is append-only rows. You never edit a turn and never delete one. A turn happened or it didn't.
The entity graph goes further. ontology/graph.jsonl is an append-only file with two kinds of line: entity records, and patch lines. Supersede an entity or confirm it, and you do not rewrite its record; you append a patch. At read time loadGraph folds every patch over the records to compute each entity's current state. Because the file is never rewritten in place, a half-finished write can never corrupt an existing record. The worst a torn append can do is leave one malformed line, and the reader logs and skips it instead of throwing.
The trade is real: append-only stores drift and grow. The ledger accumulates. The JSONL file collects patches faster than records. Left alone, both rot.
The answer is a single nightly job at 02:00. It runs VACUUM INTO for a point-in-time snapshot of the database. (VACUUM INTO is the WAL-safe way to copy a live SQLite DB; a plain file copy can miss the uncheckpointed WAL or catch a torn page.) It copies graph.jsonl to the backup directory right beside the snapshot. It prunes stale, low-confidence, unconfirmed entities. And it compacts the JSONL file only once it crosses 5 MB, because rewriting the whole file is not worth doing on a small graph. Three maintenance steps, each in its own error boundary so one failure cannot abort the others.
That is the whole cost of going append-only: one cron job. Keeping an append-only store clean is far less code than keeping a mutable, distributed store consistent across a network.
The MCP server is just a route
The 26 MCP tools are not a server. They are a route handler. /api/mcp speaks JSON-RPC over Streamable HTTP, and all it does is dispatch a tool name to the same TypeScript library the ticket UI already calls. mot_create_ticket runs the exact code path the web form runs. memory_context reads the same tables the dashboard reads.
So there is no second server to deploy, and no network hop for a tool that reads a ticket or writes a memory. A cloud routine filing a signal and a human clicking through the dashboard hit the same process, the same connection, the same file.
The boot sequence shows how little machinery this takes. On start, the process runs its database migrations, validates the source-adapter config against the live enums (a misconfigured adapter fails the boot instead of breaking quietly at runtime), and starts the backup cron. Then it is ready. Ready for the web UI and any MCP client at once, because they were never separate things.
Where it breaks
The limit is the headline, not the footnote: this design is single-user. It is not a SaaS, and the choices that keep it small are the same ones that stop it from scaling out.
The synchronous, single-connection code is safe because SQLite has one writer. Add a second concurrent writer and the model that made the dedup transaction trivially correct stops holding. If M.O.T. ever had to serve many people writing at once, this is the first thing that would go, and most of the simplicity would go with it. That is not a flaw to hide. It is the edge of the bet.
The other cost is operational, and sharp. mot.db and graph.jsonl are irreplaceable live data with no upstream to rebuild from. The deploy excludes both by name, so a release can't clobber the database or the graph. Good. But that same exclusion means a backup that snapshots the database and forgets graph.jsonl is silently incomplete, and you find out only when you need the graph back. The two files have to be backed up together, every time, on purpose. The nightly job does exactly that, and the reason it is written so carefully is that nothing else will catch the omission.
None of these are hidden gotchas. They are the price of the simplicity, and naming the price is the entire point.
Count your services before you add one
The five-box diagram is a default. Defaults are easy to inherit and hard to notice, and this one costs you four deployments you may never need.
A second service earns its place on a narrow rule: when one writer stops being enough, or when a piece genuinely has its own lifecycle, the way the Telegram bot does. Short of that, separate concerns want separate code, not separate servers, and one process reading one file carries more than the diagram suggests.
So before you add the second service, count the ones you already have. For a personal assistant, the honest count is often one.