Off the clock · Jan 2026
AI data analyst v2
A local app to upload a CSV and ask questions in plain English. Gemini writes pandas code that runs against the full dataset and returns text, a chart or a downloadable CSV.
What if I could just talk to my data?
This picks up where my CSV visualiser left off. That one could only ever answer the questions I had built a control for, and every new kind of question meant a new branch in the backend and a new mode in the chart.
TODO(tarang): what happened between May 2025 and January 2026, and why the next version was a chat and not more dashboard.
Seven months is a long time in this field. By January 2026 a model writing my code was routine, not a novelty, and the new idea for me was to stop having it write the app and have it write the analysis instead. Today it's a small platform that runs on my laptop: I upload a CSV and it becomes a project, each project can have several chats, and everything is saved to disk. I type a question in plain English, Gemini writes the pandas code to answer it, and my server runs that code against the full dataset. The answer comes back as text, a chart or a CSV to download.
How it works
flowchart TD
A["Me, in the browser"] -->|"upload a CSV"| B["New project, data saved on disk"]
B --> C["Profile of the data: columns, ranges, common values"]
A -->|"ask a question"| D["This chat's Gemini conversation"]
C --> D
D --> E["Reply: kind of answer, pandas code, explanation"]
E --> F["My server runs the code on the full data"]
F -->|"a question"| G["Printed answer"]
F -->|"a chart"| H["Chart image"]
F -->|"a change to the data"| I["New CSV to download"]
G --> J["Saved to the chat on disk"]
H --> J
I --> J
J --> A
I think of it as two halves. The core was the analysis - one model call per question followed by running whatever code comes back. The other is plain bookkeeping: projects, chats and messages kept as files on disk with no database, so I can open any of it in a text editor - I got to this later when I started testing out the core model with queries and I figured I would need a better way to manage this if this actually works.
This was still early days for me with a model inside something I built - I didn't trust models with tool calls yet and hallucinations were a real deal. I started off thinking this should be manageable - assuming the model can actually write good Python, if given enough context about the data that I am working with it'll do a reasonable job with the script that will get me my answers. Here is how I went about this. The model never sees the full data. I send it a short profile and the first couple of rows: each column's type, the range of numeric columns, and the actual values of columns that only have a handful. This was my form of metadata for the model based on which it was to write the query to my question. My thinking was that if it knows the real category spellings and bounds, it has less reason to guess when it filters.
I decided there were a couple of distinct types of queries that I ask and formats that I wanted the results back in. I ask Gemini to reply with what kind of answer this is, the code and a short explanation, and I let that one choice decide what happens next: a plain question gets its answer printed as text, a chart is saved as an image, and a change to the data becomes a new CSV to download. Most of the prompt is rules against specific mistakes, like returning a single column where I wanted a table or printing empty tables.
I chose to run the code on the full data so the numbers are exact, and since the model only sees the profile, a bigger file doesn't cost more tokens. The catch is that my server runs whatever the model writes, with nothing in between. Fine on my laptop with my own files, and the main reason this is not something I would put on the internet.
"Agent" is a generous word for what sits in the middle. There's no tool calling and no loop. Each chat gets its own Gemini conversation, and its memory is just Gemini's chat history, which is what makes a follow-up like "now split that by department" work. The model never sees what its code printed or drew though, only its own replies. The history is saved with the chat too, but restoring it never made it in, so after a restart the app shows the whole conversation and the model has forgotten all of it.
How I got here
1. A Streamlit chat with Gemini (by January 2026)
The first version was one Streamlit file plus a script that profiled the data. I uploaded one CSV, chatted with Gemini about it, and the model wrote pandas code that the app ran on the whole dataset, answering with a table, a plot or a single number. Instead of building one more control, I let the model write that code fresh for every question, and the whole filter, aggregate and compare layer was simply gone.
I picked Gemini because it can be told to reply in a fixed structured format, its context window holds the full chat history, and Flash is fast and cheap. The idea was to let Gemini decide the kind of answer and its format.
Using it, the limits were obvious: one file at a time, one chat per file, nothing saved, no versions, no way to manage files and nobody checking the generated code. Most of that list is exactly what the visualiser already had.
2. Projects, chats and React (January 2026)
In early January I wrote a plan for the rebuild: several CSVs as projects, several chats per project, everything saved to disk, versions I could go back to, a sidebar like ChatGPT or Claude, the ability to change a CSV and download it, and an optional judge agent to review the generated code. For the interface I took cues from Claude, ChatGPT's Advanced Data Analysis and Julius AI.
A day later I wrote a guide for rebuilding from scratch, this time with git, because without it there had been no way to recover the first version. It also wrote down what the first attempt had taught me, and all of it is in the code: Gemini's history can't be saved as is, so only who said what is kept; if the history comes back without the original instructions Gemini stops replying in the structured format, so the instructions go at the start of every session; and changed data needs a predictable name for the app to find it.
TODO(tarang): what broke that made a from-scratch rebuild necessary?
The rebuild landed in one go: projects, chats and versions on disk, a Gemini conversation per chat, an API, and a React UI scaffolded with Lovable that became the main one, with the Streamlit UI kept alongside. The kinds of answer changed too. Table, plot and number became question, chart and data change, where a data change is a new CSV offered as a download.
Not everything in the plan shipped. The judge agent left only empty fields behind. The version store can create and revert versions, but only the first snapshot is ever saved, so the visualiser's "go back to a saved view" didn't really survive the move.
TODO(tarang): why move from Streamlit to React?
3. Prompt fixes and release (January to April 2026)
Docker and start/stop scripts came next, and the last feature work in mid-January was a round of fixes: working download links, making sure a data change really returns a table, flagging those lists written out as text in the profile, and stricter prompt rules for handling text and formatting output. I wrote all of the January work together with Claude Code, and in April I cleaned it up for a public release. That's the system at the top of this post, with the same core trick as the Streamlit version: the model writes code, my server runs it.
Looking back, the real trade was the one I made at the pivot. The visualiser could only answer questions I had built a control for, but its answers were deterministic and its filters could never invent a column. The chat can attempt anything the model can turn into pandas, and in exchange my server runs code nobody has read. Letting the model pick the kind of answer kept the app simple, but a wrong pick either fails the request or quietly loses the chart. And I believe the bigger gap is that the model never sees its own output: showing it what its code printed is what would let it check its own answer.