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.