how this was built
the chat is the portfolio
I'm Leon. This site doesn't have a projects grid you click through — it has me, answering you, live, in first person. That's the whole bet: instead of building a portfolio and hoping you read it in the order I intended, I built an agent that reads the room and gives you the order you actually want.
why chat, not pages
A static case study answers questions nobody asked in the order I picked. A chat answers the question you have, right now, and can pull up the exact screenshot or metric while it talks. The interface becomes the proof. If I say I built a tool-calling agent, you're watching one work.
The tradeoff is real: chat is slower to scan than a resume, and it fails ungracefully if the model hallucinates. Most of the engineering below exists to close that gap — grounding every fact in a tool call, and refusing to let the agent say something it can't back up.
the tool loop
Every turn runs the same loop: your message goes to the model with a system prompt and tool schemas, the model decides whether it needs data or a UI action, calls a tool, gets a result back, and only then writes prose. Up to five steps per turn — enough to open a project, scroll to a section, and narrate it, but capped so a confused agent can't spiral.
"Client executes" is the important part. Tool calls don't run on the server — they return a description of an action (open this project, scroll to this section, click this button) that the browser then performs against the real DOM. The model never touches your page directly.
click_element: tool({
description:
'Click a DOM element the visitor could have clicked themselves. ' +
'Element MUST carry `data-agent-clickable` or the action is refused.',
inputSchema: z.object({
selector: z.string().min(1).max(200),
reason: z.string().min(1).max(200),
}),
execute: async ({ selector, reason }) => ({ ok: true, selector, reason }),
}),That refusal isn't a comment — it's enforced where the tool result gets applied client-side. If an element doesn't carry data-agent-clickable or data-agent-fillable, the agent can describe the action all it wants; nothing happens. Absence of the attribute is the default-deny.
knowing when to open vs. describe
The system prompt gives the model a decision tree for one recurring judgment call: when you ask about a project, should it open the full rundown panel, or just answer in text? Opening is heavier — it changes the scene, slides in a panel, updates the URL — so it's reserved for when you clearly want to look at something, not for every passing mention.
The same prompt carries an anti-fabrication rule that matters more than the voice coaching: the model is told, explicitly, that project facts may only come from a fresh get_rundowncall or the currently open panel — never from its own training data or an earlier turn. "I'd need to check" beats a confident, invented metric.
safety rails
An agent with a real API key sitting on a public portfolio needs guardrails that assume hostile traffic, not just typos. Four run on every request, in order: an aggregate cost cap, a per-IP and per-conversation rate limit, a click/fill allowlist (above), and a streaming output filter.
The output filter is the one I'm most careful about. Some of my work is under NDA — client internals, former employer specifics. The system prompt tells the model not to leak them, but prompts get ignored under adversarial pressure. So there's a second layer that doesn't trust the model at all: every streamed token passes through a regex scanner before it reaches you.
// Chunk-boundary safe: retains a tail of up to BUFFER_SIZE characters between
// pushes so a forbidden phrase split across SSE chunks ("Tes" + "la") is
// caught when the second chunk arrives.
//
// Unicode-hardened: buffer is passed through .normalize('NFKC') before scan,
// catching Cyrillic lookalikes, zero-width joiners, and decomposed forms.
if (matches) {
result = result.replace(pattern, replacement ?? '[redacted]');
}It buffers enough trailing characters to catch a match spanning two chunks, normalizes Unicode so a lookalike character can't sneak a forbidden term past a naive regex, and swaps hits for [redacted] mid-stream rather than failing the whole response. A CI script asserts the buffer is always at least twice the longest pattern it has to catch, so a future edit to the pattern list can't silently reopen the gap.
Underneath the filter is a clean-room boundary at the content layer itself. Every project entry declares what it's allowed to say in a cleanRoom block, and a build script checks the actual copy against it before deploy — so the filter is a runtime backstop, not the only line of defense.
const cap = checkCostCap();
if (!cap.ok) {
return NextResponse.json(
{ error: 'cost_cap', reason: cap.reason },
{ status: 503, headers: { 'retry-after': '86400' } },
);
}
const rl = await checkRateLimit({ ipHash, kind, convHash, recentEventsCount });
if (!rl.ok) {
return NextResponse.json({ error: 'rate_limit', reason: rl.reason }, { status: 429 });
}the design system underneath
Visually the site runs on two tokens: pitch black (#0a0a0c) and amber (#ca8a04), used sparingly enough that it reads as a signal, not a theme. Panels are layered liquid glass — three blur intensities so a panel opened on top of another panel doesn't flatten into one surface. Type is Instrument Serif italic for display, Instrument Sans for body, Commit Mono for anything that's effectively code — including these labels.
Motion is restrained on purpose: a slow cinematic ease for scene changes, a snappier pop for UI feedback, and every animated component checks prefers-reduced-motionand falls back to an instant cut. An agent that flashes and slides things around for you is a liability if it can't also sit still when asked.
go ask the agent about any of this — it's reading from the same files.