How this site is built

This site is a static Astro build with React islands, deployed on Cloudflare Pages. The resume is data, not markup: one YAML file drives the landing page, the resume page, the D3 visualizations, the ATS PDF, and the chat assistant.

Ask the chat assistant about any of this — it knows the same list.

Framework & UI

Static HTML by default; interactivity is opt-in per component.

Astro 7
Static site generator. Ships zero JavaScript unless a component asks for it.
React 19
React and react-dom, used only for interactive islands - visualizations, chat, theme and motion toggles.
TypeScript
Strict mode across the site, the build scripts, and the Cloudflare function.
Tailwind CSS 4
Utility styling over a small set of CSS custom properties for theming.
Fontsource
Self-hosted Inter, Fraunces, JetBrains Mono, and Caveat. No third-party font requests.

Data Visualization

Three views of the same skills data, each a separate island.

D3
Force-directed skills constellation, radial bars, and treemap.
Rough.js
Hand-drawn rendering style for the hero field.

AI Assistant

The chat can highlight skills and roles on the page while it answers.

Claude Opus 5
Answers visitor questions about the resume and about this site, in the first person as Andrew. The chat is labelled as an AI, and the assistant says so when asked; it never claims to be the person.
First-person persona
Replies say "I built" rather than "Andrew built", because a reply can be read aloud in Andrew's cloned voice and the third person sounds wrong in it. The label on every reply reads "Andrew (AI)".
Anthropic TypeScript SDK
Calls the Messages API from a Cloudflare Pages Function.
Tool use
Four tools let the assistant highlight skill groups, spotlight a role, highlight job-description matches, and switch theme.
Two-turn tool loop
Tool results go back to the model so every reply contains a written answer, not only a highlight.
Streaming (NDJSON)
Replies stream over newline-delimited JSON, then draw at a steady rate so text appears written rather than pasted in blocks. The draw rate scales with the backlog, so it never lags the response, and it is skipped entirely when the visitor prefers reduced motion.
Prompt caching
The ~16K-token system prompt is byte-identical on every request and cached, which cut the cost of a reply from about $0.098 to $0.024.
Usage telemetry
Every reply reports its own tokens in and out, cache hit rate, and cost in USD, computed from the API usage fields.
Deep links
Each highlight resolves to an anchor on /resume, and a matched job description links straight to the full fit report at /fit.
Markdown rendering
Replies render as rich Markdown through a renderer that escapes every character before inserting any tag, so model output shaped by visitor input cannot become markup.
Cross-page chat session
The conversation and the open sidebar persist in sessionStorage, so one thread continues as the visitor moves between pages of this static site.

Voice

Andrew's cloned voice can read the chat, the resume, and the fit report. Off by default, one toggle in the header.

ElevenLabs voice clone
An instant clone of Andrew's own voice. The voice id is a constant in the code; the API key is a Pages secret.
Flash v2.5 for live replies
Chat replies and fit reports are spoken through /api/speak with the lowest-latency model, about a second from text to audio.
Multilingual v2 for the resume
Resume audio is generated once with the highest-quality model, committed like the PDF, and verified against resume.yaml on every deploy. A listen costs nothing.
Signed narration requests
The chat function signs the spoken form of each reply. /api/speak refuses chat text without that signature, so the endpoint cannot speak arbitrary text at Andrew's expense.
Per-session narration limit
Twenty narrations an hour per anonymous session, recorded in D1 with characters and cost and shown on the trace line of each reply.
Lipsynced intro video
A short recording of Andrew, lipsynced to a script read in the cloned voice with Sync Lipsync 2 Pro, with captions built from character timestamps. Rendered once and committed.

MCP Server

The resume is queryable from any MCP client at carmer.info/mcp.

Model Context Protocol
A stateless Streamable HTTP server exposing get_resume, search_experience, and assess_fit. Visit /mcp in a browser for setup instructions for Claude Code, Claude Desktop, Cursor, VS Code, Windsurf, Zed, ChatGPT, and the Gemini CLI.
Hand-rolled, not the SDK
The server is stateless and read-only, so the Agents SDK's session and Durable Object machinery would add a dependency and a storage binding without adding behaviour. The protocol surface a stateless server needs is three methods.
Deterministic tools
No tool calls a model. A public endpoint that calls an LLM is an unbounded bill and an abuse vector, and none of these answers needs one.

Job Fit Report

At /fit, a recruiter pastes a job description and gets a structured match.

Deterministic matching
No model call. The same posting always produces the same report, and the gaps are never softened by a model with an incentive to agree.
Concept aliases
Maps posting vocabulary onto the resume's own words - "distributed systems" matches the federated GraphQL gateway - but only when a role bullet evidences it.
Runs in the browser
The job description never leaves the visitor's machine, which matters when a posting is not public yet.
Honest gaps
Requirements the resume does not cover are listed plainly. A report that only lists strengths is a sales pitch.

Privacy & Observability

Conversations are logged anonymously. No IP address and no user agent are stored.

Cloudflare D1
SQLite at the edge. Stores one row per chat exchange with a random session id, country, tokens, and cost.
Cloudflare Access
Zero-trust policy in front of the private /admin log view, with one-time PIN login. The route also verifies the Access JWT itself, so it fails closed if the policy is ever removed. There is no application password to leak.
90-day retention
Rows older than 90 days are purged on write, so nothing accumulates indefinitely.
Anonymous by design
IP address is personal data under GDPR and CCPA, identifies little behind corporate NAT, and is deliberately never collected.

Hosting & Deployment

A push to the main branch is the deploy. There is no staging.

Cloudflare Pages
Static hosting, git-connected build from the main branch.
Cloudflare Pages Functions
Serves the /api/chat endpoint on Workers.
Wrangler
Cloudflare CLI and project configuration.
GitHub Actions
Runs typecheck, unit tests, browser tests, and the ATS PDF check on every push.

Content Pipeline

One YAML file is the source of truth for every surface, including the PDF.

YAML
content/resume.yaml holds the resume; content/stack.yaml holds this page.
Zod
Validates both files at build time, so a malformed edit fails the build.
tsx
Runs the TypeScript build scripts directly.
Playwright (Chromium)
Prints /resume-print to the ATS PDF that visitors download.
pdf-parse
Verifies the committed PDF still matches the resume data, including a content hash.

Testing & Accessibility

The site targets WCAG AAA contrast, and the tests enforce it.

Vitest
Unit tests for the resume schema, chat helpers, and contrast math.
Playwright
End-to-end tests for the landing page, resume page, career explorer, and chat.
axe-core
Automated WCAG 2 A, AA, and AAA checks on every page and on the open chat widget.
Assistant evals
A fixed scenario suite grades the chat assistant against the deployed site and publishes the results at /evals. Graders are deterministic, not an LLM judge.
Reduced motion
Every animation respects prefers-reduced-motion, with a manual toggle as well.

Deliberately not used

The half of a stack decision that usually goes unrecorded. Each of these was considered and left out for a measured reason.

RAG and a vector store
The whole corpus - resume plus this stack file - is about 6,000 tokens, or 3% of the context window. Retrieval exists to select from a corpus that does not fit; this one fits 33 times over. Worse, retrieved chunks vary per query, which would invalidate the cached prompt prefix that currently runs at a 99% hit rate and holds a reply to about $0.011. Adding RAG would make each reply roughly nine times more expensive, add latency, and turn a retrieval miss into the assistant denying real experience to a recruiter. The MCP server does expose retrieval - exact and structured rather than approximate and embedded, which is the right trade at this size.
An LLM judge for the evals
The scenario suite at /evals is graded deterministically. A judge model is another thing to pay for, tune, and distrust, and it fails for reasons you cannot read. These checks are reproducible and their failure messages name the measured value.
The Agents SDK for the MCP server
The server at /mcp is stateless and read-only - three tools over data already in memory - so the SDK's session and Durable Object machinery would add a dependency and a storage binding without adding behaviour. The protocol surface a stateless server needs is three methods.
IP addresses and user agents in the chat log
IP address is personal data under GDPR and CCPA, and behind corporate NAT it identifies almost nobody. A random per-tab session id groups one visitor's questions, which is the useful part, without the obligation. A unit test asserts no such column can be added.
A live video avatar in the chat
ElevenLabs has no real-time talking-head product. Its lipsync models render a file in minutes, and the avatar in its Agents widget is a static image or an orb. A live avatar means a second vendor, a WebRTC session per visitor, and per-minute billing on a public page. The intro video is pre-rendered instead, and the chat is narrated by voice.
The ElevenLabs Agents widget
It would replace the chat with a hosted voice agent and drop Claude, the page-highlight tools, the evals, and the conversation log. The voice is added to the existing assistant instead of replacing it.
A navigation bar in the header
The header is a name and two toggles. A nav would turn a focused personal site into a brochure and push the hero down on mobile, for links most visitors do not need. The secondary pages sit in a quiet sub-nav under the hero buttons and in the footer instead.

Back to the site