Back to Blog

Dharun's AI Sidekick: How a PM shipped a RAG chatbot

Why I built it


Two reasons, and the honest order matters.

First, I wanted to get my hands dirty. I've spent years writing PRDs for AI features and sitting next to ML engineers. I wanted to know what it actually feels like to build a chatbot that understands what someone is asking, pulls the right context, and answers in a way that sounds like me. Reading about RAG is one thing. Debugging why your vector index is returning the wrong chunk at 11 pm is another.

Second, the practical bit. Recruiters and hiring managers land on this site with specific questions. "Did he work on payments?" "What's the biggest thing he's shipped?" Nobody wants to scroll a resume to find out. A chatbot that answers from my experience and hands the conversation to me when it can't felt like a better front door.

One rule I set before starting: I would not write the code. I'd direct Claude Code the way I direct an engineering team at work. Define the problem, make the calls, review what comes back, test it, decide what ships. If that didn't work, that itself was a useful finding.

It worked.

What it does

From a visitor's point of view, it's simple. You open the chat, you type, it answers. Underneath, it's running one of three playbooks depending on what you asked:

Portfolio questions. "What did Dharun do at HBO Max?" The bot has my full background, projects, and FAQ loaded every single time. No retrieval, no guessing. If it's in my knowledge files, the bot knows it.

Advice questions. Type "help me" and ask about a career decision, a relationship, or a tough choice. Here the bot searches a memory built from my own writing and, crucially, from replies I've personally given to earlier visitors. More on that below.

Everything else. "How do I become a PM?" The bot gives a short, useful answer in three lines, then invites you to email me. It's a chatbot, not a search engine, and it knows it.

Then there are two features that make it more than a FAQ with a personality:

I can see every chat. Each conversation becomes a thread in a private Slack channel. I see what people ask, what the bot said, and where it struggled.

I can take over. If I reply in that Slack thread, my message appears in the visitor's chat window in about a second, labelled as me. The bot steps back for ten minutes and lets the human talk.

And the part I'm most pleased with: every reply I write teaches the bot. My Slack replies get saved to a Notion database with an Approved checkbox. I tidy them up, tick the box, run one command, and the bot now uses my words to answer similar questions in future. It only ever learns from me. Never from visitors.

The architecture

01-architecture.png

The site itself is static, hosted on GitHub Pages. Static sites can't run backend code, so the brain lives somewhere else: a Cloudflare Worker. It's a small program that wakes up when a request arrives, does its job, and goes back to sleep. No server to manage, and the free tier is generous enough that I'll likely never pay for it.

The Worker has four doors:

  • /chat is the main one. A message arrives, the Worker checks rate limits, decides which of the three modes applies, builds the right context, asks Claude, and logs the exchange to Slack.
  • /poll is how the widget asks "has Dharun replied yet?"
  • /slack/events is where Slack delivers my replies. Every request is signature-checked, so nobody can fake a message from me.
  • /admin/reindex rebuilds the advice memory. Locked behind a token.

Around it: Cloudflare KV for sessions and rate limit counters, Vectorise as the vector database for advice memory, Workers AI to turn text into embeddings, a Durable Object for the live reply handoff (there's a story there), and Claude Haiku as the model.

The design decisions that mattered

Full context for facts, RAG for opinions

This was the first real product call. RAG, retrieval-augmented generation, is the fashionable answer to everything. Chop your content into chunks, embed them, retrieve the closest few at question time. But retrieval can miss. If someone asks about R21 and the retriever pulls the wrong chunk, the bot confidently says it doesn't know. For facts about my own career, that's unacceptable.

So portfolio mode doesn't retrieve at all. All three knowledge files go into the prompt every time. It's about 2,000 tokens, and Anthropic's prompt caching means after the first call it costs almost nothing to repeat. Zero misses on the questions that must be right.

Advice mode is the opposite case. That corpus grows every time I approve a reply, and it'll never fit in a prompt. That's exactly what vector search is for.

A hard cost ceiling

A chatbot on a public website is a small open tap into your API bill. I put in four layers: a per-visitor limit of 20 messages an hour, a per-session limit of 30, prompt caching, and a spend cap on the Anthropic workspace with an email alert before it hits. If the key ever stops working, the bot just says it's taking a nap. The site carries on.

The bot never learns from visitors

This was the rule I was most stubborn about. Visitor messages are never embedded, never indexed, never stored anywhere the bot can retrieve from. The only things in the advice memory are my own advice files and replies I personally approved. That makes cross-visitor leakage architecturally impossible. Visitor B cannot pull out what Visitor A asked, because it isn't in any retrievable store.

The Notion database does hold the visitor's question next to my reply, but for my eyes only. I'm the human firewall. I read it, strip anything personal, rephrase if needed, and only then tick Approved.

Everything optional fails quietly

Slack logging, Notion writes, all of it runs after the visitor already has their answer, inside what Cloudflare calls waitUntil. If Slack is down, the visitor never knows. The core promise- ask and get an answer- never depends on the extras.

Four phases in three days

02-four-phases.png

I wrote a spec before any code existed. Not because I'm disciplined, but because directing an AI coder without a spec is like briefing an agency with "make it pop". The spec had a phase plan, and every phase shipped as its own pull request. The site worked at every step, even with the Worker switched off.

Phase 1: was the bot itself. Widget, router, both memory strategies, rate limits, and the content. Writing the content took longer than the code. Turns out a chatbot with placeholder text is worse than no chatbot, so I pulled my resume and my Notion experience bank together and wrote the about, projects and FAQ files properly.

Phase 2: put every chat into Slack. One parent message per session, every turn threaded underneath, bot replies labelled with which mode handled them.

Phase 3: made the thread two-way. That's the live takeover, and it's where the interesting bugs lived.

Phase 4: closed the loop with Notion.

Live takeover, and the bug that taught me about storage

03-live-takeover.png

The flow is simple to describe. I reply in Slack. Slack sends a signed webhook to the Worker. The Worker stores the reply as pending for that session. The widget, which polls every second while the visitor is active, picks it up and shows it as "Dharun (live)". The bot goes quiet for ten minutes.

Getting it working was not simple, and two bugs are worth telling.

Slack sent nothing. For a solid hour, not one event reached the Worker. The webhook URL was verified, the permissions were right, the app was installed. Zero requests. The cause was a single setting called Socket Mode that had been switched on by default when the app was created. Socket Mode tells Slack "push events down a live connection instead", and when it's on, Slack ignores your webhook URL entirely. One toggle, one hour.

Replies took 30 seconds. Once events were flowing, my reply would show up in the widget eventually. Thirty seconds, sometimes more. I assumed polling was the problem and spent a while tuning intervals. It wasn't. The pending replies were being stored in Cloudflare KV, which is built for data that changes rarely and is read globally. A write can take up to a minute to become visible to a read. The fix was moving pending replies to a Durable Object, which gives instant consistency. Replies now arrive in about a second.

That second bug is the kind of thing you only learn by building. I'd read the words "eventually consistent" a hundred times. Now I know what they cost.

The polling design that came out of it is one I'm quite happy with. The widget polls once a second, but only while the visitor is actually doing something. After 20 seconds of no typing, scrolling or touching, it stops. The moment they move, it polls immediately and resumes. Fast when it matters, silent when it doesn't, and a visitor who walks away for lunch doesn't burn my request budget.

The learning loop

04-learning-loop.png

This is the feature I wanted from day one. Every reply I send from Slack lands in a Notion database with the visitor's question, a session reference, a tag, and an Approved checkbox that starts unchecked. When I have a few minutes, I open the database, clean up anything personal, fix my typos, and tick the box. Then one command on my laptop re-embeds everything approved into the vector memory.

The next visitor who asks something similar gets an answer grounded in what I actually said. The bot sounds more like me every week, and I stay in control of every word it learns.

Tools and cost

Piece Tool Cost
Model Claude Haiku 4.5 via Anthropic API Capped at $3/month, currently well under
Backend Cloudflare Workers, KV, Vectorize, Workers AI, Durable Objects Free tier
Site GitHub Pages, Netlify previews for PRs Free
Chat logs and takeover Slack, one private channel, one custom app Free
Learning loop Notion database, one internal integration Free
Building it Claude Code, on my existing Claude subscription Already paying

Total new spend: a few dollars a month at most, with a hard ceiling.

What I'd do differently

Write the content first. I built the whole pipeline around placeholder files and had to hide the advice feature at launch because the advice files weren't written yet. The code was ready before the words were.

Test from the visitor's seat earlier. I spent a while debugging Phase 3 on my laptop with the Slack tab in front, which put the widget tab in the background, which made the browser throttle polling to once a minute. Half my "it's slow" reports were me testing it wrong.

Rotate secrets the moment they're exposed, not later. I screenshotted a config file mid-session without thinking and had to rotate three keys. Ten minutes of cleanup for two seconds of carelessness.

What's next

More advice content, so the "help me" flow can go back into the greeting. A nightly reindex so I don't have to remember the command. And at some point, replacing polling with a real push connection, though at current traffic that's a solution looking for a problem.

If you're a PM thinking about trying this

Get your hands dirty by working on the project. Not a course, not a tutorial, a thing with your name on it that real people will use. You'll learn more from one eventually consistent storage bug than from a dozen explainer videos. And you'll come out of it with a much sharper sense of what to ask for, and what to push back on, the next time an engineer tells you something "just needs a quick fix".

The chat bubble is in the corner. Go on, ask it something.