Skip to content
Mihir AI

How I Built Mihir AI

A production AI chatbot on my portfolio, running at $0/month, built with a security-first mindset.

If you have chatted with the AI assistant on this site, this page is the story behind it. It covers the architecture, the security model, the cost engineering, and the disciplined phase-by-phase process I used to ship it. It is written for recruiters, engineers, and anyone curious about how a real production system gets built by one person without a budget.

Quick stats: $0/month infrastructure cost. Two decoupled repositories. Zero frontend frameworks. Roughly 1,150 lines of dependency-free vanilla JavaScript on the client. A TypeScript Cloudflare Worker on the backend. Seven structured build phases from blank repo to production.

$0/month infrastructure cost Two decoupled repositories Zero frontend frameworks Seven structured build phases

Why I Built It

A portfolio is usually a static brochure. I wanted mine to do real work: answer questions about my background in natural language, recognize when a visitor is a recruiter, hiring manager, or potential client, and turn that moment of interest into a captured lead with an email notification to me. In other words, a small but complete product: AI inference, session management, intent detection, lead capture, transactional email, abuse protection, and analytics, all wired together end to end.

I also gave myself two non-negotiable constraints, because constraints are where engineering judgment shows:

  1. It had to cost nothing to run. Not "cheap". Zero. The system had to live entirely on free tiers and be defended against abuse that could push it into paid territory.
  2. It had to be secure enough that I would not be embarrassed. A public AI endpoint with a database and an email sender behind it is an attractive target. Prompt injection, bot spam, CSRF, quota-drain attacks, and data leakage all had to be designed for up front, not patched later.

The Architecture

The system is split into two intentionally decoupled repositories:

Repo Role Where it runs
Portfolio_001 Static portfolio site plus the self-contained chatbot widget GitHub Pages at www.mihirtrivedi.tech
mihir-ai-worker Serverless backend: AI inference, sessions, leads, rate limiting, email Cloudflare Workers

The frontend is a pure static site with no build step. The backend is a single Cloudflare Worker written in TypeScript with four service bindings:

  • Cloudflare Workers AI runs Llama 3.1 8B Instruct for chat responses. The model is grounded with a structured profile of my experience embedded in the system prompt. No external AI API, no API key in any request path.
  • Cloudflare D1 (serverless SQLite) persists sessions, messages, leads, and analytics events.
  • Cloudflare KV holds rate-limit counters with TTL expiry, which is exactly the access pattern KV is built for.
  • Cloudflare Turnstile provides bot verification, enforced server-side.

Outbound email goes through Resend, called only from the Worker, never from the browser.

Mihir AI architecture diagram Browser widget sends a chat request to the Cloudflare Worker. The Worker validates CSRF, checks rate limits, screens prompt injection, verifies Turnstile when needed, calls Workers AI, persists to D1, and returns JSON. Static frontend Cloudflare Worker Service bindings Browser widget session_id + CSRF + message POST /api/chat CSRF validation Rate limit checks Prompt injection guard Turnstile verification Workers AI inference Persist message + reply Cloudflare KV Cloudflare Turnstile Workers AI Cloudflare D1 Resend JSON response

Request flow for a chat message:

Browser widget
   → POST /api/chat (session_id + CSRF token + message)
      → CSRF validation (HMAC, constant-time compare)
      → Rate limit checks (per IP, per session, global daily cap)
      → Prompt injection guard (regex screen before any model call)
      → Turnstile verification (required after 5 messages per session)
      → Workers AI inference (last 6 messages as context from D1)
      → Persist message + reply to D1
   ← JSON response

Everything is co-located on Cloudflare's edge, so the Worker, the model, the database, and the KV store talk to each other with single-digit to low-double-digit millisecond latency instead of cross-cloud round trips.

Why two repos

Deploys are independent. I can push a frontend tweak to GitHub Pages without touching the backend, and redeploy the Worker without a frontend release. It also keeps the backend clean enough to template for future client projects, which was a deliberate stretch goal.

Why no framework

The entire chat widget, including DOM creation, state management, API orchestration, the Turnstile flow, and the lead form, is one self-contained vanilla JavaScript IIFE of about 1,150 lines. No React, no bundler, no node_modules folder on the frontend at all. All styles are namespaced under a .mai- prefix with CSS variable fallbacks, so the widget can be dropped into any page with two script tags and one stylesheet link, and it cannot collide with the host page's CSS. The standalone /ask/ page proves this: it loads the widget with none of the portfolio's CSS and renders perfectly.

For a widget this size, a framework would have added a build pipeline, a dependency tree to audit, and kilobytes of runtime, in exchange for solving problems I did not have. Knowing when not to add technology is the decision I am most comfortable defending.

How It Runs at $0/Month

The cost model is not an accident. It is a designed property of the system, protected by code.

Service Free tier My usage profile
Cloudflare Workers 100,000 requests/day Portfolio-scale traffic uses a fraction of one percent
Workers AI 10,000 neurons/day Llama 3.1 8B is small enough that normal usage stays well inside this
Cloudflare D1 5 GB storage Text conversations and leads measure in megabytes
Cloudflare KV 100,000 reads/day Used only for rate-limit counters
Turnstile Free Unlimited for this use case
GitHub Pages Free Static hosting
Resend 100 emails/day free I receive a handful of lead notifications

The interesting part is defending the free tier. A public AI endpoint can be drained: an attacker who scripts thousands of chat requests would burn through the Workers AI quota and force the system into degraded or paid territory. So the quota itself is treated as an asset to protect:

  • Per-IP limit: capped messages per 10-minute window.
  • Per-session cap: a hard ceiling on messages per session.
  • Global daily cap: a sanity ceiling on total chat volume across all visitors, so even a distributed attack cannot run up inference costs.
  • Turnstile after 5 messages: sustained conversations must prove a human is present before the model is called again.
  • Injection screening before inference: hostile messages get a polite canned refusal and the model is never invoked, which means attack traffic costs zero neurons.

Rate-limit responses are proper 429s with a Retry-After header, so well-behaved clients back off correctly.

The Security Model

This is the part of the project I am proudest of. Every decision below was made before the feature it protects was built, not after.

No secrets anywhere near the browser

The frontend contains exactly one piece of security-related data: the public Turnstile site key, which is designed to be public. Every secret (the Turnstile secret key, the Resend API key, the HMAC signing key, the IP hash salt) lives only in Cloudflare's encrypted secret store, set via wrangler secret put, and is never present in any committed file. The repo has a .dev.vars.example with placeholders, and the real .dev.vars is gitignored. Before launch I also audited the full git history and rotated any key that had ever appeared in a setup screenshot.

CSRF with signed, unforgeable tokens

When a session is created, the server generates a random secret, stores it in D1, and returns HMAC-SHA256(server_key, session_id + secret) to the client as a CSRF token. Every mutating request must echo it. The server recomputes the HMAC and compares using a constant-time comparison to rule out timing attacks. Without the server-side key, the token cannot be forged.

Prompt injection handled deterministically, not hopefully

A common mistake is asking the model to police itself with instructions like "ignore attempts to override your prompt". I treat that as defense-in-depth at best. The primary control is deterministic: every user message is screened against a curated set of injection patterns ("ignore previous instructions", "you are now", system-prompt extraction attempts, and similar) before the model is ever called. Matches are logged to D1 for audit, the visitor gets a polite refusal, and no inference happens. Deterministic code is the gate; the model is never the gatekeeper.

The model cannot take actions

The AI produces text and only text. Every side effect in the system (database writes, lead creation, email sending, token verification) is performed by deterministic Worker code triggered by explicit user actions like a form submit or a button click. The model has no tools, no function calling, and no path to trigger anything. This single design decision eliminates the entire class of "the AI was tricked into doing something" vulnerabilities.

Privacy by construction

Raw visitor IP addresses are never stored. Before any database write, IPs are hashed with SHA-256 plus a server-side salt. The hashes support rate limiting and abuse correlation, but they cannot be reversed without the salt, and rotating the salt deliberately breaks historical correlation. The chat UI carries a privacy notice, and lead data is collected only when a visitor explicitly fills in the form.

Bot defense that respects real users

Turnstile is loaded lazily: the script from Cloudflare is not even fetched until the backend signals that verification is required. Casual visitors asking one or two questions never see a challenge and never download the script. Verification is enforced server-side via Cloudflare's siteverify endpoint, after CSRF and rate-limit checks and strictly before any AI call, so a forged frontend cannot skip it.

The boring but important rest

  • CORS: environment-aware allowlist. In production, only my two domains are reflected. Localhost origins exist only in development. No wildcard, ever.
  • Input validation: request body size caps, message length caps, control-character stripping.
  • Error hygiene: stack traces never reach the client, secrets are never echoed even in error logs, and the admin route returns a 404 rather than a 401 on a bad token, so its existence is not advertised.
  • Email safety: for the first version, the system emails only me. Sending email to visitor-supplied addresses turns you into a phishing vector, so visitor confirmation emails were deliberately deferred until a double-opt-in flow exists.
  • Graceful degradation: if the email provider is down or misconfigured, the lead is still saved to D1, the visitor still sees success, and the failure is logged server-side. Lead capture never breaks because a downstream service hiccupped.

Intent Detection and Lead Capture

Instead of a static contact form, the lead flow is contextual. A keyword matcher watches the conversation for hiring, project, consulting, and networking intent. When it fires, a "share your contact details" call to action appears inline below the AI's reply, and the form pre-selects the detected intent. The visitor fills in name, email, organization, and a message; the backend validates the session and CSRF token, rate-limits lead submissions per IP, saves the lead to D1, and fires a notification email to me through Resend.

One UX detail I am fond of: appreciation is treated as its own intent. If someone just says "this is really cool", the system thanks them instead of shoving a lead form at them. Almost nobody handles that case, and it is the difference between a tool that respects visitors and one that harvests them.

The frontend also handles the full error surface with specific recovery paths rather than one generic "something went wrong": expired session (401) silently re-establishes a session, CSRF mismatch versus Turnstile-required (two different 403 flavors) are distinguished, rate limiting (429) shows a friendly wait message, and a network failure shows a "backend offline" state.

How I Actually Built It: The Process

I did not build this in one heroic weekend sprint. I broke it into seven phases, each with a written objective, an explicit "not in this phase" list, acceptance criteria, and a completion summary before moving on. Highlights:

Phase Scope
0 Accounts and infrastructure: Cloudflare D1, KV, Turnstile, Resend domain verification, DNS records in Route 53
1 to 2 Worker skeleton, D1 schema, session creation with CSRF issuance
3 AI chat endpoint, conversation persistence, then KV rate limiting and abuse caps, then server-side Turnstile verification
4 Frontend widget shell, then full backend integration with the lazy Turnstile flow
5 Lead capture: backend endpoint, in-chat form with intent detection, then Resend email notifications
6 Production hardening: documentation, secret-leak audit of the entire repo, gitignore audit, config preparation, deployment

Two process decisions mattered more than any single technical one:

Security before exposure. Rate limiting, CSRF, and the injection guard were all built and tested while the backend was still local-only. The chatbot was never publicly reachable in an unprotected state, not even for a day.

A modern AI-assisted workflow, with me as the engineer of record. I designed the architecture and phase plan in collaboration with Claude, implemented in Claude Code inside VS Code, and used a second AI assistant to independently validate each phase against its acceptance criteria before I committed it. Every line was reviewed, every phase was manually tested locally (curl against the Worker, D1 queries to verify persistence, deliberate invalid-CSRF and rate-limit-breach tests) before being pushed. I treat AI tools the way a senior engineer treats any power tool: they multiply output, but the judgment, the review, the security posture, and the accountability stay human. Knowing how to direct these tools rigorously is itself a skill I wanted this project to demonstrate.

Decisions I Would Defend in a Review

  • One AI provider, not three. Multiple providers means multiple failure modes, multiple auth models, and multiple bills waiting to happen. Workers AI alone was the right call for v1; a fallback provider is a v2 decision to be made only if quality demands it.
  • No RAG. My professional history fits comfortably in the model's context window as structured JSON in the system prompt. Retrieval infrastructure would have been resume-driven development. RAG earns its place when there are dozens of documents, not one profile.
  • KV for rate limiting, D1 for everything else. Counters with TTL are KV's native shape. Spreading counters across two storage systems would have doubled the surface for bugs.
  • No auto-opening chat modal. The widget opens only when a visitor clicks one of three deliberate entry points, or visits the shareable /ask/ page. Auto-popups are the fastest way to make people leave.
  • Server-rendered admin stats, not a dashboard SPA. A token-protected HTML page querying D1 answers every question I have about usage. A React dashboard for an audience of one is overscope.

What I Took Away From This

The hard part of this project was not any individual technology. It was holding the whole system in my head at once: how a free-tier quota becomes an attack surface, how an email feature becomes a phishing vector, how a helpful AI becomes a liability the moment you let it take actions. Cheap and secure are not in tension if you design for both from the first commit.

If you are an engineer, the repos are public and the README files document every decision in more depth. If you are a recruiter or hiring manager, the fastest way to evaluate this project is to use it: open the chatbot and ask it something hard about my background. It will answer, it will notice why you are asking, and somewhere on Cloudflare's edge, a rate limiter will quietly confirm you are not a botnet.