Back to home

How it's built

This page describes what is actually in this site's code. Each section ends with the files behind it, so you can check what I say.

When the chat assistant is live, visitor questions and the resume text retrieved to answer them are sent to OpenAI, which creates the search embeddings and writes the answers.

01

What this site is and what it is not

This is my portfolio. It has a home page, a page about the Enterprise Knowledge Assistant, this page, a page on trust and limits, a read-only MCP page, and a chat that answers questions about my work. The chat reads only my resume, including its project descriptions.

It is not a general chatbot. There are no accounts, no comments, no content system and no stored conversations.

Files behind this section

02

Stack

  • Next.js (App Router) with TypeScript, built to run on Vercel.
  • Postgres with pgvector on Neon, for retrieval.
  • Upstash Redis, for rate limits.
  • OpenAI for both the embeddings and the answers. Each sits behind one small file, so a provider can be swapped without touching the rest.
  • Vitest for tests, plus a separate eval run for the chat.
  • Few dependencies on purpose. The runtime packages are Next.js, React and Neon's database client.

Files behind this section

03

How the chat answers a question

Every question goes through the same steps, in this order. If a step from the input check onward fails, the visitor gets one fixed refusal message and no sources. Rate limits, malformed requests and outages show a short error message instead.

  1. Rate limit. A limit per visitor per minute and per day, and a daily limit for the whole site. If the rate-limit service cannot be reached, the chat shows an unavailable message instead of running unchecked.
  2. Input check. One field, up to 500 characters, no hidden characters, and no questions that try to script the answer.
  3. Retrieval. The question is turned into an embedding, and the closest chunks of my resume are read from the database.
  4. Scope check. If even the closest chunk is not similar enough, the question is refused without calling the model.
  5. Answer. The model sees only those chunks, and must return an answer plus the ids of the chunks it used.
  6. Citation check. The server checks that every cited id is real and was one of the retrieved chunks.
  7. Output checks. The answer must not add numbers or capitalized names that the cited text does not contain, must not link anywhere except my own profiles, and must not contain private words.

The visitor gets the answer and the names of the sections it came from, never the raw retrieved text.

Every answer and every refusal also carries a request trace: the result and timing of each step. For an answered question it lists the retrieved section names with similarity scores. A refusal shows which step stopped, with every check after the model counted as one step, and no scores.

Files behind this section

04

Security choices

  • Read-only database role. The chat connects as a role that can only read the chunks table, so even a tricked model cannot change or delete anything.
  • A strict Content-Security-Policy with a new nonce on every request, so only this site's own scripts and styles run. The usual headers are set too: no framing, no content-type guessing, HTTPS only.
  • Secrets live only in server-side environment variables. The example file lists names only, and the build fails if a secret value shows up in the built output.
  • Input limits and rate limits on the chat route. Visitor addresses are hashed before they are used as rate-limit keys.
  • No stored conversations. By default only the event, counts, timing and error reasons are logged, not the question.
  • The system prompt is not treated as the security boundary. The limits that matter are the corpus, the read-only role, the citation check, the output checks and the rate limits.
  • A dependency audit runs as part of the full check before each commit.

Files behind this section

05

Evals

I wrote the eval sets before the chat. The eval command runs every case against the real chat and fails on any unsafe result or any answer it should have given but did not.

Chat eval results, last full run on 19 October 2026
SetWhat it testsPassed
GoldenReal questions about my resume. Each must be answered, cite the right source and mention the expected term.20 of 20
ScopeOff-topic questions and questions the chat must not answer. All must be refused.10 of 10
InjectionAttempts to print the prompt, change the rules or pull out private details. All must be refused.10 of 10
ExtraMixed questions, yes or no probes, encoded text, planted false claims and wording checks.31 of 31

Chat eval results, last full run on 19 October 2026. The sets are small and I wrote them. A pass means the cases I thought of pass, not that nothing can go wrong.

Files behind this section

06

How I used Claude Code

  • Claude Code wrote most of the code and the tests.
  • I set the rules and the spec, supplied and approved every fact about me, and decided what stays private.
  • Changes to the locked spec were proposed first and recorded as decisions after I approved them.
  • Each change had to pass lint, type checks, tests, the build, the security header check and a dependency audit before it was committed.
  • Two review helpers, one for facts and one for security, were run before each phase was closed, and their findings were fixed.
  • I checked the pages and the chat by hand in a browser.

Files behind this section

07

What is simple on purpose

  • No accounts, comments or content system, so there is little to attack.
  • One language-model provider and one embedding provider, each behind one small file.
  • No separate vector database. pgvector lives in the same Postgres as everything else.
  • Expand and collapse use the browser's own details element. The skills filter is a small script, and the page still works without it.
  • No animation libraries, carousels or tabs.

Files behind this section

08

Known limits

  • A language model can still get things wrong. The checks catch invented sources, numbers, capitalized names and links, but they cannot prove that every sentence is a fair summary of its source.
  • Model answers are not perfectly repeatable. One golden question was wrongly refused in one run and passed on the rerun.
  • The scope check compares meaning, so an off-topic question that happens to sit close to my resume text can still reach the model. The model's own refusal and the output checks handle those.
  • Rate limits can turn away a real visitor who asks many questions.
  • The chat depends on three outside services: OpenAI, Neon and Upstash. If one is down, it shows an error message instead of answering.
  • There are no automated browser tests. I checked the pages and the chat by hand.
  • The chat answers about my work only. It does not know how this site is built. This page is for that.

Files behind this section

09

Decisions and what I rejected

Each row comes from a decision record in the repository. Where a record names no alternative, the table says so instead of guessing.

Design decisions, what was chosen, what was not, and the record for each
DecisionWhat I choseWhat I did not choose and whyRecord
Label for a project with no stated outcomeCall the fourth project item "Quality" when the resume gives no outcome. Use "Result" only when the resume states one.A "Result" label on every project. It would suggest more than the resume supports.ADR 001
StackNext.js with TypeScript on Vercel, Postgres with pgvector on Neon, Upstash Redis for rate limits, two provider interfaces, and Vitest.Supabase. Only plain Postgres is needed, not its extra services.ADR 002
What the dependency audit blocksVerify fails on high or critical advisories in packages that ship to production. Dev-only advisories are checked by hand each phase.Downgrading eslint-config-next to 14.x, the fix npm suggested. It does not match Next 16, and the flagged package is used only by the linter.ADR 003
Site structureA scrolling home page, a project page and this page. Expand and collapse use native details and summary.Tabs, carousels, floating widgets and headline counters. The record rules them out. Notes are a fixed set of short pieces, not a blog.ADR 004
Fictional example profileKeep a fictional comparison profile only on my computer, git-ignored, with build guards that fail if it is present.No alternative recordedADR 005
Which evals to run and what they expectThe three sets from the spec, plus an extra safety set and a private set kept out of git. Every refusal uses one fixed message.Answering only the public half of a mixed question. It can still hint at the other half, so the whole question is refused.ADR 006
Model providerOpenAI for chat and embeddings, one project with a monthly spending limit, behind lib/embed.ts and lib/llm.ts.A provider SDK. Requests use plain fetch against the documented endpoints, so there is no SDK dependency.ADR 007
How the chat checks scopeReuse the retrieval embedding for the scope check, after a cheap input check that makes no model call.A separate scope check before retrieval. It needs its own model call per question, doubles cost and latency, and adds a second prompt to attack.ADR 008
Content-Security-PolicyA fresh nonce for every page request, so every page renders per request instead of being prerendered.Allowing only the theme script by hash, which would break the other inline scripts Next.js adds. Also "unsafe-inline" for scripts, which is ruled out.ADR 009
Vercel's NEXT_PUBLIC_ variables in the build checkAllow names starting NEXT_PUBLIC_VERCEL_, only on a Vercel build. Every other NEXT_PUBLIC_ name still fails the build.Switching off Vercel's system variables setting. It also removed VERCEL_ENV, which silently turned off the secrets check.ADR 010
Public request trace and visual layerA trace on /api/chat showing six steps with timings and retrieved section headings. Motion is CSS only, with no new dependencies.No alternative recordedADR 011
Streaming the chat stepsJSON stays the default. With Accept: text/event-stream, the route streams typed events, and the system prompt is a tested dependency.No alternative recordedADR 012
MCP serverA stateless, read-only server at /api/mcp with three tools. JSON-RPC is handled by hand, with no new dependencies.A separate rate cap, because the chat limiter already bounds it. Sessions and event streams are not built.ADR 013
Trust page, "Try to break it" panel and hero tilesA trust page that links each control to its file, a panel that uses only the public injection set, and tiles whose values are computed.Hero tiles for a passed count and a CSP, which were dropped as unverifiable or unclear.ADR 014
Home page densityPut the skills explorer in a closed native details element, trim padding on the home page only, and make Focus cards compact.A sticky in-page section indicator.ADR 015
Where eval results on this page come fromnpm run eval writes a committed summary file. The table's pass counts and date are read from it.Typing the pass counts and date by hand, which can drift from the real latest run.ADR 016
Chat unavailable banner (proposed)On a 503 or a failed request, show a status banner with an email link and pause Ask for 5 seconds.No alternative recordedADR 017

Files behind this section