Why I Added a Chatbot to My Own Portfolio
A portfolio is usually a static wall of text — projects, skills, a contact form. I wanted mine to answer questions instead of just displaying information, so I built a chatbot widget that sits in the corner of every page and can talk about my experience, projects, and services on demand. This post is a real walkthrough of how it works: no vector database, no RAG pipeline, no backend server — just a well-designed system prompt, the Groq API, and a purpose-built markdown renderer, all running client-side in a Next.js component.
Why Not a Full RAG Pipeline?
The obvious "correct" architecture for a knowledge-grounded chatbot is Retrieval-Augmented Generation: embed your documents, store them in a vector database, retrieve relevant chunks per query, and pass them to the LLM as context. That's the right call when you have a large, evolving knowledge base.
For a personal portfolio, it's overkill. Everything the bot needs to know — my experience, my projects, my services, my contact info — fits comfortably inside a single, well-organized system prompt. No retrieval step needed; the entire "knowledge base" travels with every request. This keeps the whole feature client-side with zero backend infrastructure to maintain.
Choosing Groq and a Fallback Model
I picked Groq for inference because of its speed — responses stream back fast enough that the chat feels conversational rather than laggy, which matters a lot for a widget that's supposed to feel like a live assistant, not a form submission.
Since this is a client-side call (the API key is exposed to the browser bundle, scoped specifically for this use case), reliability matters, so I built in a fallback: if the primary model call fails for any reason, the widget silently retries with a second model before giving up.
const PRIMARY_MODEL = 'openai/gpt-oss-20b';
const FALLBACK_MODEL = 'qwen/qwen3-27b';
async function callGroqAPI(messages: Message[]): Promise<string> {
const payload = {
messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...messages],
max_tokens: 420,
temperature: 0.65,
};
const request = async (model: string) => {
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${GROQ_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...payload, model }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.choices[0]?.message?.content || 'Sorry, I could not generate a response.';
};
try {
return await request(PRIMARY_MODEL);
} catch {
return await request(FALLBACK_MODEL);
}
}
If both models fail, the UI still degrades gracefully — the widget shows a message pointing the visitor directly to WhatsApp or email instead of a broken chat.
Designing the System Prompt
The system prompt is really the entire "brain" of this chatbot. Since there's no retrieval step, everything the bot can talk about has to live here, structured so the model can pull the right facts without rambling. A few things I learned tuning it:
Force brevity, explicitly. LLMs default to long, hedge-everything answers unless told not to. My prompt is blunt about it:
## RESPONSE FORMAT RULES (CRITICAL)
- Keep responses SHORT and PUNCHY — max 3-4 sentences OR a short bullet list.
- Use **bold** for important terms, names, or numbers.
- Use bullet points (- item) for lists of services, skills, or features.
- NEVER use headers (##) in your responses — they're for the system prompt only.
- End with a clear next step or CTA when relevant.
Structure the facts as scannable sections, not prose — a markdown-like outline of experience, projects, tech stack, and rules the model must follow (e.g., "never invent technical details," "redirect pricing questions to WhatsApp"). Treating the system prompt like a structured knowledge document rather than a personality essay made responses noticeably more consistent.
Set explicit guardrails. Since there's no retrieval step to ground answers in verified data, the prompt has to compensate with hard rules: don't invent details, admit uncertainty, don't recommend competitors, always leave a path to real contact info.
Building a Lightweight Markdown Renderer (No Library)
The model's replies use light markdown — bold text, inline code, links, bullet lists — and I didn't want to pull in a full markdown library for a widget this small. So I wrote a small parser that handles just the subset I actually need:
type SegmentType =
| { type: 'text'; value: string }
| { type: 'bold'; value: string }
| { type: 'code'; value: string }
| { type: 'link'; label: string; href: string };
function parseInline(text: string): SegmentType[] {
const segments: SegmentType[] = [];
const re = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) segments.push({ type: 'text', value: text.slice(last, m.index) });
if (m[1] !== undefined) segments.push({ type: 'bold', value: m[1] });
else if (m[2] !== undefined) segments.push({ type: 'code', value: m[2] });
else if (m[3] !== undefined) segments.push({ type: 'link', label: m[3], href: m[4] });
last = re.lastIndex;
}
if (last < text.length) segments.push({ type: 'text', value: text.slice(last) });
return segments;
}
A second pass groups raw lines into bullet-list blocks vs. prose paragraphs, since the model sometimes mixes both in one reply:
interface Block { type: 'bullet' | 'prose'; lines: string[] }
for (const raw of rawLines) {
const line = raw.trimEnd();
const isBullet = /^[-•*]\s+/.test(line);
const isNumbered = /^\d+\.\s+/.test(line);
// group consecutive bullet/numbered lines into one block,
// everything else into prose blocks
}
This single regex-based approach covers everything the system prompt actually asks the model to produce — bold, inline code, links, and lists — without shipping a general-purpose markdown parser for a feature that never needs one.
Handling Failure Gracefully
Client-side API calls fail more often than server-side ones — flaky connections, rate limits, ad blockers. Rather than let the chat silently break, a failed request (after both models are exhausted) falls back to a message with direct links:
catch {
setMessages((prev) => [
...prev,
{
role: 'assistant',
content:
"I'm having trouble connecting. Please reach out via [WhatsApp](https://wa.me/918929394994) or [email](mailto:rishabnishad22@gmail.com) directly.",
},
]);
}
The chatbot's whole job is to help a visitor reach me one way or another — if the AI layer fails, the fallback still accomplishes that.
What I'd Do Differently at Larger Scale
This approach works well because the knowledge base is small and mostly static. If I were building this for a business with a large, frequently changing set of documents (product catalogs, long FAQs, policy documents), I'd reach for real retrieval — chunking documents, embedding them, and pulling only the relevant pieces into context per query — rather than stuffing everything into one prompt. For a single person's portfolio, though, the simpler approach is faster to build, cheaper to run, and easier to keep accurate.
Want Something Similar for Your Site?
If you're thinking about adding an AI chat widget to your own site or product — whether it's a simple prompt-based assistant like this one or something that needs real retrieval — I'd be glad to talk through the right approach for your case. Reach me at rishabnishad22@gmail.com or on WhatsApp, or explore more at rishab-nishad.vercel.app.