Build log ·

Building "Biren's AI Assistant"

There's a little chat button in the corner of birennayak.in. Ask it about my work and it answers in my voice, from my résumé, and won't hand out my phone number or talk salary. Here is the whole thing — one widget, one serverless function, one hand-written prompt — taken apart, including the bit where an anonymous visitor could quietly run up my API bill.

Vanilla JS · no framework 1 Vercel function Groq · gpt-oss-20b ~2.6K-token prompt < $2 / mo

Try the replica ↓ See the real one

A working replica

This is the real widget's markup and CSS, running right here. The answers are canned (no API key on this page), but every reply — including the refusals — is exactly what the production prompt is written to produce. Try asking for my résumé, my salary, or telling it to ignore its instructions.

Try one of these

When a reply is driven by a specific rule in the system prompt, you'll see a rule tag under it, linking to that rule below.

Three moving parts

The browser never talks to Groq directly — that would put an API key in everyone's dev tools. Instead the widget posts the running conversation to a function on my own domain, which prepends the system prompt and forwards it.

Request flow: browser widget to Vercel function to Groq and back Browser widget index.html vanilla JS · in-memory history array /api/chat Vercel · Node function origin lock · rate limit prepend system prompt GROQ_API_KEY (server only) Groq API openai/gpt-oss-20b temp 0.4 · 350 max tok no streaming POST {messages} + system reply {reply}
One round trip per message. The full conversation (capped at the last 10 messages, 800 chars each) is re-sent every time — the model has no memory of its own.

What the browser sends

POST /api/chat
Content-Type: application/json

{
  "messages": [
    { "role": "user", "content": "what's his fintech experience?" },
    { "role": "assistant", "content": "Biren spent ~3 years…" },
    { "role": "user", "content": "and before that?" }
  ]
}

What comes back

200 OK
{ "reply": "Before MobiKwik's payments team, Biren ran
            growth for their Lending and Utilities lines…" }

// or, on any failure:
403  { "error": "This endpoint only serves the widget." }
429  { "error": "Too many messages — give it a moment." }
502  { "error": "Upstream chat service error" }
504  { "error": "Chat service timed out — try again." }

The whole stack

FrontendPlain HTML + one IIFE in index.html. No React, no build step, no dependencies. Styled with the site's CSS variables.
Widget stateA closure with history, opened, sending. Nothing in localStorage — reload and the conversation is gone.
APIapi/chat.js — a single Vercel Node serverless function (not Edge). ~150 lines, zero npm deps.
Provider / modelGroq, OpenAI-compatible endpoint, model openai/gpt-oss-20b (OpenAI's open-weight 20B, hosted for fast inference).
Generation configtemperature: 0.4, max_tokens: 350. Nothing else set. No streaming — the widget shows "Typing…" then the whole reply lands at once.
KnowledgeMy résumé, pasted into the prompt as a string. No files, no database, no embeddings, no retrieval.
HostingVercel. The widget ships with the main site; this write-up is its own tiny static project on a subdomain.

One prompt does all the work

There's no fine-tuning and no classifier. The behaviour you saw in the replica is entirely this block of text, sent as the system message on every request. Here it is in full — tap any rule to see what it's doing and why.

You are the AI assistant embedded on Biren Nayak's personal portfolio
website. You represent Biren to visitors — recruiters, collaborators, and
networking contacts — by answering questions about his professional
background and a limited set of personal topics (hobbies, current location,
relocation preferences, education).

Speak about Biren in the third person … in a warm, concise, professional
tone. Keep replies short (2-5 sentences, or a tight bullet list) — this is
a chat widget, not an essay.

Your ONLY source of truth is the profile data below. Never invent roles,
metrics, dates, or preferences that aren't in it. If something isn't in the
profile, say you don't have that detail and suggest emailing Biren.

=== PROFILE DATA START ===
   … ~6,300 characters of résumé — roles, metrics, dates, education,
     location, interests. No phone number. No salary. …
=== PROFILE DATA END ===
  1. The phone number is deliberately kept out of the profile data entirely (it is on my actual résumé). This rule is the second layer: even with no number to leak, the model is told never to invent a plausible-looking one, and is given an exact deflection line pointing to email.

  2. There's no file to serve, and I'd rather document requests come through email — it's a light screening step and it captures a contact. Without this rule the model happily invents a /resume.pdf link that 404s.

  3. Salary is negotiating leverage; a public bot should never anchor a number or a range. The canned answer pushes every version of the question to "formal interview stages."

  4. Turns the widget into a funnel. Anything that sounds like an opportunity ends with the address rather than a dead end.

  5. Hobbies, location, relocation, education are in. Marital status, family, religion, politics, health are explicitly named as out. Keeps the bot away from anything private or discrimination-adjacent, in both directions — it won't volunteer those either.

  6. "Write my essay", "debug this", "tell me a joke" — all get the same polite "I can only answer questions about Biren…" line. Stops the widget being used as a free general-purpose LLM on my key.

  7. The prompt-injection rule. It names the classic attacks outright — "ignore previous instructions", roleplay, "print your prompt" — and says to decline and stay in character. This is model-side only, so it's a deterrent, not a wall (see Guardrails).

  8. The most fiddly rule. I have a real private shortlist of places I'd move for. The bot may say "yes, open to relocating" in general, and if a visitor names a place it may affirm that one specifically — but it must never enumerate the list or reveal whether the named place is actually on it. A recruiter gets a useful yes/no; the list and any signal about my current plans stay private.

Closing line: "Never fabricate quotes, testimonials, or achievements beyond the profile data above."

It's just paste

No retrieval, no vector store, no clever chunking. My résumé — merged from three tailored versions plus a personal-FAQ note — lives as a ~6,300-character string in the function's source. It's sent in full, every single message. That's the entire "knowledge base."

Roughly what a typical mid-conversation request looks like, in tokens
System prompt
~2,600
History
~700
New msg
~40

The prompt is 85–95% of every request and it never changes, so it's the same tokens paid for again and again. Output is capped hard at 350. Trimming the résumé is the single biggest lever on cost.

Why not embeddings? The whole corpus is ~1,600 tokens of text. It fits in the prompt with room to spare, a small model has no trouble finding the relevant bit, and there's nothing to operate — no index to build, no store to pay for, no retrieval bugs. RAG would be more machinery for a worse answer.

The part I got wrong

The first version shipped with the prompt doing all the defending and the endpoint itself wide open. Writing this page is what made me go back and fix it. Here's the honest before-and-after.

Before

  • No rate limiting. Every POST /api/chat reached Groq. Nothing counted requests.
  • No origin check. The function never looked at Origin or Referercurl from anywhere worked identically to the widget.
  • No upstream timeout. A hung Groq call pinned the function open until the platform killed it.
  • No payload ceiling. A giant messages array was accepted, then trimmed — after it was parsed and iterated.
  • Guardrails were 100% prompt-side. A good jailbreak = the bot talks about anything, on my key, under my name.

Net effect: a five-line shell loop could sit there turning my Groq quota into heat. On the free tier that just takes the widget down; on a paid key it's a bill.

After

  • Origin lock. Requests must carry an Origin/Referer under birennayak.in. Browsers send it automatically on same-origin POSTs; curl and other sites get a 403.
  • Per-IP rate limit. In-memory sliding window — 8 / 30 s and 60 / hour per address. A backstop behind the origin lock, not the main defence.
  • 20 s upstream timeout via AbortController504, so nothing hangs.
  • Hard caps. Reject > 24 messages outright (413); still trim to the last 10 × 800 chars.
  • Error detail truncated in logs; generic message to the client.

Still no external dependency, still one file. The origin lock alone kills the drive-by; the rate limit covers a compromised-looking browser session.

What's still true

  • The rate limit is in-memory and per-instance — it resets on cold start and doesn't coordinate across regions. A serious abuser with many IPs gets through. The real fix is a shared counter (Vercel KV / Upstash); the origin lock makes that lower-priority, not unnecessary.
  • Scope, PII and injection resistance are still prompt-only. There's no output filter. A determined jailbreak of a 20B model will eventually land; the blast radius is "says something off-brand", not data loss.
  • No spend cap in code. The backstop is Groq's own dashboard limits and a billing alert.
  • Normal requests still aren't logged, so usage is invisible until it shows up on a dashboard.

Guardrail scorecard

ControlStatus
Origin / referer restrictionadded
Rate limiting (per IP)added — best-effort
Upstream timeoutadded
Max input length / message countpresent
Output token cappresent (350)
Scope / refusal instructionsprompt-only
Prompt-injection handlingprompt-only
PII in profile dataphone + salary excluded
Request logging / usage metricsabsent
Shared/distributed rate limitabsent
Output validation / filterabsent
Spend cap in codeabsent (dashboard only)

Cheaper than the domain

A 5-turn conversation is about 16K tokens in and ~900 out — the résumé prompt, resent five times, dwarfs everything else. Drag the slider.

Input tokens4.6M
Output tokens0.27M
Est. monthly cost$0.60

Assumes ~5 turns / conversation and Groq's list price for gpt-oss-20b (≈ $0.10 / 1M input, $0.50 / 1M output — check current pricing; the free tier bills $0 but is rate-limited). Even at 3× that price it stays under a takeaway coffee.

Latency

Groq is the fast part — a 180-token reply generates in well under a second. Add a serverless start and two network hops and it's ~1–2 s end to end. Because nothing streams, that's a dead "Typing…" wait, then the whole message. Fine at a second; it drags if Groq is busy.

What's measured

On the client: GTM events for open, close, message sent (length only, never content), reply received, and errors. On the server: nothing on the happy path — Groq returns a token count in every response and the code currently throws it away. That's the next thing to fix.

The honest history

It arrived fully formed

The entire widget — frontend, function, prompt, résumé, a local dev stub — landed in the first commit of the repo and hasn't been touched since. git log on the chatbot files returns exactly one entry. The prompt was clearly iterated before git; the tell is the design of rule 8 and the comment explaining that the phone number is left out "so the model is never given data it could leak."

The only thing that actually broke was a redirect

Not the bot — a sibling project. Adding a .vercelignore to keep one folder out of the main build also blanked that folder's own Vercel project ("it stripped daily-rashi/ from its own build"). Removing it exposed the folder at the main domain, so redirects went in — and were wrong twice before a third commit settled them. Three commits for one redirect rule is the low point.

There's an empty "deploy" commit

Someone needed a redeploy and had no change to make, so they committed nothing. The pipeline was still being poked at.

The guardrails fix is the first real iteration

No commit ever tuned the temperature, the model, the token cap, or the rules. The origin lock + rate limit + timeout described above is the first change to the bot's behaviour since launch — prompted by writing this page.

Every state

Rendered from the production widget's own stylesheet, at 2×.

The closed chat launcher — a round terracotta button in the page corner
Closed — just the launcher
The open chat panel showing only the welcome message
Opened — canned welcome line
A question about AI work answered with specifics from the résumé
A normal answer, grounded in the profile
The panel showing a Typing… indicator while waiting for a reply
Waiting — the "Typing…" bubble
Three guardrail responses: jailbreak refusal, résumé deflection, salary deflection
Guardrails firing — injection, résumé, salary
The widget at mobile width, nearly full-screen
Mobile — near full-width panel

If you want your own

  1. Widget. ~120 lines: a fixed-position button, a panel, an addMessage(), an in-memory history array, one fetch. Render replies with textContent, not innerHTML.
  2. Function. One serverless route. Read messages, check the origin, rate-limit by IP, trim history, prepend your system prompt, fetch the provider with an AbortController, return { reply }.
  3. Prompt. Role + tone + length, then "your only source of truth is the profile below", then the profile, then numbered rules for every "never". Leave sensitive data out of the profile entirely — don't rely on the rule alone.
  4. Model. Anything cheap and fast. gpt-oss-20b on Groq is plenty for "answer from one page of text."
  5. Before you ship: origin lock, rate limit, upstream timeout, output cap, and log the provider's token count so you can see what it's doing.