Development of Notarik Chat System by Nashir JamaliDevelopment of Notarik Chat System by Nashir Jamali

Development of Notarik Chat System

Nashir Jamali

Nashir Jamali

I planned LangGraph. I shipped plain Python. When I wrote the plan for the rebuild in October 2026, the stack was clear: a language model as the supervisor, LangGraph for state, loops, and pauses, LangChain chat models through OpenRouter, and LangSmith or Langfuse for tracing. It was a good plan on paper. The code went a simpler way, and this case study is about why.
Rules before code. Before any agent code, I wrote four rules. Agents propose and you commit, so every write waits for a confirm. Tools see one user, and the signed-in user comes from the server, never from the message. Receipt text is data and never an instruction. And there is no free-form SQL: only fixed queries with parameters. 🔒
One route per message. Every chat message goes to exactly one route: capture, budget, insights, smalltalk, or refuse. If a message holds both an amount and a question ("kopi 18rb, was that too much?"), it is capture, and the question waits for the next turn. Capture and budget can propose a write. The other routes never write.
A classifier, not a model. The router is a small rule-based classifier with no tools. "nasi goreng 25rb gojek" is capture, "berapa pengeluaran dining bulan ini?" is insights, "am I on track for my budget?" is budget, and "which stocks should I buy" is refused. Because it uses rules, routing can be scored without a model call. An LLM router can replace it if labelled data shows it is better.
Tools, not text-to-SQL. The insights answers come from five fixed queries: spend by category, top merchants, compare two periods, search items, and search past transactions. 🛠️ They take parameters, and the user comes from the server, never from the message.
The confirm gate. A handler never writes. It saves a pending action on the thread. You approve, edit, or reject, and only the confirm endpoint writes, through the same API and authorization as the rest of the app. Insights and refuse can never set a pending action.
State lives in Postgres. I did not add a separate checkpointer service. A conversation thread is a row, with its route and pending action. Messages are rows. Token usage per user per day is a row. Every row has a user_id, and every query filters on it.
Guardrails. There is a rate limit per user per minute and a token budget per user per day. Out-of-scope requests (stocks, transfers, other people's data) are refused. Amounts and receipt files are capped.
Testing without a model. A scripted model replays fixed outputs, so tests are exact and free. Tests cover that confirm writes and reject writes nothing, that a follow-up keeps its subject, that a bad edit is rejected and the proposal stays, and that a budget follows the month named in the message. ✅ What I have not built yet is a labelled routing set.
Why not LangGraph, yet. Plain Python gives me few dependencies, routing I can score without a model call, and behaviour that is easy to predict. It costs me pause and resume written by hand, no hosted trace viewer yet, and messages outside the rules fall back to small talk. If I swap in LangGraph later, the HTTP API does not change.
Where it stands. Notarik is in a closed test with friends and family, about 10 to 20 users. There is no labelled routing set yet (the plan is 60 to 80 messages), and I have not measured routing accuracy. Next, I would only try an LLM router if it beats the rules on labelled data. Try it: https://notarik-app.nashir.tech/
Like this project

Posted Nov 4, 2026

Developed Notarik, a chat-based system using Python with rule-based routing.