/// Working note
How the chatbot drives this page
The assistant on this site doesn't just answer — it scrolls the page, pins callouts onto the entries that answer you, walks you through the whole thing on request, searches the web when the question needs it, and typesets a résumé for a job description you paste in. Fifteen tools, and about twenty thousand lines around them. The interesting part isn't any of the features; it's what each one could do wrong, and what stops it.
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 the fifteen 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.
The right handling differs by tool, and that's a judgment rather than a rule. A highlight with no valid target is worth rejecting so the model can try again. A seven-stop tour with one bad callout is worth keeping — the walk is worth more than the callout, and the stop still lands on the right section.
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, and since it can search the web, so does anything on a page it reads. 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.
That floor has teeth. A stop that focused a section, pinned a callout and then scrolled to the chart inside it spent two full cooldowns arriving — so landing on something within a section became an argument to the scroll rather than a second call, and a step with nothing to pin skips the queue entirely. A queued no-op still costs 1.2 seconds.
Every action shows as a pill in the transcript — ↳ focused Experience · highlighted 2 roles — and each is clickable, because the record of an action is also the way back to it.
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
The tour: planning is the model's job, pacing is yours
Ask to be shown around and the assistant walks you through Experience, Education, Skills, Projects, a summary, the timeline chart, and how to get in touch.
The first version ran the whole thing inside one reply — seven sections scrolled past in a couple of seconds, each highlight replaced by the next before anyone could read it. Correct, and useless. The fix wasn't slower scrolling; it was giving the wheel to the reader.
So the route arrives in one tool call and the browser owns everything after that. Previous and Next name where they go rather than saying "next". Auto-play runs by default with a visible countdown, and stops the instant a button is pressed. A round trip to the model per stop would have made every press cost a wait, with the answer arriving somewhere below the fold.
It is also the most expensive reply the site produces — seven stops of narration in a single tool call, well past the 500-token ceiling that suits ordinary prose. It gets its own ceiling, and then gets cached, because the prompt fixes the route: the reply genuinely doesn't depend on the conversation. That property is the entire reason it's safe to cache, and the reason nothing else is.
05
A résumé written for one job, in real LaTeX
Paste a job description and you get a PDF typeset from the same template the printed CV uses. The pipeline is five stages, and the interesting decision is where the model is allowed to touch.
job description → structured extraction (typed object, never LaTeX) → render (owns every backslash) → pdflatex, twice (page count settles on pass two) → deterministic ATS audit (read the text layer back) → the model confirms fidelity
The model never writes markup. One unbalanced brace from a language model is an unrecoverable compile failure, and model-authored LaTeX can reach \input and \write18. So it fills in a typed object and the renderer owns escaping. Even emphasis is data: the model names the phrase it wants bolded, and a phrase that isn't a verbatim substring is dropped rather than guessed at.
Then the PDF is read back and compared to the object it came from — because a résumé can compile perfectly and still be broken for its only real reader, a parser reading the text layer. Those failures are silent: a bullet that didn't make it, a date format that parses badly, an escape printed literally. Deterministic checks run first, since they're cheap and never disagree with themselves. The model's judgment is reserved for what can't be checked mechanically.
One finding from doing this: \scshape made section headings extract as S UMMARY. It looked perfect and parsed wrong, which is exactly the class of bug the audit exists for.
06
Retrieval, with the summaries always in the prompt
A portfolio is small. Serialised whole — every role, project and skill, plus live GitHub and LeetCode figures — it's a couple of thousand tokens, so the summaries go in the prompt unconditionally.
The bodies don't fit, and that's what pgvector is for: chunks are embedded on save and the question retrieves the handful that matter. The layering is the point. Retrieval adds depth rather than being the only path to the content, so a missing extension, a cold cache or an embedding outage costs detail rather than producing an assistant that has forgotten who it works for.
Knowing which half of your content needs RAG is more useful than knowing how to build it.
07
Web search, and treating results as hostile
Ask what dbt is, or what a company in the history builds, and the assistant searches rather than guessing. Two guardrails matter more than the integration.
First: it will not search for the subject of this site. Results for a common name are other people, and a public answer can't tell them apart — everything about him is already in context, so the tool refuses before it checks whether search is even configured.
Second: retrieved text is fenced as untrusted. A search result is a page a stranger wrote, and it lands in the same context window as the instructions. The visitor gets the citations, the model gets the content, and the budget is per-IP so one visitor can't drain a metered quota.
08
The admin is Postgres policies, not a login page
Every table on this site is editable from a private dashboard behind Supabase auth, gated on an allow-list of email addresses. But the login isn't what protects the data — row-level security is, and the valuable part of it is negative space.
With RLS on, absence of a policy means denial. Published rows are world-readable; unpublished drafts aren't. And a few tables have no visitor policy at all — the job descriptions behind saved résumés, which reveal where he's applying, sit in a table that simply has no read policy beside the résumés it's about.
That absence is invisible to anyone reading the schema quickly, and one well-meaning "add a read policy to everything" loop would undo it silently. So the schema is applied to a real Postgres in CI and the missing policies are asserted as tests. The negative properties are the ones worth testing.
09
Sending a conversation on
A recruiter who works out that someone fits usually has to convince a hiring manager next, and until recently the transcript died with the tab.
Share stores it and hands back a link. Three decisions carry it: sharing is explicit and per-conversation, so nothing is written until a button is pressed; the stored copy holds only what was said, with no IP, session or identifier, which is why the feature needs no consent flow; and the page is noindex and disallowed in robots.txt, because these are somebody's questions rather than content this site publishes.
The table has a read policy and deliberately no insert policy. A link works precisely because whoever holds the id can read it — but writes go through the service role after a click, so a link can't be forged into existence.
10
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.
Just under 700 checks run in a few seconds, covering the tool layer, theme-token parity, contrast in both modes, structured data, the LaTeX escaper, the schema against a real Postgres, and the client/server boundary — that last one after a helper living in a "use client" module was called from the server, which compiles, type-checks and builds clean before failing on every request.
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.