Personal portfolio of Murugappan, built with Astro 7 (Vite 8 / Rolldown) and served by a single Cloudflare Worker.
Live site: https://murugappan.dev
One Astro project serves both the portfolio and the blog (served at /blog): blog routes live in src/pages/blog/, so the /blog prefix comes from file position rather than an Astro base, and the blog's non-route code (layout, islands, styles, post helpers) is namespaced under src/blog/. Posts are markdown in content/blog/<slug>/index.md. bun run build:site builds the whole site into a single dist/. The light/dark theme is shared across both halves via the isDark localStorage key.
bun install
bun run dev # local dev server
bun run build # production build into dist/
bun run preview # preview the production buildBun (version pinned in package.json → packageManager)
installs dependencies, runs the package scripts and executes the TypeScript in
scripts/ directly. Astro and Vitest both run on Bun: the dev, build and
preview scripts call Astro with bun --bun, test is bun --bun vitest run
(the tests still execute inside workerd via @cloudflare/vitest-plugin — only
the host moved), and check:astro uses Bun's --preload for the TypeScript-7
alias in scripts/ts-alias.cjs (Bun 1.3.x breaks the production build and
astro check, so 1.4.2 is the pinned floor). Run the suite with bun run test,
not bun test (Bun's own runner). Node (version in .nvmrc) stays the runtime
for Wrangler and tsc. Bun blocks the install scripts of packages outside its
default trust list; the two it blocks here are safe to leave blocked:
@posthog/cli downloads its Rust binary the first time a source map upload
actually runs (production builds), and core-js prints a funding banner.
The GitHub profile card is fetched at build time from the GitHub GraphQL API. Set a GITHUB_TOKEN environment variable locally (any token with public read scope) to render it; without one the site builds fine and shows a contact fallback instead.
GITHUB_TOKEN=ghp_xxx bun run buildPostHog analytics is wired at build time when both POST_HOG_TOKEN and POST_HOG_URL are set (configured in the Workers Builds build env vars for production). With either missing the SDK is never loaded, so local dev and CI builds stay analytics-free.
Ingestion goes through https://e.murugappan.dev — PostHog's managed reverse proxy, a CNAME to their infrastructure (…cf-prod-us-proxy.proxyhog.com, US region). Nothing in this repo proxies it; the Worker is not in that request path, and the host is just the SDK's api_host. If the CNAME is ever re-added in Cloudflare DNS it must be DNS only (grey cloud) — proxying it breaks PostHog's cert issuance and SNI. ui_host stays https://us.posthog.com so the PostHog toolbar works.
Session replay is PostHog's too, started on the visitor's first interaction. The recorder is the SDK's heaviest extension, and loading it in the idle window right after paint put its evaluation inside what Lighthouse scores as blocking time — so initAnalytics boots with disable_session_recording: true and calls startSessionRecording() from onFirstInteraction (src/lib/first-interaction.ts: the first pointer, touch, key or wheel event, once — not scroll, which Chrome fires during load without any input). A visitor who never touches the page has nothing worth replaying; a Lighthouse run never interacts, so it never pays for the recorder. Recording must also be switched on in the PostHog project's replay settings. The extensions this site does not use (disable_surveys, capture_dead_clicks: false) are off, since each is a separate script the SDK would otherwise fetch after boot.
Privacy: replay masks every <input> and <textarea> (maskAllInputs), which covers the Jarvis chat box and the blog search. A sent chat message is re-rendered as a bubble <div> though, so the transcript container carries data-ph-mask="true", the maskTextSelector PostHog is configured with — nothing a visitor typed reaches a recording, matching the no-PII rule the events follow.
Error tracking is PostHog's, on the same SDK. initAnalytics turns on capture_exceptions: true, so uncaught errors and unhandled promise rejections become $exception events (the SDK fetches its small exception-autocapture script once it boots). Because the SDK itself waits for the first interaction, bootAnalytics attaches its own error / unhandledrejection listeners at page load and buffers everything thrown in between — the hydration and chunk-load window, where client errors actually happen — replaying it through captureException once the SDK arrives, then detaching so PostHog's autocapture is the only listener. Failures the code catches on purpose (a malformed chat frame, a chunk that failed to load) go through reportError(error, { ... }), which no-ops without the SDK and buffers while it loads like track/tag. Error tracking must also be switched on in the PostHog project settings.
Source maps go up with each production build, so those stack traces are unminified. The Vite build runs @posthog/rollup-plugin (astro.config.ts): it switches the build to hidden source maps, injects the chunk-id comments PostHog's symbol-set lookup keys on, uploads chunks and maps, then deletes the .map files — nothing extra is served, and an upload that fails fails the build instead of deploying unmapped. It activates only when POSTHOG_API_KEY (a personal API key with error-tracking write) and POSTHOG_PROJECT_ID are set, which is true in the Workers Builds production env vars and false for local dev and the GitHub checks, so those builds stay map-free and self-contained.
src/lib/analytics.ts wraps PostHog for both apps — track(event, props?), tag(key, value), reportError(error, props?), initClickTracking() and bootAnalytics(). track captures an event; tag registers a super property (session context like theme, correct for anonymous visitors rather than person properties); reportError sends a caught error to error tracking. Every one no-ops when the SDK was never loaded and swallows failures, so calls are safe anywhere; anything captured while the SDK is still loading is buffered and replayed. Pageviews, autocapture and heatmaps come from PostHog itself and are not re-instrumented here. Nothing a visitor typed (chat messages, blog search queries) is ever sent: blog filter params (?q=, ?tag=) are stripped from captured URLs before they leave the browser (redactBlogFilters / sanitize_properties), so even a reloaded or shared filtered link does not leak the query.
The SDK is the posthog-js npm package, import()ed on idle so it is a separate chunk outside the initial bundle. The token and host reach the client through <meta name="ph-token"> / <meta name="ph-host"> rendered from frontmatter — a meta tag rather than a data- attribute on the script because Astro bundles <script> as a module, where document.currentScript is null. This is also why the env vars need no PUBLIC_ prefix: they are read at build time, never in a client bundle.
Plain links opt in declaratively — data-ph-event, plus optional data-ph-prop/data-ph-value — and one delegated click listener per document handles them, React-rendered markup included. initClickTracking() is called from src/layouts/Layout.astro (portfolio) and src/blog/components/BaseHead.astro (blog).
| Event | Fired on |
|---|---|
resume_download |
"Download my resume" (also upgrades the session recording) |
contact_click |
"Contact me" |
social_click |
any outbound profile link, tagged social=github|linkedin|email|phone|x|rss |
project_click |
a pinned-repo card, tagged project=<repo> |
more_projects_click |
"More Projects" |
oss_link_click |
an open-source card link, tagged oss=<name> |
blog_card_click |
a homepage blog card, tagged post=<title> |
chat_open |
the Jarvis launcher is opened |
chat_starter_click |
a suggested starter chip |
chat_message_sent |
a message is sent (first one also upgrades the recording) |
chat_limit / chat_error |
the room replies with a rate limit or an error |
chat_restart / chat_transcript_download |
conversation menu actions |
listen_play |
first play on a post, tagged post=<slug> |
listen_resume / listen_pause / listen_seek |
transport controls (seek fires once per scrub) |
listen_rate |
playback speed changed, tagged listen_rate=<n>x |
listen_complete |
the post was read to the end |
listen_audio_fallback |
pre-rendered audio failed and speech synthesis took over |
listen_unavailable |
no backend at all (should be unreachable — the control hides itself) |
Super properties carry context rather than actions: theme (dark/light, set on load and on every toggle) and listen_backend (audio/speech).
bun run check-format # oxfmt (+ prettier for .astro/.md)
bun run lint # oxlint
bun run check:astro # type-check .astro files
bun run check:src # type-check src/, scripts/ and the config files
bun run check:worker # type-check worker/Everything is TypeScript: the Astro config, the blog islands and routes, and the
scripts/, which Bun runs directly (bun scripts/<name>.ts). Two files stay
JavaScript on purpose: scripts/ts-alias.cjs is a node -r preload for
astro check (which runs under Node) and must be CommonJS, and public/blog/sw.js
is served verbatim.
The project compiler is TypeScript 7, whose native build no longer ships the
old JS API that Astro's Volar-based tooling calls into — astro check crashes on it outright.
Microsoft publishes that old API as @typescript/typescript6 for this overlap,
so check:astro preloads scripts/ts-alias.cjs to point Volar's
require("typescript") at the compat package while everything else stays on 7.
@astrojs/check also still declares a typescript@^5 || ^6 peer, hence the
overrides entry in package.json. All three come out together once
@astrojs/check supports TypeScript 7.
The other overrides entry, htmlparser2, is for linkedom (the DOM in the
test files). linkedom still declares htmlparser2@^10, whose domutils and
entities dependencies ship source maps rooted at raw.githubusercontent.com.
Vite can't resolve those roots locally and warns "points to a source file
outside its package" for every file in a test run. htmlparser2 12 uses the
fixed majors (domutils 4, entities 8), which ship their src/ and resolve
locally; both Vite and Bun load linkedom's ESM entry, so htmlparser2 12 being
ESM-only is not a problem here. Revisit when linkedom moves past
htmlparser2 10.
Two consequences of having both compilers in the tree. @typescript/typescript6
pulls its own TypeScript 6, and that copy wins node_modules/.bin/tsc — so bare
bunx tsc reports 6.0.3, and the check:* scripts invoke
node node_modules/typescript/bin/tsc by path to be sure they get 7. And
TypeScript 7 ships no tsserver, so an editor set to "use the workspace
TypeScript version" lands on the 6 copy; point it at the TypeScript 7 language
service instead.
Cloudflare Workers Builds (git-integrated) builds on every push to main with build command bun run build:site and deploy command bun run deploy — one Worker serves the static dist/ and hosts the chat backend (see "AI chat widget" below). Both commands are Cloudflare dashboard settings, not read from this repo, so changing either means editing it by hand there — nothing in this file enforces them. bun run deploy applies any unapplied D1 migrations from ./migrations before wrangler deploy; nothing in the Worker issues DDL against D1, so a deploy that skips this step leaves the chat mirror writing to a table that doesn't exist. GitHub Actions (.github/workflows/ci.yml) runs checks only — format, lint, type-check, worker and unit tests, and a build smoke test including resume generation.
Workers Builds settings, for reference (dashboard → Workers → this application):
- Build command:
bun run build:site. Workers Builds picks the package manager from the lockfile and installs before the build command runs; it has recognised Bun's textbun.locksince May 2025 (earlier it only knew the binarybun.lockb, which is why older guides prependbun install &&). - Deploy command:
bun run deploy(notbunx wrangler deploy— see above). - Build env vars:
BUN_VERSION(matchpackageManagerinpackage.json—1.4.2or newer, the floor for the Astro-on-Bun build; the image's default Bun is older),GITHUB_TOKEN(public read scope),REQUIRE_GITHUB_PROFILE=1,POST_HOG_TOKEN,POST_HOG_URL,RESUME_PHONE(optional — see "Resume generation" below). Node's version comes from.nvmrc. - Worker secrets:
OPPORTUNITY_INBOXandDEEPSEEK_API_KEY, set withbunx wrangler secret put <name>.
/resume (src/pages/resume.astro) renders a print-styled resume sourced entirely from src/data/portfolio.ts and src/data/resume.ts — portfolio data is the single source of truth, so the page and the PDF can never drift from the site. As the last step of bun run build:site, scripts/generate-resume.ts serves the finished dist/ on a local port, opens /resume/ in headless Chromium via Puppeteer, and prints it to dist/resume.pdf. Set RESUME_PHONE (Workers Builds build env for production, a local .env for previewing the phone line) to show a phone number on the resume — no phone number is hardcoded in source, so leaving it unset simply omits that line. The portfolio's own contact section (GithubCard.astro) only reads this value in its no-GitHub-profile fallback view; production renders the GitHub-profile branch instead, which never shows a phone number. After printing, the script parses dist/resume.pdf with pdf-parse and fails the build (exit 1, listing what's missing) unless every ATS-critical string (name, email, section headings, current title, and the standout stats) is present as extractable text — a guard against the PDF ever becoming an image-only, unparseable export. Puppeteer runs fine on Workers Builds — the image lacks some system libraries, so the script falls back to @sparticuz/chromium when no system Chrome is present (see its resolution chain). If headless Chromium ever stops being viable there, Tectonic/LaTeX is the documented fallback renderer for this same build step.
The blog ("SDE Journey", migrated from Gatsby) lives in the same Astro project:
content/blog/ # one directory per post: <slug>/index.md (+ images)
draft/ # drafts — visible in dev, excluded from production builds
src/pages/blog/ # index, [...slug] post pages, 404, rss.xml, llms.txt, llms-full.txt
src/blog/ # layout, head, React islands (search, tags, theme toggle, bio),
# styles, post helpers, consts.ts site metadata
src/content.config.ts # blog content collection schema
public/blog/ # static files served verbatim (og-image, sw.js)
The index mirrors its search box and tag chips into the URL (/blog/?q=…&tag=…, one tag per checked chip),
so a filtered list is shareable, bookmarkable and survives a reload. Post pages link their tags to the same
URLs, so a reader can jump from an article straight into its filtered view. A tag is a controlled vocabulary
term from the schema (see "Tag vocabulary") — unknown ones are ignored.
Create content/blog/<slug>/index.md with frontmatter:
---
title: My post title
date: "2026-06-10T10:00:00.000Z"
tags: ["system-design"]
keywords: ["kafka", "outbox"] # optional: SEO/search terms, never shown as chips
description: One-line description shown in lists, search and feeds.
---Images placed next to index.md can be referenced relatively () and are optimized at build
time. ```mermaid code blocks become diagrams at build time, not in the browser: bun run diagrams renders
each fence to two SVGs (light and dark theme, with a subset of Fira Code embedded so labels measure the same
through <img>) plus a light PNG for the feed, under content/blog/<slug>/diagrams/, named by a hash of the
fence, and prunes renderings no fence uses any more; commit them with the post. Every file carries the mermaid version it was rendered with, so
a mermaid upgrade makes them all stale and the next run re-renders them (--force does the same on demand; a
change to the renderer's own output — theme, font — needs a RENDERER_VERSION bump in
src/blog/utils/mermaid-diagrams.ts so the hashes change). An edited diagram can never ship stale: bun run build runs bun run diagrams --check first (no browser needed) and stops with the command to run, and a
post-build integration (blogPostBodies in astro.config.ts) asserts every post body rendered with one figure
per fence — needed because the content layer only logs a post whose markdown failed to render, caches the empty
result in node_modules/.astro and ships a blank article. The RSS feed carries the PNG, not the SVG: feed readers and
mirrors (dev.to, Hashnode) rasterize images server-side, with no HTML engine for mermaid's foreignObject labels
and no web fonts, so the SVGs come out blank there while the PNG shows the diagram. On wide screens the post's ## and
### headings feed a Notion-style table-of-contents rail at the right edge (TableOfContents.astro); a post
with fewer than two shows no rail, and a separator should be ---, never an empty ##. The directory name is the URL slug, so
the post is published at /blog/<slug>/ and picked up automatically by the sitemap, RSS feed, both llms.txt
files and the markdown renditions.
Tags are the filter chips on the blog index, so they must be broad reader intents that recur across posts — not
labels for a single article. They are constrained in src/content.config.ts (an unknown tag fails the build)
and must follow: 1–3 per post, lowercase, kebab-case, singular, no vendor names. Precise terms (kafka,
debezium, partykit, floating-point) belong in keywords, which feeds JSON-LD, article:tag and the
index's search haystack but never renders as a chip.
| Tag | What it covers |
|---|---|
ai |
LLM agents, AI-assisted development |
algorithms |
DSA and interview-style problem solving |
backend |
Server-side engineering, APIs |
career |
Learning, tooling, becoming a developer |
databases |
Postgres, streaming, CDC, data plumbing |
fundamentals |
First-principles posts: floating point, closures, scope |
javascript |
Applied JS and language deep dives |
react |
React mental models and the ecosystem |
system-design |
Distributed architecture: queues, scaling, event-driven |
Adding a tag is a deliberate act: add it to BLOG_TAGS in src/content.config.ts, document it here, and tag
the posts it covers. A tag with no posts yet is fine — chips are derived from post counts, so it stays
invisible until used.
Every post has a Listen control. When /blog/audio/<slug>.json exists the page plays a
pre-rendered MP3 of the post in the blog's designed narrator voice and highlights the paragraph
being read from the timing JSON; otherwise (a new post, or astro dev, which has no Worker) it
falls back to the browser's speech synthesis. Audio is generated on a laptop, never in CI: the
model is 3.9 GB and needs Apple Silicon.
Pipeline (scripts/generate-audio.ts): built HTML → the same speechBlocks() the page
uses → emoji/punctuation/long-digit-run normalisation → ≤300-char sentence groups → Breeze TTS 2
(scripts/tts/synth.py, mlx-community/Breeze-TTS-2-mlx-8bit
via mlx-audio, plain clone of .voice/reference.wav) →
atempo=1.08 per chunk → sample-accurate joins (0.15 s within a paragraph, 0.45 s between) →
loudnorm I=-16 → 64 kbps MP3 + {blocks:[{text,start,end}]} JSON → R2 bucket
murugappan-dev-audio (infra/main.tf, bound as AUDIO) under the per-voice prefix
blog/breeze/, served by worker/audio.ts with Range/ETag support. Posts whose spoken-text hash
is unchanged are skipped. The previous voice (a Fish Audio S2 Pro clone of a phone recording,
2026-09-05) is still in the bucket at blog/<slug>.* and is no longer referenced.
One-time setup
brew install ffmpeg
python3.13 -m venv .venv-tts && .venv-tts/bin/pip install -r scripts/tts/requirements.txt
.venv-tts/bin/python scripts/tts/design-voice.py 3 # persona prompt → .voice/candidates/{0,1,2}.wav
cp .voice/candidates/<k>.wav .voice/reference.wav && cp .voice/candidates/reference.txt .voice/reference.txt
bun run audio --upload-voice # durable copy in R2; restored automatically if .voice/ is lostOr skip the design step and fetch the clip in use: bun run audio restores .voice/ from
voice/breeze/ in R2 when it is missing.
The bucket comes from terraform apply in infra/ (or bunx wrangler r2 bucket create murugappan-dev-audio). Voice reference and venv are git-ignored; the reference is never served.
Publishing a post
bun run build && bun run audio <slug> # ~2.8 s of compute per second of audio on an M4 Pro
bun run audio:align <slug> # word timings for the Speechify-style highlight, ~5 s per postThen push as usual. bun run audio with no slug renders every changed post; --force re-renders,
--dry-run only extracts and hashes, --local targets wrangler dev's R2. bun run audio:align
(scripts/align-audio.ts) runs after synthesis, never concurrently: it slices each paragraph out of the
MP3 in R2, gets word timestamps from mlx-whisper
(whisper-large-v3-turbo, 1.6 GB, auto-downloaded), maps them onto the known text
(src/blog/utils/audio-words.ts) and rewrites the JSON as version 2 with a words array per
paragraph. The page highlights the current word when that array exists and the paragraph otherwise;
the speech-synthesis fallback gets word highlights from the browser's boundary events. Breeze TTS 2
weights are under the BreezeBlue Research and Non-Commercial License, which this personal blog satisfies.
The voice was chosen in a 2026-09-09 listening evaluation against Google Chirp 3 HD, Gemini 3.1
Flash TTS and the earlier Fish clone. The clone of a phone recording hissed and shifted tone between
paragraphs; a designed voice fixes both because the reference clip is synthetic (noise floor about
−60 dBFS) and every paragraph clones the same clip. The design runs once with the persona prompt in
scripts/tts/design-voice.py (cfg_scale=4):
A 25-year-old male software engineer from Chennai with a light Tamil-influenced Indian English accent, recording the audio version of his own blog post. Quiet confidence and a bit of dry humour. Natural conversational pace, slight emphasis on key terms, brief pauses between ideas.
Per-paragraph synthesis passes no instruction: classifier-free guidance would run the 3B backbone
twice per frame and let the delivery drift. Use the 8-bit build; bf16 swaps on a 24 GB machine and is
3x slower. Breeze loops on long runs of one digit (0.30000000000000004), which is why
normalizeSpeechText describes such runs instead of listing them. Gemini 3.1 Flash TTS (voice
Fenrir) sounded as good and renders in seconds, but has no free tier once billing is attached to the
project; its narration prompt was "Narrate this technical blog post like a calm, clear software
engineer explaining to a peer. Natural conversational pace, no hype. Read acronyms letter by letter
and code identifiers exactly as written."
A public, unauthenticated JSON API over the site's own content, for AI agents and
developers. Documented for humans at /developers,
for machines at /openapi.json (OpenAPI 3.1.0),
and for agents at /AGENTS.md.
- Code:
worker/api/—routes.tsis the single source of truth for the surface (the router and the spec are both checked against it),openapi.tsgenerates the spec,errors.tsis the one JSON error envelope,store.tsreads the inputs,index.tsis the Hono sub-app. - Endpoints:
GET /api/v1/profile,/api/v1/experience,/api/v1/skills,/api/v1/education,/api/v1/open-source,/api/v1/posts(?q=,?limit=),/api/v1/posts/{slug}(full markdown),POST /api/v1/contact,GET /api/v1/versions, and the spec at/openapi.json+/api/v1/openapi.json. - Versioning (
worker/api/versioning.ts): the version is a path segment.API_PATHSholds the published/api/v1/...templates;toVersionedPath()normalises the unversioned/api/...alias onto them, which is what letsserver.tsmount the same Hono app at both prefixes (versioned first, so/api/v1/profilematches its own route rather than the catch-all). The alias is a permanent pin to v1 — a v2 would live only at/api/v2/....VERSIONSis the catalogue: adding a record there is what turns onDeprecation(RFC 9745) andSunset(RFC 8594) headers, thedeprecation/successor-versionLinkrelations, and theGET /api/v1/versionsdocument. Every API response carriesAPI-Version,API-Supported-Versionsand the discoveryLinkrelations, applied byworker/api/middleware.tsso no endpoint can be added without them. - Rate-limit headers (
worker/api/ratelimit.ts): every response carriesRateLimit-PolicyandRateLimitin the syntax of draft-ietf-httpapi-ratelimit-headers, mirrored asX-RateLimit-Limit/-Remaining/-Reset, withRetry-Afteron a429. Reads have a fair-use ceiling (600 per 60s per client) counted in a per-isolate fixed window — no Durable Object round trip on a read, and the consequence (the ceiling is per edge location, so the advertised number is a floor) is documented on/developers. Contact responses report the real daily allowance, whichRateLimiter.takeContactSlot()/contactUsage()now return. - No second copy of the data.
src/pages/api/dataset.json.tsis an Astro static endpoint that runssrc/data/portfolio.ts+resume.tsthroughbuildDataset()(inworker/api/dataset.ts) and prerendersdist/api/dataset.json; the Worker reads it back through the ASSETS binding. Blog posts come from the merged rootllms.txtand the per-postindex.mdrenditions, so the API can't fall behind the blog./developersrenders its endpoint table from the same OpenAPI document the Worker serves. - Worker-owned paths.
run_worker_firstinwrangler.jsoncclaims/api/*,/openapi.json,/mcp*,/mcp.jsonand/.well-known/*so every API failure is the JSON error envelope rather than the HTML 404 page, and so the generated discovery documents name the host that answered — keep that list in sync withworker/server.ts. POST /api/v1/contactemailsOPPORTUNITY_INBOX(same secret andsend_emailbinding the chat's lead capture uses). Rate-limited by the existingRateLimiterDO: 3/client-IP/UTC-day, 20 site-wide."dryRun": truevalidates a payload without sending or spending a slot — the endpoint's sandbox. Without the secret configured it answers503, never a silent drop.
Three things exist so an agent that has never seen this site can find its way in without reading prose, and can recover when it guesses a URL wrong.
/.well-known/api-catalog(worker/well-known.ts) — an RFC 9727 API catalogue, serialised as an RFC 9264 link set (application/linkset+json). One context object per API: the REST API (anchored at/api/v1, withservice-desc→/openapi.json,service-doc→/developers/,service-meta→/api/v1/versions) and the MCP server. Also advertised asLink: rel="api-catalog"on the pages and in<link>in the layout head./.well-known/mcp.jsonand/mcp.json— the MCPserver.jsonmanifest, following the published schema: reverse-DNSname(dev.murugappan/murugappan-dev), adescriptioninside the schema's 100-character cap, and onestreamable-httpremote. Anything beyond the schema rides in_metaunder a reverse-DNS key. Generated, so the remote URL names the host that answered.- The 404 (
worker/not-found.ts) — every miss reaches the Worker (seerun_worker_firstabove) and is content-negotiated:Accept: text/htmlgets the styled404.htmlthe assets layer produced, unchanged; anything else (including noAcceptat all, which is what curl andfetchsend) gets a short markdown body naming the sitemap,llms.txt,AGENTS.md,/developers/, the OpenAPI document, the API catalogue, the MCP manifest and the pages that do exist. Both branches carryVary: Acceptand the discoveryLinkrelations, and the status is a real404either way.
public/_redirects also 301s the URLs people and agents guess before they guess
/developers/: /docs, /api-docs, /developer, /api-reference,
/mcp-server.
The same content again as a Model Context Protocol
server, so an MCP client can use the site without any HTTP glue. Add it as
https://murugappan.dev/mcp — Streamable HTTP, POST only, no auth, no session.
- Code:
worker/mcp/—protocol.ts(JSON-RPC framing, version constants, header/body validation, Origin check),tools.ts(the eight tools, each a thin adapter overworker/api/store.ts),schema.ts(inlines the OpenAPI$refs so every tool'soutputSchemais self-contained, as the spec requires),index.ts(the Hono app). - Dual-era. Implements revision
2026-07-28(stateless, per-request_meta,resultType, mandatoryserver/discover, mirroredMCP-Protocol-Version/Mcp-Method/Mcp-Nameheaders validated against the body with-32020on mismatch) and theinitializehandshake of2025-11-25/2025-06-18/2025-03-26, which is what most deployed clients still speak. The era is chosen by whether the request carries modern_meta. No session is ever minted;GET/DELETEanswer405. - Tools:
get_profile,list_experience,list_skills,list_education,list_open_source,search_blog_posts,get_blog_post,send_message.send_messageshares theRateLimiterallowance withPOST /api/v1/contactand honours the samedryRun. - Resources (
worker/mcp/resources.ts):/llms.txt,/AGENTS.md, the generatedopenapi.json,/blog/llms-full.txt, and one entry per published post, plus the{slug}URI template.https://URIs, per the spec's rule that the scheme is for resources a client can fetch from the web itself. Reads go through an explicit allowlist and a re-validated slug, and a missing resource is-32602with the uri indata— never an emptycontentsarray. - Docs are generated. The MCP section of
/developersrenders its tool table fromMCP_TOOLS, so it cannot list a tool that does not exist.
- Design language inspired by Soumyajit4419's Portfolio; the hero desk illustration is adapted from that project (recolored to this site's navy theme).
- Originally based on developerFolio before the Astro migration.
Intercom-style AI concierge (named Jarvis) on every page (portfolio + blog).
- Server:
worker/— Cloudflare Worker servingdist/as static assets +ChatRoomDurable Object (partyserver, SQLite) streaming replies over WebSocket at/parties/chat-room/:roomId. - Model: DeepSeek
deepseek-flash(BYOK via theDEEPSEEK_API_KEYsecret), the only provider — Workers AI was dropped on 2026-08-27 for being slow and truncating replies when the free neuron allocation ran out. Thinking is enabled (thinking: {type: "enabled"}) — the model reasons before answering, which sharpens tool selection;reasoning_contentis dropped so visitors see only the reply. This is only safe because there is nomax_tokens: reasoning used to consume the whole 800-token cap.deepseek-flashtracks the current Flash generation — it became V4.1-Flash on 2026-09-10, when the olderdeepseek-v4-flashname was retired to a temporary compat alias. - Swapping the model: run
bun run test:capturefirst. It drives a real lead-capture conversation against the live model and fails ifcapture_opportunityis never called, or is called without the visitor's contact detail. qwen3-30b was reverted on 2026-08-17 for narrating captures it never made — unit tests cannot catch that, only the live model can. Needs.dev.varsand a builtdist/llms.txt; costs a fraction of a cent. - Loading: the widget is a React island hydrated with
client:interaction, a custom directive (src/directives/interaction.ts, registered inastro.config.ts) that loads React + the widget on the visitor's first input anywhere on the page instead of on idle. A page that is only loaded — a Lighthouse run — never downloads it; a tap on the server-rendered launcher while the bundle is in flight is remembered on the island and the panel opens itself once mounted. - Widget:
src/components/chat/(shared by the blog via relative import). While a turn is in flight the panel showsActivityRowinstead of the three dots: a rotating playful label ("Discombobulating…") with an elapsed counter while the model reasons, switching to the real action when the worker broadcasts atoolframe ("Reading blog/…", "Noting your details"). The frame carries only the tool name plus a page path — never tool arguments — and is ephemeral: nothing is persisted, and the next delta clears the row. The row isaria-hiddenwith a stablesr-only"Jarvis is typing", so the rotating words don't spam the panel's live region. - Email:
send_emailbinding →OPPORTUNITY_INBOX(Worker secret). - Visitor context: the Worker edge reads the country off the WebSocket
upgrade (
request.cf.country, falling back toCF-IPCountry) and the address offCF-Connecting-IP, and carries both to the room as headers — a Durable Object never seesrequest.cf, and the client's own copy of those headers is deleted first, so a spoofed value can never be read.ChatRoom.onConnectrecords them undervisitor_*meta keys and mirrors oneroomsrow per room to D1 (migrations/0002_rooms.sql), refreshed on every reconnect withfirst_seenpreserved, so the dash shows where a conversation came from without joining transcripts. IPs are personal data: they live only in the room and that mirror, never in analytics. - Limits: 20 msgs/day per conversation, 300/day globally, 1000 chars/msg.
Chat runs until the DeepSeek account is actually out of credit — the
RateLimiterDO readsGET /user/balanceon the same key (cached 10 min, shared by every room, fails open) and gates belowBALANCE_RESERVE_USD. A 402 from a chat call is authoritative and gates every room at once; a top-up is picked up at the next cache expiry, no deploy. There is no daily allowance — at ~$0.003/turn, top up to set the ceiling. - Local dev (full-fidelity single-origin):
bun run build:site && bunx wrangler dev→ http://localhost:8787 (runs both Astro and Worker on the same origin; chat connects at the Worker origin with full Durable Objects). PutOPPORTUNITY_INBOX=you@example.comandDEEPSEEK_API_KEY=sk-...in.dev.vars(gitignored); without the key the chat gates itself, since there is no fallback provider. - Local dev (fast HMR loop): put
PUBLIC_CHAT_HOST=localhost:8787in a root.env(gitignored), then runbun run dev:all. Starts Astro dev server (with HMR) on :4399 and Worker on :8787 in parallel; the widget connects to the real Worker. Note: the Worker serves grounding fromdist/, so runbun run build:siteat least once first, or Jarvis will lack site knowledge. Also note: AI calls in dev hit the real DeepSeek API and are billed, so watch your spend. - Tests:
bun run test(vitest + workers pool),bun run check:worker.