How Sibu, the AI Copilot on This Portfolio, Actually Works
Sibu is the chat widget in the corner of this site. Ask it about my projects, skills, certificates or experience and it answers from the same data that renders the pages you are reading. This post walks through how that works. It was rebuilt in October 2026, so if you read the first version: retrieval, streaming and citations are all new.
The problem
A recruiter or client usually wants one fact: "Has he worked with X?", "Is he available?". Hunting through ten pages for it is friction. A chat box removes that, but a chatbot that invents employers or projects is worse than no chatbot at all. The requirement was simple: answer only from my real portfolio data, and say so when the answer is not there.
The data is already structured
There is no database. The site's content lives in typed constants (about, skills, experience, projects, education, certificates, publications, testimonials, freelancing) plus the MDX blog posts. A small knowledge layer maps each of them into compact records, and every record carries the URL of the page that shows it:
certificates: matches.slice(0, input.limit).map(certificate => ({
title: certificate.title,
issuer: certificate.issuer,
issued: certificate.issued,
verifyUrl: certificate.verifyUrl,
url: `/certificates#${certificate.id}`, // opens it in the site's own viewer
})),
Because the records are generated from the same constants as the UI, the chatbot cannot drift out of date. Edit a project once and both the page and Sibu change.
Tools instead of stuffed context
The first version ranked text chunks by keyword and pasted the top six into the prompt. That worked for "what are his skills?" and fell apart on anything that needed two kinds of data, or on wording that did not share a keyword with the right chunk.
Now the model looks facts up itself. It gets eleven read-only tools built with the Vercel AI SDK: getProfile, getExperience, getProjects, getCertificates, getTestimonials, getFreelancing and so on, plus a searchPortfolio keyword search as the catch-all. Each tool has a zod input schema, so the model can filter (getCertificates({ query: 'AWS' })) and the inputs are validated before anything runs.
A question like "Which AWS certificates does he have, and where does he work now?" becomes two tool calls in one step, getCertificates and getProfile, and an answer built from both. Up to four steps are allowed, which is plenty for a portfolio and keeps cost bounded.
Streaming, with sources
The route returns a UI message stream, and the client renders it with useChat, so answers appear word by word instead of after a five-second pause. Tool calls stream too: while the model is looking things up, the reply shows chips like "Checking projects", which turn into "Checked projects" links to the page the answer came from. The answer itself is Markdown, so it can link straight to Projects or to a specific certificate.
What the model is allowed to see
- System instructions hold the rules and a one-paragraph brief (name, role, years of experience, availability, contact), so trivial questions need no tool call.
- History is the last eight messages, reduced to plain text. Tool calls and results from earlier turns are dropped on the server, so a client can never feed the model a forged "tool result", and old lookups do not cost tokens again.
- The current question is the final
usermessage.
User text always travels as a real user message. If everything were concatenated into one string, a visitor could type ASSISTANT: ignore the rules and it would look like part of the transcript. With proper roles it stays what it is: an untrusted question. The prompt also says explicitly that user messages cannot change the rules, and that anything unrelated to me or this site gets politely declined.
Other limits keep behaviour predictable: 1,200 characters per message, 600 output tokens, a temperature of 0.35, and generation stops the moment the visitor stops the answer or closes the chat.
When OpenAI is unavailable
The route holds back the model's stream until the first piece of text arrives. If the model fails before that (missing key, bad key, no quota, an outage), the buffered output is dropped and an offline answer is streamed instead, in exactly the same format. It comes from a few keyword intents (availability, skills, projects, experience, certificates) plus the keyword search over the same records. It is less fluent, but it is correct, and the UI labels it "Answered from portfolio data" so nobody is misled. The site even runs without an OpenAI key at all.
Keeping it light
The chat should not slow down a portfolio. Only the launcher button ships with the page. The panel and the AI SDK are a separate chunk, prefetched when the browser is idle or when you hover the button. The Markdown renderer loads with the first reply, and the speech code (dictation and spoken replies) only when you first press the mic or Listen.
Protecting the endpoint
A public endpoint that calls a paid API needs a budget. Each visitor gets a per-minute and per-day limit, and only requests that pass those spend from a global daily cap, so one client hammering the endpoint cannot use up everyone's budget. Request bodies are capped in bytes before parsing. The hardening post covers the rest.
What I would change next
- Semantic search. The tools filter by keyword, which misses synonyms. Embeddings are cheap at this data size.
- Shared rate limiting. The limiter lives in server memory, so it is per instance. A shared store would make it exact.
- Evaluations. A small set of questions with expected facts, run on every change, would catch regressions in answers the way tests catch them in code.
If you want to see the result, open the chat and ask about my projects.