latentSource

Building AI-Powered Personal Websites

Your portfolio site can do more than display static content. Learn how to integrate AI chat, RAG, and agentic tools into a personal website.

·7 min read
Share
Building AI-Powered Personal Websites

A personal website that can answer questions about you is worth more than one that just lists your resume.

I built this site, latentSource, to be exactly that. The about page tells you who I am. The chat tells you anything else — what I worked on, what I think about a topic, whether I'm available for a project. The chat reads from the same knowledge base that powers the rest of the site, so the answers stay consistent. The whole thing runs in Docker on a cheap VPS, costs me a few dollars a month to run, and gets better as I add more articles.

This post is the architecture writeup. What goes in, what each piece does, what to watch out for. The patterns work for any portfolio, agency, or consultant site that wants to be more than static.

The vision

The site has two jobs. One is the traditional portfolio job: a clean home page, articles, an about section, a way to contact me. The other is the AI representative job: a chat that knows my work and can hold a real conversation about it. The two jobs share a knowledge base. They share a brand. They share a deployment.

The chat handles a few patterns:

  • "What did you work on at company X?" — pulls from the resume.
  • "How did you build the search feature?" — pulls from blog posts.
  • "Can you summarize your experience with Postgres?" — pulls from articles and resume together.
  • "How can I reach you?" — triggers a contact tool.

The chat is the differentiating feature. The static content is the substance. Together they make the site useful in two different modes.

Architecture

The stack is intentionally boring.

Frontend — Next.js 16 with the App Router, React 19, Tailwind CSS 4. Server components by default. Server-rendered for SEO, hydrated client-side for interactivity. The chat is a client component because streaming responses need real-time updates.

Backend — FastAPI in Python. One process. Handles all the API routes the frontend needs: articles, search, chat, file storage, admin. Lives behind the frontend, never exposed publicly. The frontend proxies API calls through Next.js rewrites.

Database — PostgreSQL with three extensions: pgvector (semantic search), pg_trgm (fuzzy matching), unaccent (accent-insensitive search). One database. One connection pool. Source of truth for everything.

Cache — Redis for session state, rate limiting, and short-lived chat context. Optional but cheap.

LLM — Gemini 2.5 Flash by default for chat and generation. Gemini's embedding model for vectors. The model choice doesn't really matter; what matters is keeping the integration thin so you can swap providers when prices change.

Reverse proxy — Traefik in front, handling TLS via Let's Encrypt. The frontend container has Traefik labels and is the only one exposed.

Deployment — Docker Compose on a single VPS. Three services: frontend, backend, postgres (and redis if you want sessions). One docker-compose.yml, one .env file, one deploy command.

That's the whole architecture. Nothing exotic. Nothing that costs money to run idle. In one picture:

RAG integration

The knowledge base is the core. Without good retrieval, the chat is just a generic LLM with my name on it.

Three sources of content end up in the knowledge base:

Resume — parsed once, chunked by section (work history, education, skills), embedded, stored.

Articles — every blog post on the site is automatically embedded when it's published. The same content the public reads is what the chat retrieves from.

GitHub repositories — synced via the GitHub API. README files, project descriptions, recent activity. Refreshed daily via a cron job.

The retrieval flow is straightforward. The user asks a question. The backend embeds the question with Gemini's embedding model. It runs a hybrid search against the knowledge base — pgvector cosine similarity for semantic matching, pg_trgm for keyword fallback. The top 5 chunks become context for the LLM. The LLM generates an answer grounded in those chunks. The answer streams back to the user.

The trick is keeping the knowledge base in sync with the rest of the site. Every time I publish an article, a hook re-embeds it. Every time I update my resume, a hook re-embeds it. The chat is always current with what the site claims about me.

Agentic capabilities

The chat isn't just retrieve-and-answer. It has tools.

Schedule a meeting — when the conversation indicates someone wants to talk, the model calls a tool that drafts a calendar invite and sends it via Gmail. The user gets a confirmation in the chat and an email a few seconds later.

Send a verification code — for any tool that takes user contact details, the model first sends a verification code to the email or phone, then waits for the user to confirm. Cuts down on spam tools by a lot.

Send a conversation summary — at the end of a substantive conversation, the user can ask for a summary by email. The model formats the conversation, the tool sends it.

Generate a custom resume — given a job description, the model produces a tailored resume highlighting the most relevant experience. Useful when someone is asking about a specific role.

Each tool is a Python function with a Pydantic argument model. The LLM picks tools through structured output (the same pattern as a ReAct loop). The agent runs in a loop: think, act, observe, repeat, until it's done.

The chat interface

The front end of the chat needed three things to feel right:

Streaming. Tokens appear as they're generated. Without this, the chat feels broken. With it, even slow models feel responsive.

Tool indicators. When the model calls a tool, the UI shows a small pill — "Searching knowledge base", "Sending email" — so the user knows something is happening. Without indicators, tool calls feel like silent stalls.

Session isolation. Each browser session gets its own X-Chat-Session header. Backend rate limits and conversation context are scoped to that session. Caps it at 5 concurrent sessions, 30 minutes idle. Prevents one user from blowing up the chat for everyone else.

The implementation is a single React component, ChatPanel.tsx, plus a backend route at /api/chat that uses server-sent events for streaming. About 300 lines of frontend code. Maybe 200 lines of backend.

Knowledge base management

The piece that takes the most ongoing work is keeping the knowledge base accurate. A few things that helped:

Source of truth in the database, not files. Disk files are for seeding and human editing. Once it's in the database, the database wins. This avoids the "but the file says X" / "but the database says Y" confusion.

A dedicated admin tab. The site has an admin UI for managing prompts, viewing conversations, and triaging knowledge base entries. Costs a couple of days to build. Saves hundreds of hours over the life of the site.

Approval flow for new content. The seeder picks up new files from disk, but they get added with status: pending until I approve them. Stops accidental publication of half-written articles.

GitHub sync as a cron job. Pull README files daily. Don't pull more often. Every API call costs something even when free, and stale GitHub data isn't a real problem.

Analytics and observability

A few signals that ended up being useful:

Conversation logging. Every chat is stored. Searchable, exportable. Tells you what people are actually asking, which is mostly different from what you assumed they would ask.

Per-tool metrics. Prometheus counters and histograms for every tool. Token usage, latency percentiles, error rates. Surfaces the slow tools and the failing tools without me having to read logs.

Sentry on both frontend and backend. Catches real exceptions. Cheap to wire up. Don't run a serious production app without an error tracker.

Umami for traffic. Page views, top articles, referrers. Privacy-friendly, self-hosted, no cookies. Skip if you don't care, but I do.

SEO and content

The same content that powers the chat also needs to be discoverable from search engines. A few things that mattered:

Server-side rendering for articles. Every article page is fully rendered on the server, so crawlers see the content. Important.

Structured data. JSON-LD BlogPosting and BreadcrumbList schemas on every article. Improves how articles show up in Google results.

Dynamic OG images. Each article gets a generated social card with the title, summary, and site branding. Pillow composites them on demand. Boosts click-through dramatically when articles get shared.

Sitemap and robots.txt. Boring, necessary, easy to forget.

AI-assisted article generation with human editing. I draft articles with a Gemini-backed editor, then rewrite in my own voice. Speed without losing authorship.

GEO: making the site legible to AI

Search engines aren't the only crawlers that matter anymore. Answer engines — ChatGPT, Claude, Perplexity, Gemini — and the agents people wire into their editors read the web too, and they read it differently. They want the content, not the chrome. So alongside classic SEO I did a second pass for what people are calling GEO, generative engine optimization, or just agent-readiness.

Content signals in robots.txt. Beyond the usual allow and disallow, the file emits Content-Signal: search=yes, ai-input=yes, ai-train=no. Index me, cite me in an answer, don't train on me. Whether every crawler honors it is out of my hands, but an explicit preference beats silence.

Markdown on request. Every article URL returns raw markdown when you ask for it with Accept: text/markdown instead of the rendered HTML. That was nearly free — the articles already live as markdown in Postgres, so the HTML you're reading is the render, not the source. An agent skips the nav, the sidebar, the social tags, and gets the article in a fraction of the tokens. The response even carries an X-Markdown-Tokens header so it can decide whether the piece fits its context window before fetching it.

An llms.txt at the root. A curated markdown map of the site, generated from the live article list so it can't go stale, pointing agents at the content and advertising the markdown negotiation above.

A real entity graph. JSON-LD Organization and Person nodes with sameAs and knowsAbout, and every article's BlogPosting links back to both by @id. That's how an answer engine knows who wrote this and what they claim to know, rather than guessing from the page.

I checked the whole thing against Cloudflare's isitagentready.com, which scans a site across discoverability, content accessibility, bot access, protocol discovery, and commerce. The reading and discovery boxes tick; the commerce standards (x402 and friends) are meaningless for a blog, so I left them alone. One gotcha earned its own scar: the checker flagged robots.txt directives my server never wrote. Cloudflare sits in front of this domain, and on new zones it blocks AI crawlers by default — serving agents a modified robots.txt with Disallow: / lines the origin never sent. What your server emits is not what agents see. Audit through the edge or you're auditing fiction. I go deeper on all of this, plus turning the blog into an MCP server, in its own article.

Lessons from running it

A few things I'd tell myself starting over:

Build the database schema first. Everything else flows from that. If your articles, knowledge base, prompts, and conversations all live in one well-designed schema, the rest of the system is mostly plumbing.

Externalize prompts to files, not code. A prompt that's hardcoded as a Python string is a prompt you can't iterate on without a deploy. A prompt that's a Markdown file is a prompt you can edit in two seconds.

The chat will be slow if you don't think about it. Use streaming. Use prefix caching. Pick a fast model. Don't make the user wait five seconds before the first token.

Costs add up. The free tier of any LLM provider is generous, but if your site gets traffic, you'll outgrow it. Track per-conversation cost from day one and have a switch to downgrade to a smaller model if costs go off plan.

You don't need feature flags or A/B testing. This is a personal site. If something is broken, fix it. Don't build infrastructure for problems you don't have.

The end result is a site that does more than a static portfolio. It answers questions. It schedules meetings. It learns about you over time. And it costs a few dollars a month to run. That's the deal.