← back to the site

/// Working note

How the chatbot drives this page

The assistant on this site doesn't just answer — it scrolls the page, splits the layout, and pins callouts onto the exact entries that answer you. That's five tool definitions and about four hundred lines. The interesting part isn't the feature; it's the three ways it can fail, and what stops each one.

01

A hallucinated ID is the whole ballgame

The model doesn't manipulate the DOM. It names things: experience:ml-engineer-acme. Something has to turn that name into a scroll position and a highlight.

Models invent plausible identifiers. Ask about Kubernetes and you'll get experience:google — confident, well-formed, and pointing at nothing. The callout attaches to empty space and the feature looks broken rather than wrong.

So tools are constructed per request, closed over the content that actually exists. Validation happens before anything reaches the browser:

const accepted = items.filter((i) => known.has(i.itemId));

if (!accepted.length) {
  return {
    ok: false,
    error: `None of those ids exist: ${rejected.join(", ")}.
            Valid ids are: ${[...known.keys()].join(", ")}`,
  };
}

The error hands back the valid vocabulary. That matters more than the rejection: a bare failure gets retried identically, while a failure carrying the answer gets retried correctly. A mixed batch keeps the real IDs and drops the invented ones, so one bad guess doesn't cost the whole response.

02

The model cannot send email — by construction

This site has a contact form the assistant can fill in. The obvious implementation is a sendEmail tool, and it's a mistake.

Anything a visitor types reaches the model. Prompt injection against a bot that can only talk is embarrassing. Against a bot that can send mail, it's an open relay pointed at your own inbox — and no amount of instruction-hardening turns that into a guarantee, because instructions are exactly what an injection attacks.

So the capability doesn't exist. The model can call draftContactMessage, which renders an editable card. Sending is an ordinary HTTP request the browser makes after a human clicks Send. There is no tool to abuse, so the guarantee doesn't depend on the prompt holding.

The general form: when a capability is dangerous, removing it beats defending it. A test asserts no tool matching /send|email|mail/ exists, so re-introducing one fails CI.

03

Agency without motion sickness

A model that moves the page on every turn is exhausting. One that never moves it is pointless. The line between them is sequencing.

Navigations are queued, never dropped, with a 1.2-second floor between them. Dropping one produces an answer referencing something the page never moved to — worse than a slow tour. Highlights cap at three and replace rather than accumulate, so three questions don't leave the page covered in stale callouts.

Every action shows as a pill in the transcript — ↳ focused Experience · highlighted 2 roles — so the agency reads as deliberate rather than as the page having a mind of its own.

Accessibility isn't a coat of paint here. Scrolling a viewport leaves a keyboard user exactly where they were, so navigation moves real focus to the section heading and announces through an aria-live region. prefers-reduced-motion swaps smooth scroll for instant jumps.

04

Why there's no vector database

A portfolio is a few kilobytes. Serialised whole — every role, project and skill, plus live GitHub and LeetCode figures — this one is about 1,700 tokens. Retrieval over that is strictly worse: an embedding call on the request path, a similarity threshold to tune, and a new failure mode where the right chunk doesn't come back.

So it all goes in the prompt. The seam is still there — ContextProvider has one implementation today and a pgvector one is a one-file swap — but building it now would be complexity bought against a problem this site doesn't have.

Knowing when RAG isn't the answer is part of knowing how to build it.

05

Testing an LLM feature without an LLM

Every property above is verified without an API key. Tools are pure functions of content and arguments, so the tests hand them fabricated IDs and assert the rejection, hand them a mixed batch and assert the partial accept, and assert no send-shaped tool exists.

133 checks run in about two seconds, covering the tool layer, theme-token parity, contrast in both modes, structured data, and Google Drive link conversion. What they can't test is whether the model chooses to call the right tool — that's a judgment, and judgments need evals rather than assertions.