<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Cameron Rye</title><description>Software Engineer &amp; Architect - Articles on software development, AI integration, and building scalable systems. Plus featured projects and tools.</description><link>https://rye.dev/</link><language>en-us</language><managingEditor>c@meron.io (Cameron Rye)</managingEditor><webMaster>c@meron.io (Cameron Rye)</webMaster><copyright>Copyright 2026 Cameron Rye</copyright><lastBuildDate>Wed, 15 Jul 2026 02:40:20 GMT</lastBuildDate><atom:link href="https://rye.dev/rss.xml" rel="self" type="application/rss+xml" xmlns:atom="http://www.w3.org/2005/Atom"/><item><title>Making My Portfolio Agent-Readable: From Files to an Interface Agents Can Act On</title><link>https://rye.dev/blog/making-my-portfolio-agent-readable/</link><guid isPermaLink="true">https://rye.dev/blog/making-my-portfolio-agent-readable/</guid><description>I stopped publishing files for agents to discover and built an interface they can act on: markdown mirrors, an A2A agent card, and verifiable skills.</description><pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/making-my-portfolio-agent-readable-a-visual-metaphor-for-the-shif-featured-1781570042816.png&quot; alt=&quot;A split scene: a dark, tangled forest of &amp;#39;Discovery&amp;#39; signposts on the left and a bright, organized &amp;#39;Usability&amp;#39; interface on the right, with an AI agent bridging the two along a glowing data road.&quot; /&gt;&lt;/p&gt;&lt;p&gt;A few months ago I published &lt;a href=&quot;https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/&quot;&gt;an essay&lt;/a&gt; arguing that &lt;code&gt;llms.txt&lt;/code&gt; is an elegant solution to a problem nobody important is willing to solve. No major AI platform reads it. Not OpenAI, not Google, not Anthropic, not Meta. The standard, I wrote, &quot;sits unused, waiting for a problem that the powerful have chosen not to solve.&quot;&lt;/p&gt;
&lt;p&gt;Then I went back to my portfolio and built more of it.&lt;/p&gt;
&lt;p&gt;I added an agent card. I added an A2A endpoint that agents can actually POST to. I wrote markdown twins of every page on the site. And I added a manifest of skills an agent is told to &lt;em&gt;verify&lt;/em&gt;, not just trust, before acting on them. If you only read the first essay, this looks like a man losing an argument with himself.&lt;/p&gt;
&lt;p&gt;It is not. There is one distinction that makes building all of this the opposite of insane.&lt;/p&gt;
&lt;h2&gt;Discovery is dead; usability is the live question&lt;/h2&gt;
&lt;p&gt;The thing I declared dead was &lt;strong&gt;discovery&lt;/strong&gt;. The idea that publishing the right file would make an AI crawler find me, rank me, surface me. That bet has not paid off and there is no sign it will. In &lt;a href=&quot;https://rye.dev/blog/ai-slop-is-a-search-problem-now/&quot;&gt;my follow-up on AI slop&lt;/a&gt; I went further and reframed the entire category: these standards are valuable &quot;not as a way to be discovered, but as a way to be verified once a user has found you.&quot;&lt;/p&gt;
&lt;p&gt;Hold onto that sentence. It is the hinge of this whole post.&lt;/p&gt;
&lt;p&gt;Because there is a completely different scenario the discovery debate ignores. A human is sitting in front of an agent (Claude, ChatGPT, some autonomous research thing) and they say: &lt;em&gt;go look at Cameron&apos;s site and tell me what he&apos;s done with MCP.&lt;/em&gt; The agent is already here. Nobody had to discover me. The question is no longer &quot;will it find me.&quot; The question is:&lt;/p&gt;
&lt;p&gt;When it gets here, can it actually do anything?&lt;/p&gt;
&lt;p&gt;That is a usability question, not an SEO question. And usability for an agent has a spectrum:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Can it read me?&lt;/strong&gt; Most sites hand an agent a tag-soup DOM full of nav chrome, cookie banners, and analytics noise, then make it guess which &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; is the article.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can it understand me?&lt;/strong&gt; Even with clean text, can it tell a blog post from a project, find the publish date, know which pages are drafts?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can it act on me?&lt;/strong&gt; If the human says &lt;em&gt;subscribe me to his newsletter&lt;/em&gt; or &lt;em&gt;send him a message&lt;/em&gt;, is there a door the agent can open, or does it have to scrape a form and forge a POST?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My RAG chatbot, &lt;a href=&quot;https://rye.dev/blog/building-ask-rag-portfolio-chatbot/&quot;&gt;Ask&lt;/a&gt;, already handles the conversational half of this. This post is about the other half: the static, declarative, machine-addressable surface underneath. The part an agent reaches for when there is no chat box, just a URL and an intent.&lt;/p&gt;
&lt;p&gt;I built it in five layers. None of them is a traffic play. Every one of them is a usability or verification mechanism for the agent a visitor already brought with them.&lt;/p&gt;
&lt;h2&gt;Every page has a markdown twin&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/making-my-portfolio-agent-readable-markdown-twin-one-document-tw-1781571015356.png&quot; alt=&quot;A browser window rendering a portfolio web page beside the same content as a clean Markdown document with YAML frontmatter and headings, joined by an equals sign.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Start with reading, because if an agent cannot cleanly read the page, nothing above it matters.&lt;/p&gt;
&lt;p&gt;Every blog post on rye.dev is served at &lt;code&gt;/blog/&amp;lt;slug&amp;gt;.md&lt;/code&gt;, and every project at &lt;code&gt;/projects/&amp;lt;slug&amp;gt;.md&lt;/code&gt;. These are real routes: Astro file routes at &lt;code&gt;src/pages/blog/[...slug].md.ts&lt;/code&gt; and &lt;code&gt;src/pages/projects/[...slug].md.ts&lt;/code&gt;, with &lt;code&gt;prerender = true&lt;/code&gt; so they bake to static files at build time.&lt;/p&gt;
&lt;p&gt;Here is the part I care about: &lt;strong&gt;these do not convert HTML to markdown.&lt;/strong&gt; They reconstruct it. The route takes the source content and emits a fresh YAML frontmatter block (title, description, date, updated, tags, author, plus a &lt;code&gt;canonical_url&lt;/code&gt; I inject) followed by the raw markdown body, served as &lt;code&gt;text/markdown; charset=utf-8&lt;/code&gt;. No nav, no footer, no cookie banner. Just the document, the way I actually wrote it.&lt;/p&gt;
&lt;p&gt;Each response also carries a non-standard header I made up because I wanted it: &lt;code&gt;x-markdown-tokens&lt;/code&gt;, set to &lt;code&gt;ceil(markdown.length / 4)&lt;/code&gt;. That four-chars-per-token estimate lets an agent budget its context window &lt;em&gt;before&lt;/em&gt; it spends a fetch. A small courtesy, but the whole exercise is courtesies.&lt;/p&gt;
&lt;p&gt;There is a second way in, for agents that don&apos;t know about the &lt;code&gt;.md&lt;/code&gt; convention: &lt;strong&gt;HTTP content negotiation.&lt;/strong&gt; Request the canonical HTML URL (&lt;code&gt;/blog/foo&lt;/code&gt;, or &lt;code&gt;/about&lt;/code&gt;) with &lt;code&gt;Accept: text/markdown&lt;/code&gt;, and middleware serves you markdown instead. For blog and project pages it internally rewrites to the prebuilt &lt;code&gt;.md&lt;/code&gt; sibling. For the eight static pages (&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/now&lt;/code&gt;, &lt;code&gt;/uses&lt;/code&gt;, &lt;code&gt;/colophon&lt;/code&gt;, &lt;code&gt;/reading&lt;/code&gt;, &lt;code&gt;/resume&lt;/code&gt;, &lt;code&gt;/blogroll&lt;/code&gt;) it serves hand-authored markdown twins embedded in the Worker bundle: &lt;code&gt;src/data/static-page-md/*.md&lt;/code&gt;, imported via Vite&apos;s &lt;code&gt;?raw&lt;/code&gt;. One resource, two representations, picked by a header. Content negotiation working exactly as specified.&lt;/p&gt;
&lt;h3&gt;The part that took the longest was the part you can&apos;t see&lt;/h3&gt;
&lt;p&gt;I want to be honest about how much fighting the platform this took, because the clean result hides it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Runtime HTML-to-markdown is impossible here.&lt;/strong&gt; The obvious move (let a request come in, grab the rendered HTML, run it through &lt;code&gt;turndown&lt;/code&gt; or &lt;code&gt;cheerio&lt;/code&gt;) does not work on Cloudflare Workers. Those libraries pull in &lt;code&gt;parse5&lt;/code&gt;, which is Node-only, and they crash the Workers runtime at &lt;em&gt;module load&lt;/em&gt;. Not at call time. At import. So the conversion can never happen at the edge. It has to happen at build, which is why these are reconstructed routes, not a clever middleware filter.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;run_worker_first&lt;/code&gt; is a trap.&lt;/strong&gt; Cloudflare lets you ask the Worker to run before static assets are served. Except for routes that match a prerendered asset, &lt;code&gt;serve_directly&lt;/code&gt; silently overrides it and the asset is handed back before your code runs. &lt;strong&gt;&lt;code&gt;_routes.json&lt;/code&gt; does nothing&lt;/strong&gt;: that is a Pages mechanism, and this is a Workers deploy. And Cloudflare&apos;s own zone-level &quot;Markdown for Agents&quot; feature, which would replace this entire layer, requires a paid plan. rye.dev is on the &lt;strong&gt;Free plan&lt;/strong&gt;. So I built the free version by hand.&lt;/p&gt;
&lt;p&gt;There&apos;s one subtlety that cost me an afternoon. The static &lt;code&gt;.md&lt;/code&gt; routes must be &lt;code&gt;prerender = false&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; there must be a registered SSR route at &lt;code&gt;src/pages/[slug].md.ts&lt;/code&gt;, or Workers Static Assets answers the request first and 404s it before middleware ever runs. The Worker has to be the thing that picks up the phone.&lt;/p&gt;
&lt;p&gt;One more, because it bit me in production: &lt;code&gt;Vary: Accept&lt;/code&gt; is the correct header to set, and I set it. But Cloudflare&apos;s edge cache does not vary on &lt;code&gt;Accept&lt;/code&gt;. So without intervention, the edge could cache an HTML body and then hand it to an &lt;code&gt;Accept: text/markdown&lt;/code&gt; request. The fix is to force &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt; on the negotiable static-page paths. You give up edge caching on a handful of routes to guarantee correctness. Worth it.&lt;/p&gt;
&lt;h2&gt;The index nobody reads, built anyway, honestly&lt;/h2&gt;
&lt;p&gt;On top of the twins sits &lt;a href=&quot;https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/&quot;&gt;the file I already eulogized&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;public/llms.txt&lt;/code&gt; is hand-authored. I&apos;ll say that plainly, because the next file isn&apos;t, and the distinction matters. It follows the &lt;a href=&quot;https://llmstxt.org/&quot;&gt;llmstxt.org&lt;/a&gt; structure: an H1, a blockquote summary, then &lt;code&gt;## About&lt;/code&gt;, &lt;code&gt;Featured Projects&lt;/code&gt;, &lt;code&gt;All Projects&lt;/code&gt;, a reverse-chronological &lt;code&gt;Technical Blog&lt;/code&gt; with dates, &lt;code&gt;Technical Expertise&lt;/code&gt;, &lt;code&gt;Connect&lt;/code&gt;, and &lt;code&gt;Optional&lt;/code&gt;. It documents the &lt;code&gt;.md&lt;/code&gt;-appended convention right at the top so an agent reading it knows the twins exist.&lt;/p&gt;
&lt;p&gt;Being hand-authored, it can drift. So next to it is &lt;code&gt;public/llms-full.txt&lt;/code&gt;, which is &lt;strong&gt;generated&lt;/strong&gt; by &lt;code&gt;scripts/generate-llms-full.ts&lt;/code&gt; as the &lt;em&gt;first&lt;/em&gt; step of every build. It inlines the full body of every non-draft post and project (currently around &lt;strong&gt;25 posts and 15 projects&lt;/strong&gt;) into one grep-able file. If an agent has no fancier tool, the instruction is blunt: fetch &lt;code&gt;llms-full.txt&lt;/code&gt; and grep it.&lt;/p&gt;
&lt;p&gt;Do I think OpenAI is fetching this file? No. I said in the first essay that nobody is, and I stand by it. But it is trivial to generate when you already write everything in markdown, it signals to a human inspecting the site that I take the machine-reading problem seriously, and it gives &lt;em&gt;my own&lt;/em&gt; tooling something clean to read. The cost of building it was an afternoon. The cost of being wrong about adoption is zero, because I lose nothing by being early. That was the entire argument for building &lt;code&gt;llms.txt&lt;/code&gt; anyway, and it is the entire argument here.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/making-my-portfolio-agent-readable-a-visual-representation-of-the-1781570086957.jpg&quot; alt=&quot;An isometric glowing &apos;Agent Card&apos; panel listing supportedInterfaces, a capabilities block with streaming and pushNotifications set to false, and a skills list.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;An agent card that tells the truth about itself&lt;/h2&gt;
&lt;p&gt;Now we move from &quot;read me&quot; to &quot;here is my interface.&quot;&lt;/p&gt;
&lt;p&gt;At &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt; lives a static, hand-authored &lt;a href=&quot;https://a2a-protocol.org/&quot;&gt;A2A Protocol&lt;/a&gt; AgentCard, version &lt;code&gt;1.0.0&lt;/code&gt;. It names the provider (&lt;code&gt;https://rye.dev&lt;/code&gt;, &quot;Cameron Rye&quot;), points &lt;code&gt;documentationUrl&lt;/code&gt; at the agent-skills index, and uses the site favicon as its icon. Its &lt;code&gt;supportedInterfaces&lt;/code&gt; is a single entry: &lt;code&gt;{ url: https://rye.dev/a2a, protocolBinding: JSONRPC, protocolVersion: 1.0 }&lt;/code&gt;. That &lt;code&gt;/a2a&lt;/code&gt; URL is the door. We&apos;ll walk through it in the last layer.&lt;/p&gt;
&lt;p&gt;The honest part is &lt;code&gt;capabilities&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&quot;capabilities&quot;: { &quot;streaming&quot;: false, &quot;pushNotifications&quot;: false, &quot;extendedAgentCard&quot;: false }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All false. No streaming, no push, no extended card. I could have left those out or fudged them aspirationally. I set them false because they &lt;em&gt;are&lt;/em&gt; false, and an agent that reads &lt;code&gt;streaming: false&lt;/code&gt; and then doesn&apos;t wait around for an event stream is an agent I&apos;ve saved a timeout.&lt;/p&gt;
&lt;p&gt;The card advertises &lt;strong&gt;five skills: &lt;code&gt;search-blog&lt;/code&gt;, &lt;code&gt;get-post&lt;/code&gt;, &lt;code&gt;subscribe-newsletter&lt;/code&gt;, &lt;code&gt;submit-contact&lt;/code&gt;, and &lt;code&gt;mcp&lt;/code&gt;.&lt;/strong&gt; Four of those are callable over the live endpoint. The fifth, &lt;code&gt;mcp&lt;/code&gt;, is &lt;strong&gt;not an executable skill&lt;/strong&gt;. It is a pointer that says &quot;there is an MCP server at &lt;code&gt;https://rye.dev/mcp&lt;/code&gt;, go talk to it.&quot; I list it as a skill because the A2A card is the most likely place an agent already on the site looks first, and I&apos;d rather hand off cleanly than leave the MCP server undiscovered. But I&apos;m telling you here what the card can&apos;t: four do something, one points elsewhere.&lt;/p&gt;
&lt;h3&gt;The dotfile problem, which I will mention exactly once per file&lt;/h3&gt;
&lt;p&gt;Cloudflare Workers Static Assets &lt;strong&gt;will not serve any file under a literal &lt;code&gt;.well-known/&lt;/code&gt; directory.&lt;/strong&gt; The dotfile prefix is filtered out of the asset bundle. So nothing I just described could physically live where its URL says it lives.&lt;/p&gt;
&lt;p&gt;The workaround, which recurs across this entire surface: every well-known file lives under &lt;code&gt;public/wellknown/&lt;/code&gt; (no dot) and is exposed at &lt;code&gt;/.well-known/&lt;/code&gt; via a status-&lt;code&gt;200&lt;/code&gt; internal rewrite in &lt;code&gt;public/_redirects&lt;/code&gt;. The URL bar stays &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt;; the bytes come from &lt;code&gt;wellknown/&lt;/code&gt;. &lt;code&gt;public/_headers&lt;/code&gt; then layers on the CORS, &lt;code&gt;Content-Type&lt;/code&gt;, and cache headers. (For the namespace background on why &lt;code&gt;.well-known/&lt;/code&gt; is the right place for any of this, I wrote a whole post on &lt;a href=&quot;https://rye.dev/blog/well-known-uris-standardizing-web-metadata/&quot;&gt;well-known URIs&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;And because a card that lies about its skills is worse than no card, there&apos;s a drift-guard test (&lt;code&gt;tests/a2a-agent-card.test.ts&lt;/code&gt;) that asserts the card&apos;s skill descriptions match the agent-skills index &lt;em&gt;verbatim&lt;/em&gt;. The two cannot silently diverge.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/making-my-portfolio-agent-readable-visually-depicts-the-process-o-1781570108492.png&quot; alt=&quot;Two humanoid AI agents shaking hands beside a SHA256 padlock badge with a green checkmark, over a circuit-board background.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Skills an agent is told to verify before trusting&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;public/wellknown/agent-skills/index.json&lt;/code&gt; is the verification manifest: a pointer set for an agent already on the site, declaring &lt;code&gt;$schema https://schemas.agentskills.io/discovery/0.2.0/schema.json&lt;/code&gt;. It&apos;s a flat &lt;code&gt;skills&lt;/code&gt; array. Each entry has a &lt;code&gt;name&lt;/code&gt;, a &lt;code&gt;type&lt;/code&gt; of &lt;code&gt;&quot;skill-md&quot;&lt;/code&gt;, a &lt;code&gt;description&lt;/code&gt;, an absolute &lt;code&gt;url&lt;/code&gt; to a &lt;code&gt;SKILL.md&lt;/code&gt;, and the interesting one: a &lt;code&gt;digest&lt;/code&gt; of the form &lt;code&gt;sha256:&amp;lt;hex&amp;gt;&lt;/code&gt; computed over that &lt;code&gt;SKILL.md&lt;/code&gt;&apos;s bytes.&lt;/p&gt;
&lt;p&gt;Each skill is a &lt;code&gt;SKILL.md&lt;/code&gt; file: YAML frontmatter (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;) plus a freeform markdown body of instructions written for an agent to read and follow.&lt;/p&gt;
&lt;p&gt;The digest is the whole point, and it&apos;s the theme of this post wearing a hat. The discovery schema (&lt;strong&gt;v0.2.0&lt;/strong&gt;) carries a per-skill sha256 digest precisely so a client can verify the artifact bytes before trusting a skill. &lt;code&gt;scripts/agent-skills-digest.mjs&lt;/code&gt; (&lt;code&gt;pnpm agent-skills:digest&lt;/code&gt;) recomputes them; edit a &lt;code&gt;SKILL.md&lt;/code&gt; and forget to re-run it, and you ship a stale digest that a compliant agent will &lt;em&gt;reject&lt;/em&gt;. That is not a bug. That is the verification handshake doing its job, the same thing I said standards are actually good for. Not &quot;trust me because I&apos;m in the index,&quot; but &quot;here&apos;s the hash, check it yourself.&quot;&lt;/p&gt;
&lt;p&gt;The five skills split into three kinds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read skills.&lt;/strong&gt; &lt;code&gt;search-blog&lt;/code&gt; tells the agent to prefer the MCP &lt;code&gt;search_posts&lt;/code&gt; tool, fall back to fetching &lt;code&gt;llms-full.txt&lt;/code&gt; and grepping, then read a hit via MCP &lt;code&gt;get_post&lt;/code&gt; or &lt;code&gt;GET /blog/&amp;lt;slug&amp;gt;.md&lt;/code&gt;. &lt;code&gt;get-post&lt;/code&gt; describes &lt;code&gt;get_post { slug } → { slug, frontmatter, body, url }&lt;/code&gt;, or the plain &lt;code&gt;GET /blog/&amp;lt;slug&amp;gt;.md&lt;/code&gt; with &lt;code&gt;Accept: text/markdown&lt;/code&gt;, drafts excluded, response carrying that &lt;code&gt;x-markdown-tokens&lt;/code&gt; header. Reads are cheap and safe.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write skills, where it gets interesting.&lt;/strong&gt; &lt;code&gt;subscribe-newsletter&lt;/code&gt; is &lt;code&gt;POST /api/newsletter&lt;/code&gt; with &lt;code&gt;{ email, source }&lt;/code&gt;, double opt-in, returning &lt;code&gt;200 / 400 / 429&lt;/code&gt;. &lt;code&gt;submit-contact&lt;/code&gt; is &lt;code&gt;POST /api/contact&lt;/code&gt; with &lt;code&gt;{ name, email, subject, message }&lt;/code&gt; under field-length constraints. Both are rate limited and both are &lt;strong&gt;same-origin enforced&lt;/strong&gt;: the &lt;code&gt;Origin&lt;/code&gt; must be &lt;code&gt;https://rye.dev&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That same-origin rule creates a fork in the road, and the skills document both branches honestly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;in-browser&lt;/strong&gt; agent (something running as a page-side tool) inherits the page&apos;s Origin, so it uses the WebMCP &lt;code&gt;subscribe_newsletter&lt;/code&gt; / &lt;code&gt;submit_contact&lt;/code&gt; tools and sails through the check.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;out-of-browser&lt;/strong&gt; agent can&apos;t forge a same-origin request and shouldn&apos;t try, so the skill tells it to do the polite thing: send the human to &lt;code&gt;https://rye.dev/#newsletter&lt;/code&gt; or the contact form. The agent doesn&apos;t pretend to be the user. It hands the user back the wheel.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The mcp pointer.&lt;/strong&gt; The fifth &lt;code&gt;skill-md&lt;/code&gt; entry, &lt;code&gt;mcp&lt;/code&gt;, has its own &lt;code&gt;SKILL.md&lt;/code&gt; and its own digest, but it isn&apos;t callable over &lt;code&gt;/a2a&lt;/code&gt;. It describes connecting to the MCP server at &lt;code&gt;https://rye.dev/mcp&lt;/code&gt;, Streamable HTTP, stateless (no &lt;code&gt;Mcp-Session-Id&lt;/code&gt;), &lt;code&gt;protocolVersion 2025-06-18&lt;/code&gt;, six read-only tools: &lt;code&gt;list_posts&lt;/code&gt;, &lt;code&gt;search_posts&lt;/code&gt;, &lt;code&gt;get_post&lt;/code&gt;, &lt;code&gt;list_projects&lt;/code&gt;, &lt;code&gt;get_project&lt;/code&gt;, &lt;code&gt;get_about&lt;/code&gt;. Which brings us to the layer where things actually execute.&lt;/p&gt;
&lt;h2&gt;The endpoints that do something&lt;/h2&gt;
&lt;p&gt;Everything above is description. &lt;code&gt;/a2a&lt;/code&gt; is action.&lt;/p&gt;
&lt;p&gt;It is &lt;strong&gt;live, not a placeholder&lt;/strong&gt;: a server-rendered Astro route at &lt;code&gt;src/pages/a2a.ts&lt;/code&gt;, &lt;code&gt;prerender = false&lt;/code&gt;, speaking JSON-RPC 2.0, stateless and synchronous. It supports exactly &lt;strong&gt;one&lt;/strong&gt; method, &lt;code&gt;message/send&lt;/code&gt;; anything else gets a clean JSON-RPC &lt;code&gt;-32601&lt;/code&gt;. It never returns an A2A &lt;code&gt;Task&lt;/code&gt;: no streaming, no async job queue, which is precisely what the card&apos;s all-false capabilities promised.&lt;/p&gt;
&lt;p&gt;Dispatch works like this. The client sends a message whose &lt;code&gt;parts&lt;/code&gt; include a &lt;code&gt;DataPart&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{ &quot;kind&quot;: &quot;data&quot;, &quot;data&quot;: { &quot;skill_id&quot;: &quot;get-post&quot;, &quot;args&quot;: { &quot;slug&quot;: &quot;ai-slop-is-a-search-problem-now&quot; } } }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four skills are callable through it: &lt;code&gt;search-blog&lt;/code&gt;, &lt;code&gt;get-post&lt;/code&gt;, &lt;code&gt;subscribe-newsletter&lt;/code&gt;, &lt;code&gt;submit-contact&lt;/code&gt;. Send a text-only message with no &lt;code&gt;DataPart&lt;/code&gt; and you get a help reply listing exactly those four. No guessing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Code reuse is the entire architecture, not a detail.&lt;/strong&gt; The read skills call the same &lt;code&gt;searchPosts&lt;/code&gt; / &lt;code&gt;getPostBySlug&lt;/code&gt; functions the MCP server uses. The write skills call the &lt;em&gt;same&lt;/em&gt; action handlers as the &lt;code&gt;/api/newsletter&lt;/code&gt; and &lt;code&gt;/api/contact&lt;/code&gt; REST routes, which means they inherit the existing validation and rate limiting for free: &lt;strong&gt;newsletter 3/hour, contact 5/hour, both fail-closed, keyed by client IP.&lt;/strong&gt; I did not build a parallel A2A backend. I put a JSON-RPC face on the logic that already existed. One source of truth, three front doors (REST, MCP, A2A).&lt;/p&gt;
&lt;h3&gt;Being candid about the security posture&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/a2a&lt;/code&gt; is deliberately &lt;strong&gt;unauthenticated, with open CORS&lt;/strong&gt;: &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt;. I want to be straight about that rather than let you discover it.&lt;/p&gt;
&lt;p&gt;The reason is structural. My same-origin CSRF check is scoped to the &lt;code&gt;/api/&lt;/code&gt; prefix, and &lt;code&gt;/a2a&lt;/code&gt; lives outside it so that a third-party agent (which by definition can&apos;t present my Origin) isn&apos;t blocked at the door. Put it under &lt;code&gt;/api/&lt;/code&gt; and the CSRF guard would reject every legitimate external agent, which defeats the entire point of being reachable by agents a human sent.&lt;/p&gt;
&lt;p&gt;The honest tradeoff: &lt;strong&gt;anyone can POST to &lt;code&gt;/a2a&lt;/code&gt;.&lt;/strong&gt; It is an open endpoint. What protects me is not the transport but the fact that the only &lt;em&gt;write&lt;/em&gt; paths reuse the rate-limited, fail-closed handlers above. The read paths expose only already-public blog content, so they are intentionally not rate-limited. An open read endpoint over my own published writing is not a thing I need to defend. An open &lt;em&gt;write&lt;/em&gt; endpoint would be, which is why the writes keep their limits no matter which door they came through. That&apos;s hardening by design, not an oversight. I&apos;m telling you it&apos;s open because the design &lt;em&gt;expects&lt;/em&gt; it to be open.&lt;/p&gt;
&lt;p&gt;The sibling is &lt;code&gt;/mcp&lt;/code&gt; (&lt;code&gt;src/pages/mcp.ts&lt;/code&gt;), with its own server card at &lt;code&gt;/.well-known/mcp/server-card.json&lt;/code&gt;: &lt;code&gt;serverInfo.name &quot;rye.dev&quot;&lt;/code&gt;, title &quot;Cameron Rye Portfolio MCP Server&quot;, &lt;code&gt;streamable-http&lt;/code&gt; transport, capabilities &lt;code&gt;[&quot;tools&quot;]&lt;/code&gt;, the six read-only tools. The MCP card and the agent-skills index point at each other, so an agent that lands on either one finds the other.&lt;/p&gt;
&lt;h2&gt;The table of contents that holds it together&lt;/h2&gt;
&lt;p&gt;Five layers is a lot of surface. So there&apos;s a top-level map.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/.well-known/api-catalog&lt;/code&gt; is an &lt;strong&gt;RFC 9727&lt;/strong&gt; linkset, the table of contents for an agent that&apos;s already here. It links &lt;code&gt;service-desc&lt;/code&gt; → &lt;code&gt;/openapi.json&lt;/code&gt;, &lt;code&gt;service-doc&lt;/code&gt; → &lt;code&gt;/llms-full.txt&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt; → &lt;code&gt;/api/health&lt;/code&gt;, the MCP server → &lt;code&gt;/mcp&lt;/code&gt;, and the agent-skills index. It &lt;strong&gt;does not&lt;/strong&gt; list the OAuth or web-bot-auth files. Those are placeholders, and a catalog that advertised them would be lying. (More on that in a second.)&lt;/p&gt;
&lt;p&gt;Reinforcing the catalog, an &lt;strong&gt;RFC 8288&lt;/strong&gt; &lt;code&gt;Link&lt;/code&gt; header rides on responses (emitted statically in &lt;code&gt;_headers&lt;/code&gt; and dynamically on SSR responses in middleware) pointing at the rels for llmstxt.org, agentskills.io, modelcontextprotocol.io, and a2a-protocol.org. An agent that does nothing but read response headers still gets pointed at every entry point.&lt;/p&gt;
&lt;p&gt;And for the humans: the homepage has a &quot;Built for humans and AI agents&quot; section (driven by &lt;code&gt;src/data/agent-endpoints.ts&lt;/code&gt;) that just lists these endpoints in plain sight. I&apos;m not hiding the machine surface in the metadata. I&apos;m proud of it.&lt;/p&gt;
&lt;p&gt;The whole thing is kept honest by drift guards I&apos;ve already mentioned in passing: the card-versus-skills verbatim test, the digest recompute script, and build-time hash checks. The failure mode I&apos;m most afraid of is not &quot;no agent uses this.&quot; It&apos;s &quot;an agent uses this and I&apos;ve quietly lied to it.&quot; The guards exist so I can&apos;t.&lt;/p&gt;
&lt;h2&gt;So does anything actually consume this yet?&lt;/h2&gt;
&lt;p&gt;Honest answer: mostly nothing.&lt;/p&gt;
&lt;p&gt;No major platform is fetching my &lt;code&gt;llms.txt&lt;/code&gt; or my agent card on its own. I said that months ago and nothing has changed. The thing that &lt;em&gt;does&lt;/em&gt; consume this surface is my own tooling (Ask reads the clean content, my MCP and A2A endpoints reuse it) plus whatever agent a visitor decides to point at the site today. That&apos;s it. That&apos;s the honest scorecard.&lt;/p&gt;
&lt;p&gt;And it reconciles perfectly with what I argued before, because &lt;strong&gt;I never promised this would bring traffic.&lt;/strong&gt; It won&apos;t. This is not a discovery play; I retired that idea in print. These are the exact benefits I pre-committed to in &lt;a href=&quot;https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/&quot;&gt;the llms.txt piece&lt;/a&gt;, and they still hold: it&apos;s trivial to generate when you already write in markdown; it signals technical care to anyone who looks; it lets me experiment with these protocols before they matter; it prepares me for &lt;em&gt;if&lt;/em&gt; adoption ever comes; and (the one I underrated) the markdown twins double as genuinely clean documentation of my own site. The work pays for itself the day I build it, regardless of who shows up.&lt;/p&gt;
&lt;p&gt;The reframe from the slop essay holds all the way down. These standards are not how an agent &lt;em&gt;finds&lt;/em&gt; me. They&apos;re how an agent that a human already pointed here can &lt;em&gt;read me cleanly, understand my structure, verify what it&apos;s about to trust, and act through a door I deliberately left open.&lt;/em&gt; Usability and verification. Not SEO.&lt;/p&gt;
&lt;p&gt;Which leaves exactly one question unanswered, and it&apos;s the good one.&lt;/p&gt;
&lt;p&gt;Everything here answers &lt;em&gt;what can an agent read and do on this site.&lt;/em&gt; It says nothing about &lt;em&gt;who is this agent, and should I trust it.&lt;/em&gt; My &lt;code&gt;/a2a&lt;/code&gt; endpoint is open; right now it has no idea whether the thing POSTing to it is a research assistant or a scraper wearing a trench coat. There are already placeholders sitting in &lt;code&gt;/.well-known/&lt;/code&gt; for the answer: an &lt;code&gt;http-message-signatures-directory&lt;/code&gt; publishing an Ed25519 public key for Web Bot Auth, and OAuth discovery metadata under RFC 8414 / RFC 9728. I&apos;ll be blunt about their status, because it&apos;s the same honesty the digests demand: &lt;strong&gt;the key is published but no request signing is implemented, and there is no OAuth server behind that metadata.&lt;/strong&gt; The site cannot authenticate a bot or run an OAuth flow today. The doors are framed; the locks aren&apos;t installed.&lt;/p&gt;
&lt;p&gt;Installing the locks (agent identity and verification) is the next post.&lt;/p&gt;
&lt;p&gt;For now I&apos;ve built an interface, told the truth about every corner of it, and left it open for the agents a human brings. That was always the realistic goal. Not to be discovered.&lt;/p&gt;
&lt;p&gt;To be usable once you arrive.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Companion essays:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/&quot;&gt;The /llms.txt Standard: An Elegant Solution Nobody&apos;s Using&lt;/a&gt;. The prequel: why no major platform reads these files, and why I built them anyway.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/ai-slop-is-a-search-problem-now/&quot;&gt;AI Slop Is a Search Problem Now&lt;/a&gt;. The reframe this whole post stands on: standards as a way to be &lt;em&gt;verified&lt;/em&gt;, not discovered.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/well-known-uris-standardizing-web-metadata/&quot;&gt;Standardizing Web Metadata with Well-Known URIs&lt;/a&gt;. The &lt;code&gt;.well-known/&lt;/code&gt; namespace background behind the agent card and api-catalog.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/building-ask-rag-portfolio-chatbot/&quot;&gt;Building Ask: A RAG-Powered Portfolio Chatbot&lt;/a&gt;. The conversational counterpart to this static, declarative surface.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>ai</category><category>agents</category><category>web-standards</category><category>mcp</category><category>protocols</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/making-my-portfolio-agent-readable-a-visual-metaphor-for-the-shif-featured-1781570042816.png" length="0" type="image/png"/></item><item><title>AI Slop Is a Search Problem Now</title><link>https://rye.dev/blog/ai-slop-is-a-search-problem-now/</link><guid isPermaLink="true">https://rye.dev/blog/ai-slop-is-a-search-problem-now/</guid><description>We keep blaming AI-generated content for poisoning the web. But the slop is downstream of a market shift: search stopped sending users to publishers, publishers stopped being able to fund human writing, and AI filled the gap. The diagnosis matters.</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/ai-slop-is-a-search-problem-now-abstract-3d-illustration-showi-featured-1779668002373.png&quot; alt=&quot;Abstract 3D illustration of a search engine funneling diverse web content into a uniform, monolithic AI output&quot; /&gt;&lt;/p&gt;&lt;p&gt;Ask Google &quot;what temperature should I roast a chicken at,&quot; and Google answers you. There&apos;s a confident paragraph at the top of the page. There are no clicks. The recipe site that taught Google the answer doesn&apos;t know you exist. Its ad inventory served nothing. Its newsletter signup didn&apos;t fire. The cooking writer who tested four roasting temperatures and wrote about it three years ago isn&apos;t compensated, credited, or even visible above the fold.&lt;/p&gt;
&lt;p&gt;This is the new search bargain, and we&apos;re naming the wrong thing when we call its byproduct &quot;AI slop.&quot;&lt;/p&gt;
&lt;p&gt;&quot;AI slop&quot; (Merriam-Webster&apos;s, the American Dialect Society&apos;s, and Macquarie Dictionary&apos;s word of the year for 2025) gets used to mean &lt;em&gt;low-effort, AI-generated content polluting the internet&lt;/em&gt;. That definition treats the problem as a supply issue: too many machines making too much junk. The implication is that if the supply tightens (better detectors, watermarking, AI labels, content moderation), the problem subsides.&lt;/p&gt;
&lt;p&gt;It won&apos;t. The supply side is downstream of a demand-side break that happened first. Search stopped sending users to publishers. Publishers stopped being able to afford humans. AI filled the gap. Search now answers from a corpus increasingly written by AI to be summarized by AI. That&apos;s not a content problem you can solve by writing better content. It&apos;s a search problem.&lt;/p&gt;
&lt;h2&gt;What the Numbers Actually Say&lt;/h2&gt;
&lt;p&gt;Let&apos;s get the data straight, because the figure that&apos;s been floating around, 34.5%, is now twelve months out of date.&lt;/p&gt;
&lt;p&gt;That number came from an Ahrefs study in April 2025 which compared CTR for top-ranking pages before and after AI Overviews appeared on a query. By February 2026, &lt;a href=&quot;https://ahrefs.com/blog/ai-overviews-reduce-clicks-update/&quot;&gt;Ahrefs&apos; follow-up study&lt;/a&gt; using December 2025 data put the click reduction at &lt;strong&gt;58%&lt;/strong&gt;. Almost double, in eight months.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.pewresearch.org/short-reads/2025/07/22/google-users-are-less-likely-to-click-on-links-when-an-ai-summary-appears-in-the-results/&quot;&gt;Pew Research&lt;/a&gt;, working independently, came to a similar place from a different angle. Their July 2025 study tracked the actual browsing behavior of 900 U.S. adults: 68,879 unique Google searches in March 2025, of which 12,593 surfaced an AI summary.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Users who saw an AI summary clicked a traditional search result in &lt;strong&gt;8% of visits&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Users who didn&apos;t see one clicked in &lt;strong&gt;15% of visits&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Clicks on links &lt;em&gt;inside&lt;/em&gt; the AI summary itself: &lt;strong&gt;1% of visits&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When an AI summary appears, the chance any single visitor clicks anything at all is around 9%. Without one, it&apos;s 15%. Almost half the would-be clicks evaporate.&lt;/p&gt;
&lt;p&gt;The publisher-side data tracks. Chartbeat, monitoring traffic across 2,500+ news sites globally, reported a 33% decline in Google search referrals across 2025. Digital Content Next (the trade association for major publishers) surveyed its members and found most reporting 1–25% traffic losses, with some exceeding 75%. As of early 2026, approximately &lt;strong&gt;58% of Google searches end in zero clicks&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;And the response from publishers has stopped being polite. Penske Media (Rolling Stone, Billboard, The Hollywood Reporter) filed a federal antitrust suit against Google in September 2025, with a 56-page opposition to dismissal in February 2026. The argument is creative: not copyright infringement, but anticompetitive coercion. Google&apos;s search monopoly, Penske argues, forces a &quot;forced choice&quot;: let your content train AI Overviews that cannibalize your traffic, or be excluded from search entirely. The European Publishers Council filed a parallel complaint with similar framing. One-third of publishers surveyed in early 2026 said they plan to block AI Overviews the moment tools become available.&lt;/p&gt;
&lt;p&gt;Google&apos;s response was to ship &quot;Further Exploration,&quot; a small section of curated links at the bottom of AI Overview answers, designed to send some traffic back. It&apos;s a thermostat on a burning house.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/ai-slop-is-a-search-problem-now-a-severed-glowing-cable-repres-1779668022694.png&quot; alt=&quot;A severed glowing cable representing the broken traffic link and lost clicks between search engines and web publishers.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Supply-Side Reading Misses the Diagnosis&lt;/h2&gt;
&lt;p&gt;The dominant framing of &quot;AI slop&quot; goes like this: generative AI made content creation effectively free, content farms exploit this, search engines fail to filter it, the open web fills with low-value junk. From this view, the cure is supply control: better classifiers, mandatory disclosure, platform moderation, &quot;AI-free&quot; certifications.&lt;/p&gt;
&lt;p&gt;This framing isn&apos;t wrong, but it&apos;s incomplete in a way that matters. It treats the AI-generated content surge as an exogenous shock, something that happened &lt;em&gt;to&lt;/em&gt; the web from outside. The supply surge isn&apos;t autonomous. It&apos;s a rational response to a price signal.&lt;/p&gt;
&lt;p&gt;Until roughly 2023, the implicit deal was: write content, get ranked, get clicks, monetize via ads or conversions, fund more writing. That&apos;s the bargain that paid for recipe blogs, product reviews, local journalism, and most of the long tail of the web. Each click had a value. Each post had an expected return.&lt;/p&gt;
&lt;p&gt;AI Overviews break the third step. Clicks per ranking position are falling fast, by every measure we have. But the cost of producing content didn&apos;t fall with them, and human writers didn&apos;t suddenly become cheaper. So what happens to a business whose revenue per article is dropping but whose cost per article is constant?&lt;/p&gt;
&lt;p&gt;It writes fewer articles, or it writes cheaper articles. Most chose cheaper.&lt;/p&gt;
&lt;p&gt;AI lets you produce a thousand SEO-optimized listicles for the cost of one freelance assignment. The margin on each individual page is awful, but it scales. The economics that produce &quot;slop&quot; aren&apos;t the economics of malice or laziness. They&apos;re the economics of a publisher trying to survive a 33% collapse in search referrals (per Chartbeat) without going out of business.&lt;/p&gt;
&lt;p&gt;The supply of AI content isn&apos;t a content problem. It&apos;s a &lt;em&gt;response&lt;/em&gt; to the search problem.&lt;/p&gt;
&lt;h2&gt;The Spiral&lt;/h2&gt;
&lt;p&gt;Once you see the loop, it&apos;s hard to unsee:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Answer engines (Google AI Overviews, Bing Copilot, Perplexity, the chat surface in every major LLM product) answer queries in-place, using publisher content as substrate.&lt;/li&gt;
&lt;li&gt;Publishers&apos; click-driven revenue collapses. Pew&apos;s 8%-vs-15% means the median page is clicked roughly half as often when an AI summary appears. Top-ranking pages, per Ahrefs, are clicked at 42% of their pre-AIO rate.&lt;/li&gt;
&lt;li&gt;Publishers respond by lowering the unit cost of content. Some lay off staff. Some outsource. Many shift to AI-assisted production: a human edits a generated draft instead of researching and writing one.&lt;/li&gt;
&lt;li&gt;The corpus answer engines train on and summarize from increasingly consists of AI-assisted content optimized to be ranked and summarized.&lt;/li&gt;
&lt;li&gt;The summaries get blander, more derivative, more confidently wrong. Users notice. Trust erodes.&lt;/li&gt;
&lt;li&gt;Search platforms respond by amplifying the AI surface further, since their own AI answer feels more authoritative than the slop substrate beneath it.&lt;/li&gt;
&lt;li&gt;Loop.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each cycle of this loop makes the next cycle cheaper for the platform, more expensive for the publisher, and worse for the user. It&apos;s not a stable equilibrium. It&apos;s a tightening spiral.&lt;/p&gt;
&lt;p&gt;The Cloudflare data point that drives this home: per Cloudflare&apos;s own analysis, &lt;strong&gt;Anthropic&apos;s ClaudeBot crawls 20,583 pages for every single referral it returns to a publisher&lt;/strong&gt;. That is the ratio of extraction to acknowledgement in the current AI/web relationship. It&apos;s not a small asymmetry. It is, functionally, a one-way valve.&lt;/p&gt;
&lt;p&gt;The Reuters Institute&apos;s &lt;em&gt;Journalism, Media, and Technology Trends and Predictions 2026&lt;/em&gt; puts it tactfully: publishers plan to focus on &quot;investigative journalism, analysis, and distinctive reporting&quot; while &quot;reducing investment in more routine content.&quot; Translated: humans will write the things only humans can defensibly write, and everything else becomes machine work that nobody pays for, that nobody clicks, and that everyone produces anyway because the alternative is producing nothing and dying faster.&lt;/p&gt;
&lt;p&gt;That&apos;s the slop, and it isn&apos;t a moral failing of content producers. It&apos;s the equilibrium of a market whose price signal got rewired.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/ai-slop-is-a-search-problem-now-an-infinite-downward-spiral-sh-1779668040141.jpg&quot; alt=&quot;An infinite downward spiral showing crisp data degrading into bland spheres, representing the AI training feedback loop.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Why Standards Can&apos;t Fix This&lt;/h2&gt;
&lt;p&gt;I wrote about &lt;a href=&quot;https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/&quot;&gt;&lt;code&gt;/llms.txt&lt;/code&gt;&lt;/a&gt; last September and concluded it was an elegant solution nobody is using. I want to be specific about &lt;em&gt;why&lt;/em&gt; it isn&apos;t used, because the same reasoning will apply to every voluntary standard proposed to &quot;fix&quot; AI search.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/llms.txt&lt;/code&gt; assumes a cooperative relationship between publisher and platform. The publisher creates a curated, AI-friendly version of their content. The platform reads it and respects the curation. Both win.&lt;/p&gt;
&lt;p&gt;That assumption is dead. Platforms have no reason to honor a publisher&apos;s curation, because the platform&apos;s incentive is not to send users to publishers. The platform&apos;s incentive is to keep users inside the platform. Reading &lt;code&gt;/llms.txt&lt;/code&gt; to surface a publisher&apos;s preferred summary, then linking out to that publisher, would directly reduce the platform&apos;s most important metric (time-on-Google, in Google&apos;s case). Why would they?&lt;/p&gt;
&lt;p&gt;The same argument disqualifies most variants. AI-bot-only robots.txt directives? Honored selectively: most major bots respect them, some don&apos;t, and the ones that don&apos;t are unaccountable. Schema.org annotations specific to AI consumption? Same incentive problem. A new HTTP header that signals &quot;compensate me to summarize me&quot;? The platform would need a reason to read it. There isn&apos;t one.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://rye.dev/blog/well-known-uris-standardizing-web-metadata/&quot;&gt;Well-Known URIs standard&lt;/a&gt; (&lt;code&gt;/.well-known/security.txt&lt;/code&gt;, &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt;, the IETF&apos;s tidy little namespace) works because the parties on both sides &lt;em&gt;want&lt;/em&gt; the discovery to succeed. A security researcher and a website owner both benefit from a working &lt;code&gt;security.txt&lt;/code&gt;. An OAuth client and an identity provider both benefit from &lt;code&gt;openid-configuration&lt;/code&gt;. Coordination problems get solved when incentives align.&lt;/p&gt;
&lt;p&gt;Publisher and AI platform incentives don&apos;t align. There is no standard you can write that fixes a structural conflict between two parties who would prefer the other to disappear.&lt;/p&gt;
&lt;h2&gt;So the AI Is Poisoning the Well It Drinks From&lt;/h2&gt;
&lt;p&gt;This is where the second-order effect gets interesting. AI Overviews are trained on, and summarize from, a corpus increasingly produced to be summarized. The model is reading text that was written by a model to optimize for being read by a model. There&apos;s a name for that (model collapse, technically), but you don&apos;t need a paper to see it. Search results have gotten genuinely worse over the last two years, and the worsening isn&apos;t subtle.&lt;/p&gt;
&lt;p&gt;Cloudflare&apos;s Q1 2026 robots.txt analysis found that &lt;strong&gt;89.4% of AI crawler traffic serves training or mixed purposes, not search&lt;/strong&gt;. That asymmetry matters. The web isn&apos;t being read to be indexed and referred to. It&apos;s being read to be ingested, distilled, and returned without attribution. The Reuters Institute&apos;s tracking of AI-generated content in fact-checked claims rose from 7% of cases in 2024 to 16% in 2025, and that&apos;s just the cases where someone bothered to file a fact-check. The actual prevalence is higher, because most slop doesn&apos;t trigger a check; it just sits in the substrate, doing search-engine-optimization work, training the next generation of summaries.&lt;/p&gt;
&lt;p&gt;The slop isn&apos;t sitting in a separate quarantine the AI can ignore. It &lt;em&gt;is&lt;/em&gt; the AI&apos;s input.&lt;/p&gt;
&lt;h2&gt;The Exits&lt;/h2&gt;
&lt;p&gt;If standards can&apos;t fix it and the spiral is self-reinforcing, what&apos;s actually left?&lt;/p&gt;
&lt;p&gt;A few things, and none of them are search.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Direct subscription.&lt;/strong&gt; Newsletters. &lt;a href=&quot;https://rye.dev/blog/rss-miniflux-2026/&quot;&gt;RSS&lt;/a&gt;. Bookmarks. The relationships where the reader decides what they read, and the publisher knows their reader exists. These don&apos;t scale the way search did, and that&apos;s precisely what makes them defensible. Algorithms can&apos;t disintermediate a relationship the user formed directly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Paid relationships.&lt;/strong&gt; Substack, Ghost, Patreon, individual paid newsletters, the whole micropayment-adjacent ecosystem. Click-driven advertising was always a fragile foundation; AI Overviews just clarified how fragile. The publishers most insulated from this collapse are the ones whose revenue comes from a reader&apos;s deliberate decision, not from incidental ad impressions during a search journey.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Infrastructure pushback.&lt;/strong&gt; Cloudflare flipped its default in mid-2025: new customers get AI crawlers blocked unless they opt in, and &quot;pay-per-crawl&quot; exists for those who want a compensation channel. Cloudflare hosts roughly 20% of the web. When the substrate provider changes the default, the negotiation changes. This is the first piece of structural leverage publishers have had since AI Overviews shipped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Trust signals where discovery used to be.&lt;/strong&gt; This is where standards still matter, not as a way to be discovered but as a way to be &lt;em&gt;verified&lt;/em&gt; once a user has found you. &lt;code&gt;/.well-known/security.txt&lt;/code&gt;, &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt;, signed RSS feeds, verified author identity, ATProto handles, Keyoxide profiles. The post-search internet still needs trust infrastructure. It just doesn&apos;t need it to function as a search funnel.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/ai-slop-is-a-search-problem-now-a-network-diagram-showing-brig-1779668058404.jpg&quot; alt=&quot;A network diagram showing bright direct connections bypassing a dark central hub, representing direct publisher-to-reader relationships.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;What This Means If You&apos;re Building Something on the Web&lt;/h2&gt;
&lt;p&gt;Three implications I&apos;d take seriously if I were starting a project right now:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Design for zero referral.&lt;/strong&gt; Assume the search referral to your project is going to keep dropping. Build something that survives at 10% of today&apos;s discovery traffic. If your business model only works at 100%, your business model isn&apos;t a business model. It&apos;s an artifact of a market structure that&apos;s being dismantled in real time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treat the front page as the relationship.&lt;/strong&gt; Newsletter signups, RSS subscribe buttons, and follow-on-Mastodon/Bluesky links aren&apos;t 2010 decorations. They&apos;re how someone who finds you once continues to find you. The home page of your site should optimize for the conversion from anonymous visit to known reader with the same seriousness that 2015 sites optimized for visit to page view.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build for the AI surfaces, but don&apos;t depend on them.&lt;/strong&gt; Yes, your content will be ingested. Yes, summaries will appear without your link. You can&apos;t opt out and expect to remain visible, and you can&apos;t opt in and expect to be compensated. Optimize for being &lt;em&gt;recognizable inside&lt;/em&gt; a summary (distinctive voice, real expertise, claims that are hard to compress) so that the readers who care about provenance will look for the source. That&apos;s a tiny fraction of readers. It&apos;s the fraction you can actually keep.&lt;/p&gt;
&lt;h2&gt;A Note on Optimism&lt;/h2&gt;
&lt;p&gt;There&apos;s a version of this post that ends on a confident note about the open web reclaiming itself, RSS triumphant, search dethroned by trust-based discovery. I don&apos;t believe that version. The platforms that built the click-based bargain are larger, better-capitalized, and more entrenched than ever, and the AI surfaces are still in early innings. Things will get worse before they get different.&lt;/p&gt;
&lt;p&gt;But &quot;different&quot; is the operative word. The web didn&apos;t go away when Google Reader died, when Twitter/X tilted, when Facebook hid links. It rerouted. Each rerouting cost something (visibility, breadth, frictionless discovery) and produced something else: smaller, slower, more deliberately chosen networks of readers and writers.&lt;/p&gt;
&lt;p&gt;The current rerouting will produce another one of those. It will be smaller than the search-driven web ever was. It will be unrecognizable as a &quot;market&quot; by 2015 standards. And if you&apos;re a person who writes things you&apos;d rather not see compressed, paraphrased, and served at the top of someone else&apos;s search results, it might also be the only web worth being on.&lt;/p&gt;
&lt;h2&gt;Coda&lt;/h2&gt;
&lt;p&gt;The &quot;AI slop&quot; framing is a comfortable diagnosis. It locates the problem in bad actors making bad content, which means there&apos;s someone to blame and something to filter. The real diagnosis is less comfortable: the search infrastructure the web monetized itself through changed its function from &lt;em&gt;router&lt;/em&gt; to &lt;em&gt;answerer&lt;/em&gt;, and everything downstream of that change, including the slop, is a market responding rationally.&lt;/p&gt;
&lt;p&gt;You can&apos;t fix that with standards. You can&apos;t fix it with quality controls on content production. You can only route around it.&lt;/p&gt;
&lt;p&gt;So route.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Sources and further reading:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.pewresearch.org/short-reads/2025/07/22/google-users-are-less-likely-to-click-on-links-when-an-ai-summary-appears-in-the-results/&quot;&gt;Pew Research Center: Google users are less likely to click on links when an AI summary appears&lt;/a&gt; (July 2025, n=900, 68,879 searches)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ahrefs.com/blog/ai-overviews-reduce-clicks-update/&quot;&gt;Ahrefs: AI Overviews reduce clicks by 58%&lt;/a&gt; (February 2026 update)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.searchenginejournal.com/antitrust-filing-says-google-cannibalizes-publisher-traffic/567535/&quot;&gt;Search Engine Journal: Antitrust filing says Google cannibalizes publisher traffic&lt;/a&gt; (Penske Media v. Google)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://reutersinstitute.politics.ox.ac.uk/journalism-media-and-technology-trends-and-predictions-2026&quot;&gt;Reuters Institute: Journalism, Media, and Technology Trends and Predictions 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.transparencycoalition.ai/news/cloudflare-becomes-first-infrastructure-provider-to-block-ai-crawlers-by-default&quot;&gt;Transparency Coalition: Cloudflare blocks AI crawlers by default&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://thenextweb.com/news/google-ai-overviews-publisher-links-search-traffic&quot;&gt;The Next Web: Google updates AI Overviews with &quot;Further Exploration&quot; as 58% click decline triggers antitrust suits&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Companion essays in this trilogy:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/&quot;&gt;The /llms.txt Standard: An Elegant Solution Nobody&apos;s Using&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/rss-miniflux-2026/&quot;&gt;RSS Is Still Great (and Miniflux Is the Tool You Need)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/blog/well-known-uris-standardizing-web-metadata/&quot;&gt;Well-known URIs: Standardizing Web Metadata Discovery&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>ai</category><category>search</category><category>web-standards</category><category>open-web</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/ai-slop-is-a-search-problem-now-abstract-3d-illustration-showi-featured-1779668002373.png" length="0" type="image/png"/></item><item><title>Building Aranet: A Rust Toolkit for Liberating Environmental Sensor Data</title><link>https://rye.dev/blog/building-aranet-rust-ble-environmental-monitoring/</link><guid isPermaLink="true">https://rye.dev/blog/building-aranet-rust-ble-environmental-monitoring/</guid><description>How I built a seven-crate Rust workspace that reads Aranet CO2, radon, and radiation sensors over Bluetooth LE and stores the data locally, no cloud required.</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/aranet/tui-demo-poster.png&quot; alt=&quot;The aranet-tui terminal dashboard showing real-time CO2, temperature, and humidity sparkline charts from multiple Aranet sensors&quot; /&gt;&lt;/p&gt;&lt;p&gt;My Aranet4 has been sitting on my desk for over a year. It&apos;s a good sensor: accurate CO2, temperature, humidity, and barometric pressure readings in a compact e-ink package. But every time I wanted to check my data, I had to pull out my phone, open the Aranet app, wait for a Bluetooth connection, and scroll through a clunky interface that couldn&apos;t export anything useful.&lt;/p&gt;
&lt;p&gt;The data was mine. I just couldn&apos;t get to it.&lt;/p&gt;
&lt;p&gt;So I built a Rust workspace that talks directly to Aranet sensors over Bluetooth Low Energy, stores readings locally in SQLite, and exposes everything through the interfaces I actually want: a CLI, a terminal dashboard, a desktop GUI, a REST API, Prometheus metrics, MQTT, and webhooks. Seven crates. Zero cloud dependencies.&lt;/p&gt;
&lt;h2&gt;Why Environmental Monitoring Matters&lt;/h2&gt;
&lt;p&gt;The hardware hacking is the fun part, but the reason I bothered is health. Indoor CO2 levels have a measurable impact on cognitive performance. Studies show that levels above 1,000 ppm, common in poorly ventilated offices and bedrooms, can reduce decision-making ability by 11-23%. Above 2,500 ppm, cognitive function drops dramatically.&lt;/p&gt;
&lt;p&gt;Radon is even more consequential. It&apos;s the second leading cause of lung cancer and you can&apos;t detect it without a sensor. The EPA estimates that radon causes about 21,000 lung cancer deaths per year in the US alone.&lt;/p&gt;
&lt;p&gt;The sensors exist. Aranet makes excellent ones. But the pipeline between sensor and useful data was broken. It was locked behind a mobile app with no automation, no local storage, and no integration path for the monitoring infrastructure I already run.&lt;/p&gt;
&lt;h2&gt;Starting at the Bottom: BLE Protocol Reverse Engineering&lt;/h2&gt;
&lt;p&gt;The first challenge was understanding how Aranet devices communicate over Bluetooth Low Energy. There&apos;s no official protocol documentation. The &lt;a href=&quot;https://github.com/Anrijs/Aranet4-Python&quot;&gt;Aranet4-Python&lt;/a&gt; project provided a starting point, but I needed to support four device families (Aranet4, Aranet2, AranetRn+, and Aranet Radiation), each with different data formats.&lt;/p&gt;
&lt;p&gt;BLE devices broadcast advertisements: small packets of data that nearby receivers can pick up without establishing a connection. Aranet sensors embed their current readings in these advertisements, which means you can monitor them passively.&lt;/p&gt;
&lt;p&gt;Here&apos;s where the protocol gets interesting. Aranet4 was the first device and its advertisement format doesn&apos;t include a device-type prefix. Later devices (Aranet2, AranetRn+, Aranet Radiation) prepend a type byte. The parser has to handle this inconsistency:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pub fn parse_advertisement_with_name(
    data: &amp;amp;[u8],
    name: Option&amp;lt;&amp;amp;str&amp;gt;,
) -&amp;gt; Result&amp;lt;AdvertisementData&amp;gt; {
    let is_aranet4_by_name = name
        .map(|n| n.starts_with(&quot;Aranet4&quot;))
        .unwrap_or(false);
    let is_aranet4_by_len = data.len() == 7 || data.len() == 22;

    let (device_type, sensor_data) = if is_aranet4_by_name || is_aranet4_by_len {
        // Aranet4: no device-type prefix — detect by name or data length
        (DeviceType::Aranet4, data)
    } else {
        let device_type = match data[0] {
            0x01 =&amp;gt; DeviceType::Aranet2,
            0x02 =&amp;gt; DeviceType::AranetRadiation,
            0x03 =&amp;gt; DeviceType::AranetRadon,
            other =&amp;gt; return Err(Error::InvalidData(
                format!(&quot;Unknown device type byte: 0x{:02X}&quot;, other),
            )),
        };
        (device_type, &amp;amp;data[1..])
    };
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the kind of firmware quirk you only discover by sniffing packets. The Aranet4 was designed before multi-device support existed, so its format is a special case forever. Handling it cleanly rather than with a hack mattered here: this code runs on every single BLE advertisement the system receives.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/aranet/cli-scan.gif&quot; alt=&quot;Scanning for nearby Aranet devices over Bluetooth LE with the aranet CLI.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Dealing with BLE&apos;s Unreliability&lt;/h2&gt;
&lt;p&gt;Anyone who&apos;s worked with Bluetooth knows it&apos;s flaky. Devices disappear, connections drop, scans return empty. A monitoring tool that crashes or hangs when BLE misbehaves is useless.&lt;/p&gt;
&lt;p&gt;The scanner uses exponential backoff with a cap, retrying both failed scans and empty results:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pub async fn scan_with_retry(
    options: ScanOptions,
    max_retries: u32,
    retry_on_empty: bool,
) -&amp;gt; Result&amp;lt;Vec&amp;lt;DiscoveredDevice&amp;gt;&amp;gt; {
    let mut attempt = 0;
    let mut delay = Duration::from_millis(500);

    loop {
        match scan_with_options(options.clone()).await {
            Ok(devices) if devices.is_empty()
                &amp;amp;&amp;amp; retry_on_empty
                &amp;amp;&amp;amp; attempt &amp;lt; max_retries =&amp;gt;
            {
                attempt += 1;
                warn!(&quot;No devices found, retrying ({}/{})...&quot;, attempt, max_retries);
                sleep(delay).await;
                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
            }
            Ok(devices) =&amp;gt; return Ok(devices),
            Err(e) if attempt &amp;lt; max_retries =&amp;gt; {
                attempt += 1;
                warn!(&quot;Scan failed ({}), retrying ({}/{})...&quot;, e, attempt, max_retries);
                sleep(delay).await;
                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
            }
            Err(e) =&amp;gt; return Err(e),
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;saturating_mul&lt;/code&gt; prevents overflow on the delay, and the 5-second cap keeps retries responsive. The &lt;code&gt;retry_on_empty&lt;/code&gt; flag matters: sometimes you want to distinguish &quot;no devices nearby&quot; from &quot;BLE stack isn&apos;t ready yet.&quot; This is a small function, but it&apos;s the difference between a tool that works on a bench and one that works in production.&lt;/p&gt;
&lt;p&gt;On Linux, there&apos;s an additional challenge: BlueZ (the Linux Bluetooth stack) can trigger pairing dialogs that hang BLE operations indefinitely. The core library automatically registers a BlueZ agent to suppress these prompts. These are the kind of platform-specific sharp edges that take longer to debug than the core protocol work.&lt;/p&gt;
&lt;h2&gt;The Seven-Crate Architecture&lt;/h2&gt;
&lt;p&gt;Environmental monitoring spans a surprisingly deep stack: hardware communication, data persistence, multiple user interfaces, and integration with external systems. Cramming all of that into a single crate would be unmaintainable. Splitting it into seven crates with clear boundaries keeps each piece focused and testable.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;aranet/
├── crates/
│   ├── aranet-types/    # Platform-agnostic shared types
│   ├── aranet-core/     # BLE communication + protocol parsing
│   ├── aranet-store/    # SQLite persistence + sync logic
│   ├── aranet-service/  # REST API, WebSocket, MQTT, Prometheus
│   ├── aranet-cli/      # Command-line interface
│   ├── aranet-tui/      # Terminal dashboard (ratatui)
│   └── aranet-gui/      # Desktop GUI (egui)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dependency graph flows strictly downward. &lt;code&gt;aranet-types&lt;/code&gt; has no dependencies on other workspace crates. &lt;code&gt;aranet-core&lt;/code&gt; depends only on &lt;code&gt;aranet-types&lt;/code&gt;. &lt;code&gt;aranet-store&lt;/code&gt; depends on &lt;code&gt;aranet-types&lt;/code&gt; and &lt;code&gt;aranet-core&lt;/code&gt;. The three interface crates (&lt;code&gt;cli&lt;/code&gt;, &lt;code&gt;tui&lt;/code&gt;, &lt;code&gt;gui&lt;/code&gt;) and &lt;code&gt;aranet-service&lt;/code&gt; sit at the top, consuming the lower layers.&lt;/p&gt;
&lt;p&gt;This means adding a new interface (say, a web dashboard or a Home Assistant component) requires zero changes to the sensor communication or storage layers. The separation also means each crate compiles independently, which matters when cross-compiling for ARM targets like a Raspberry Pi.&lt;/p&gt;
&lt;h2&gt;Local-First Data: SQLite and Incremental Sync&lt;/h2&gt;
&lt;p&gt;Aranet devices store history in an onboard ring buffer. Downloading that history over BLE is slow, since each record requires a round-trip. Re-downloading everything on every sync would be painful.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;aranet-store&lt;/code&gt; crate tracks sync progress per device. On the first sync, it downloads all records. On subsequent syncs, it calculates the start index from the last checkpoint and only fetches new records:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/// Incremental Sync Algorithm:
///
/// 1. Read device&apos;s current `total_readings` count
/// 2. Call `Store::calculate_sync_start` to get start index
/// 3. Download records from `start_index` to `total_readings`
/// 4. Call `Store::update_sync_state` to save progress
///
/// First sync downloads all 500 records:
///   let start = store.calculate_sync_start(&quot;Aranet4 17C3C&quot;, 500)?;
///   assert_eq!(start, 1);
///
/// Next sync — device now has 510 records:
///   let start = store.calculate_sync_start(&quot;Aranet4 17C3C&quot;, 510)?;
///   assert_eq!(start, 501); // Only download 10 new records
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Records are deduplicated at the SQLite level by &lt;code&gt;(device_id, timestamp)&lt;/code&gt; pairs. This handles edge cases where the ring buffer wraps around or the device resets, so you never get duplicate readings in your local store.&lt;/p&gt;
&lt;p&gt;The local-first approach is deliberate. Your data lives on your machine, in a standard SQLite database you can query with any tool. No account required. No API rate limits. No vendor deciding to shut down or change pricing. If the Aranet mobile app disappeared tomorrow, this toolkit wouldn&apos;t notice.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/aranet/cli-read.gif&quot; alt=&quot;Reading current measurements from an Aranet4 sensor.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Terminal Dashboard&lt;/h2&gt;
&lt;p&gt;The CLI handles one-off reads and scripting, but for day-to-day monitoring I wanted something I could leave running in a tmux pane. The TUI, built with &lt;a href=&quot;https://ratatui.rs/&quot;&gt;ratatui&lt;/a&gt;, provides real-time multi-device monitoring with sparkline charts and threshold alerts.&lt;/p&gt;
&lt;p&gt;It supports vim keybindings (naturally), light and dark themes, mouse interaction, CSV export, and device comparison views. CO2 readings above 1,000 ppm turn yellow; above 1,500 ppm, red. That&apos;s deliberately conservative, since cognitive effects are measurable well before the 2,500 ppm level where they become severe. Radon alerts follow EPA action levels. An audio bell fires when thresholds are crossed, which helps when the dashboard is running on a secondary monitor.&lt;/p&gt;
&lt;p&gt;The sparkline charts show min/max labels and adapt to terminal width. It&apos;s the kind of information density that a mobile app can&apos;t match.&lt;/p&gt;
&lt;h2&gt;Desktop GUI&lt;/h2&gt;
&lt;p&gt;For less terminal-inclined users (or when I want a persistent window rather than a terminal pane), &lt;code&gt;aranet-gui&lt;/code&gt; provides a desktop application built with &lt;a href=&quot;https://egui.rs/&quot;&gt;egui&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/aranet/gui-main.png&quot; alt=&quot;The aranet-gui desktop application showing multi-panel device monitoring with alerts and history views.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Multi-panel interface with device list, detail views, history charts, comparison mode, and a configurable alert system. It exports to CSV and JSON, supports light and dark themes, and includes a service management panel for controlling &lt;code&gt;aranet-service&lt;/code&gt; directly from the GUI.&lt;/p&gt;
&lt;h2&gt;From Sensors to Grafana: The Service Layer&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;aranet-service&lt;/code&gt; crate ties everything together as a background daemon. It exposes a REST API for querying devices and readings, WebSocket streaming for real-time updates, and a Prometheus metrics endpoint that makes Aranet data available to existing monitoring infrastructure.&lt;/p&gt;
&lt;p&gt;The Prometheus integration filters metrics by device capability. Aranet2 sensors don&apos;t have CO2, so they shouldn&apos;t emit &lt;code&gt;aranet_co2_ppm&lt;/code&gt; metrics:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (device, reading) in &amp;amp;device_readings {
    let device_type = resolve_device_type(device);

    if device_type.is_none_or(|dt| dt.has_co2()) &amp;amp;&amp;amp; reading.co2 &amp;gt; 0 {
        co2_lines.push(format!(
            &quot;aranet_co2_ppm{{{}}} {}\n&quot;, labels, reading.co2
        ));
    }
    if device_type.is_none_or(|dt| dt.has_temperature()) {
        temp_lines.push(format!(
            &quot;aranet_temperature_celsius{{{}}} {:.2}\n&quot;,
            labels, reading.temperature
        ));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The project ships with a pre-built Grafana dashboard template and a Docker Compose stack that spins up the service, Prometheus, and Grafana together. One &lt;code&gt;docker compose up -d&lt;/code&gt; and you have a complete monitoring stack scraping your Aranet sensors.&lt;/p&gt;
&lt;p&gt;MQTT publishing with Home Assistant auto-discovery means the sensors automatically appear in HA without manual configuration. Webhook notifications can ping Slack, Discord, or PagerDuty when thresholds are crossed. InfluxDB export is available for users who prefer that over Prometheus.&lt;/p&gt;
&lt;h2&gt;Why Rust for IoT&lt;/h2&gt;
&lt;p&gt;Environmental monitoring runs continuously on modest hardware, often a Raspberry Pi tucked behind a bookshelf. Rust&apos;s zero-cost abstractions and memory safety aren&apos;t academic niceties here; they&apos;re practical requirements for a long-running BLE daemon that needs to be reliable without consuming resources.&lt;/p&gt;
&lt;p&gt;The async runtime (Tokio) handles concurrent BLE scanning, API serving, MQTT publishing, and metric collection without threading complexity. The type system catches protocol parsing errors at compile time rather than in production at 3 AM. And the workspace structure means each crate carries only the dependencies it needs: the CLI binary doesn&apos;t link against egui, and the GUI doesn&apos;t pull in the Prometheus library.&lt;/p&gt;
&lt;p&gt;Cross-compilation to &lt;code&gt;aarch64-unknown-linux-gnu&lt;/code&gt; (Raspberry Pi) works out of the box with &lt;code&gt;cross&lt;/code&gt;. The CI pipeline builds and tests on macOS, Linux, and Windows.&lt;/p&gt;
&lt;h2&gt;Distribution: Meeting Users Where They Are&lt;/h2&gt;
&lt;p&gt;A tool nobody can install is a tool nobody uses. The project ships through multiple channels:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Homebrew (macOS/Linux)
brew tap cameronrye/aranet &amp;amp;&amp;amp; brew install aranet

# crates.io
cargo install aranet-cli

# Shell installer (macOS/Linux)
curl --proto &apos;=https&apos; --tlsv1.2 -LsSf \
  https://github.com/cameronrye/aranet/releases/latest/download/aranet-cli-installer.sh | sh

# Docker (full monitoring stack)
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GitHub Releases include shell and PowerShell installers plus macOS DMG bundles for the GUI. The goal is that however someone prefers to install software, there&apos;s a path that works.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/aranet/cli-history.gif&quot; alt=&quot;Downloading measurement history from an Aranet4 with progress tracking and incremental sync.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Liberating Your Data&lt;/h2&gt;
&lt;p&gt;The broader motivation behind this project goes beyond Aranet sensors. Hardware manufacturers increasingly treat the data your devices produce as something that flows through their cloud, their app, their ecosystem. You bought the sensor, but you&apos;re renting access to your own measurements.&lt;/p&gt;
&lt;p&gt;Aranet is actually better than most. The sensors work entirely offline and the BLE protocol is straightforward to reverse-engineer. But the tooling gap between &quot;sensor produces data&quot; and &quot;data is useful&quot; was still filled entirely by a mobile app with no export, no API, and no automation.&lt;/p&gt;
&lt;p&gt;Building from BLE packets up through storage, APIs, and dashboards in a single workspace proves that this gap doesn&apos;t need to exist. Your environmental data can live on your hardware, in standard formats, queryable by standard tools, integrated into the infrastructure you already run. No account required.&lt;/p&gt;
&lt;p&gt;The project is &lt;a href=&quot;https://github.com/cameronrye/aranet&quot;&gt;open source on GitHub&lt;/a&gt; and published to &lt;a href=&quot;https://crates.io/crates/aranet-cli&quot;&gt;crates.io&lt;/a&gt;. If you have an Aranet sensor, &lt;code&gt;brew install aranet&lt;/code&gt; and run &lt;code&gt;aranet scan&lt;/code&gt;. Your data is waiting.&lt;/p&gt;
</content:encoded><category>rust</category><category>protocols</category><category>open-source</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/aranet/tui-demo-poster.png" length="0" type="image/png"/></item><item><title>RSS Is Still Great (and Miniflux Is the Tool You Need)</title><link>https://rye.dev/blog/rss-miniflux-2026/</link><guid isPermaLink="true">https://rye.dev/blog/rss-miniflux-2026/</guid><description>Algorithmic feeds and AI slop are exhausting. RSS keeps it simple: you choose what you read. Miniflux is the privacy-first reader that gets it right.</description><pubDate>Thu, 05 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/rss-miniflux-2026-a-split-comparison-showing-the-1770314061766.jpg&quot; alt=&quot;A split comparison showing the mess of algorithmic feeds versus the clean, chronological order of RSS&quot; /&gt;&lt;/p&gt;&lt;p&gt;In 2026, the best way to read the internet is a 29-year-old technology that most people think died with Google Reader.&lt;/p&gt;
&lt;p&gt;RSS (Really Simple Syndication) was created by Netscape in 1997 and later refined by Aaron Swartz. It&apos;s a protocol so simple it barely qualifies as one: websites publish a structured feed of their content, and you subscribe to the feeds you want. No algorithm decides what&apos;s &quot;relevant.&quot; No engagement metrics determine what surfaces. Just content, in chronological order, from sources you chose.&lt;/p&gt;
&lt;p&gt;This isn&apos;t nostalgia. It&apos;s a rational response to what the web has become.&lt;/p&gt;
&lt;h2&gt;The Problem: Algorithms Ate the Web&lt;/h2&gt;
&lt;p&gt;Open Google Discover on your phone. Scroll through the recommendations. Notice how many headlines are optimized for clicks rather than accuracy. Notice the AI-generated summaries of articles that themselves were AI-generated. Notice how you didn&apos;t ask for any of this.&lt;/p&gt;
&lt;p&gt;This is the modern web. Social media algorithms decide what you see based on engagement metrics: not quality, not accuracy, not relevance to your actual interests. The result is a feedback loop optimized for outrage, addiction, and time-on-site.&lt;/p&gt;
&lt;p&gt;As one writer put it, the post-Google Reader era gave us &quot;filter bubbles, algorithmically driven news feeds, fake news, polarisation, privacy invasions, clickbait, spam bots, content farms, surveillance capitalism, notification addiction, doomscrolling, data harvesting, goldfish attention spans, cycles of outrage, misinformation loops, bad-faith discourse, trolling, trend-chasing, and the rise of the &apos;influencer.&apos;&quot;&lt;/p&gt;
&lt;p&gt;That&apos;s not hyperbole. That&apos;s a description of the current state of content consumption.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/rss-miniflux-2026-a-split-comparison-showing-the-1770314061766.jpg&quot; alt=&quot;A split comparison showing the mess of algorithmic feeds versus the clean, chronological order of RSS.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Search results are polluted with SEO spam. AI-generated garbage floods every platform. Google&apos;s AI Overviews have reduced organic clicks by 34.5%, keeping users in Google&apos;s ecosystem while publishers watch their traffic evaporate. The platforms that promised to connect us to information have instead become intermediaries extracting value from both sides.&lt;/p&gt;
&lt;p&gt;PC Gamer ran a piece in January calling 2026 &quot;the year of the glorious return of the RSS reader,&quot; encouraging readers to &quot;kill the algorithm in your head.&quot; They&apos;re not wrong.&lt;/p&gt;
&lt;h2&gt;The Solution: Take Back Control&lt;/h2&gt;
&lt;p&gt;RSS offers something radical: you choose what you read, in the order it was published, with zero tracking.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You choose your sources.&lt;/strong&gt; No algorithm decides what&apos;s &quot;relevant&quot; to you. You subscribe to writers, publications, and topics you care about. If something stops being valuable, you unsubscribe. Simple.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Chronological order.&lt;/strong&gt; Content appears when it&apos;s published, not when it&apos;s &quot;trending.&quot; There&apos;s no algorithmic amplification of inflammatory takes. No engagement-bait rising to the top. Just a timeline that respects the passage of time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No ads, no tracking, no engagement bait.&lt;/strong&gt; RSS feeds are just data. They don&apos;t contain tracking pixels, don&apos;t set cookies, don&apos;t build advertising profiles. Your reading habits remain yours.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Portability.&lt;/strong&gt; OPML export means you&apos;re never locked in. Don&apos;t like your current reader? Export your subscriptions and import them elsewhere. Try doing that with your YouTube recommendations or Twitter timeline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It makes the web feel manageable.&lt;/strong&gt; Instead of the infinite scroll, you have a finite reading list. You can actually reach the end. There&apos;s something psychologically healthy about completing your reading rather than drowning in an endless stream.&lt;/p&gt;
&lt;p&gt;RSS also powers more than most people realize. Over 80% of podcast distribution still runs on RSS feeds. YouTube channels have RSS feeds (though Google hides them). GitHub releases, Reddit communities, government sites, academic preprints: all available via RSS. The infrastructure never went away.&lt;/p&gt;
&lt;h2&gt;Why Miniflux Gets It Right&lt;/h2&gt;
&lt;p&gt;There are dozens of RSS readers available. I&apos;ve tried most of them. Miniflux is the one that stuck, and the reason comes down to philosophy.&lt;/p&gt;
&lt;p&gt;Miniflux is a &lt;strong&gt;minimalist and opinionated&lt;/strong&gt; self-hosted feed reader created by Frédéric Guillot. It&apos;s written in Go, compiles to a single static binary, and uses PostgreSQL as its only database. The entire thing runs on a couple megabytes of memory, even with hundreds of feeds.&lt;/p&gt;
&lt;p&gt;The interface is deliberately spartan. No AI recommendations. No social sharing buttons. No fancy features competing for your attention. Just your feeds, presented cleanly, optimized for reading.&lt;/p&gt;
&lt;p&gt;As one reviewer noted: &quot;Coming from feature-rich, busy social media apps, Miniflux&apos;s interface may feel boring at first.&quot; That&apos;s the point. The absence of distraction is the feature.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/rss-miniflux-2026-a-diagrammatic-illustration-sh-1770314078151.jpg&quot; alt=&quot;A diagrammatic illustration showing how Miniflux strips trackers and ads, acting as a privacy filter.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Privacy by Design&lt;/h3&gt;
&lt;p&gt;Miniflux treats privacy as an architectural concern, not an afterthought:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strips tracking pixels&lt;/strong&gt; automatically from feed content&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Removes UTM parameters&lt;/strong&gt; and other tracking cruft from URLs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Proxies media&lt;/strong&gt; through the server to prevent third-party tracking&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Opens external links&lt;/strong&gt; with &lt;code&gt;rel=&quot;noopener noreferrer&quot;&lt;/code&gt; and &lt;code&gt;referrerpolicy=&quot;no-referrer&quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plays YouTube videos&lt;/strong&gt; via &lt;code&gt;youtube-nocookie.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero telemetry, zero advertising&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When every app harvests data by default, Miniflux&apos;s stance is refreshing. It respects HTTP caching headers to avoid hammering servers. It doesn&apos;t phone home. It just does its job.&lt;/p&gt;
&lt;h3&gt;Keyboard-Driven Workflow&lt;/h3&gt;
&lt;p&gt;Miniflux is designed for people who read a lot. Full keyboard shortcuts let you fly through hundreds of articles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Arrow keys for navigation&lt;/li&gt;
&lt;li&gt;&lt;code&gt;v&lt;/code&gt; to open the original article&lt;/li&gt;
&lt;li&gt;&lt;code&gt;s&lt;/code&gt; to star/bookmark&lt;/li&gt;
&lt;li&gt;&lt;code&gt;d&lt;/code&gt; to fetch full article content&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/&lt;/code&gt; for search&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full-text fetching is particularly useful. Many feeds only include summaries, forcing you to click through to the original site. Miniflux can automatically fetch the complete article, letting you read everything in one place. You can enable this per-feed or trigger it manually with a keystroke.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/rss-miniflux-2026-an-illustration-of-the-rss-eco-1770314095505.jpg&quot; alt=&quot;An illustration of the RSS ecosystem, showing Miniflux as the central hub connecting to devices and other services.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;25+ Integrations&lt;/h3&gt;
&lt;p&gt;One of Miniflux&apos;s underrated strengths is its integration ecosystem:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Services&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Read-it-later&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Wallabag, Instapaper, Pocket, Readwise Reader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bookmarking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pinboard, Linkding, LinkAce, Shaarli&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Notifications&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Discord, Slack, Telegram, Matrix, Ntfy, Pushover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Note-taking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Notion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Automation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Webhooks, Apprise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The full REST API means you can build whatever custom integrations you need. There&apos;s also Fever API and Google Reader API compatibility, which opens up dozens of existing mobile apps.&lt;/p&gt;
&lt;h3&gt;Deployment&lt;/h3&gt;
&lt;p&gt;Getting Miniflux running takes about five minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# docker-compose.yml
services:
  miniflux:
    image: miniflux/miniflux:latest
    ports:
      - &quot;8080:8080&quot;
    environment:
      - DATABASE_URL=postgres://miniflux:secret@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
      - CREATE_ADMIN=1
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=changeme
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=miniflux
      - POSTGRES_PASSWORD=secret
    volumes:
      - miniflux-db:/var/lib/postgresql/data

volumes:
  miniflux-db:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run &lt;code&gt;docker-compose up -d&lt;/code&gt;, navigate to &lt;code&gt;localhost:8080&lt;/code&gt;, and you&apos;re done. For those who don&apos;t want to self-host, there&apos;s an official hosted option at reader.miniflux.app for $15/year.&lt;/p&gt;
&lt;h2&gt;The RSS Ecosystem&lt;/h2&gt;
&lt;p&gt;Miniflux doesn&apos;t exist in isolation. A whole ecosystem of tools makes RSS more useful.&lt;/p&gt;
&lt;h3&gt;Feed Generators&lt;/h3&gt;
&lt;p&gt;Many sites have removed their RSS feeds or never had them. These tools bridge the gap:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RSS-Bridge&lt;/strong&gt; is a PHP application that generates feeds for sites that removed them: YouTube, Twitter/X, Reddit, Telegram, and dozens more. The project&apos;s README includes a manifesto worth quoting: &quot;Dear so-called &apos;social&apos; websites... You&apos;re not social when you hamper sharing by removing feeds... We are rebuilding bridges you have willfully destroyed.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RSSHub&lt;/strong&gt; is a community-driven project with 30,000+ GitHub stars, generating RSS feeds for seemingly everything. If a site exists, someone has probably written an RSSHub route for it.&lt;/p&gt;
&lt;h3&gt;Alternative Frontends&lt;/h3&gt;
&lt;p&gt;Miniflux&apos;s spartan interface isn&apos;t for everyone. Third-party frontends offer alternatives:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ReactFlux&lt;/strong&gt;: Beautiful React-based web frontend with a more visual approach&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nextflux&lt;/strong&gt;: Modern Reeder-inspired UI, PWA-capable&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reminiflux&lt;/strong&gt; and &lt;strong&gt;Fluxjs&lt;/strong&gt;: Additional web frontend options&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These connect to Miniflux via its API, giving you the backend&apos;s reliability with a different presentation layer.&lt;/p&gt;
&lt;h3&gt;Mobile Apps&lt;/h3&gt;
&lt;p&gt;The Fever and Google Reader API compatibility means Miniflux works with excellent mobile apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;iOS&lt;/strong&gt;: Unread, Fiery Feeds, Lire&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android&lt;/strong&gt;: Miniflutt (FOSS), Read You (Material You design), News+&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Honest Limitations&lt;/h3&gt;
&lt;p&gt;Miniflux isn&apos;t perfect for everyone:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No feed discovery.&lt;/strong&gt; You need to know your sources. If you want recommendation features, FreshRSS might be better.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regex-only filtering.&lt;/strong&gt; Block rules require regex knowledge, not a simple keyword UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spartan by design.&lt;/strong&gt; Some people genuinely want more features. That&apos;s valid.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For me, these limitations are features. The lack of discovery means I&apos;m intentional about what I subscribe to. The minimal interface means I focus on reading, not fiddling with settings.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;If you&apos;re new to RSS, here&apos;s a practical starting point:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Deploy Miniflux&lt;/strong&gt; using the Docker Compose configuration above, or sign up for the hosted version.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add feeds you already read.&lt;/strong&gt; Most sites still have RSS feeds at &lt;code&gt;/feed/&lt;/code&gt;, &lt;code&gt;/rss/&lt;/code&gt;, or &lt;code&gt;/feed.xml&lt;/code&gt;. Browser extensions like &quot;Get RSS Feed URL&quot; can help find them.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Subscribe to writers, not publications.&lt;/strong&gt; Individual bloggers often have better signal-to-noise ratios than large publications.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Use RSS-Bridge&lt;/strong&gt; for sites that don&apos;t have feeds. YouTube channels, Reddit subreddits, and Twitter accounts can all become RSS feeds.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Export your OPML&lt;/strong&gt; periodically as a backup. This is your subscription list in a portable format.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Resist the urge to subscribe to everything.&lt;/strong&gt; Start with 10-20 feeds. Add more only when you find yourself wanting more content.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The goal isn&apos;t to replicate the firehose of social media. It&apos;s to curate a reading list that actually serves your interests.&lt;/p&gt;
&lt;h2&gt;The Bigger Picture&lt;/h2&gt;
&lt;p&gt;RSS won&apos;t save the internet. The platform incentives that created the current mess aren&apos;t going away. Algorithms will continue optimizing for engagement. AI slop will continue flooding search results. Publishers will continue chasing whatever metrics the platforms reward.&lt;/p&gt;
&lt;p&gt;But RSS might save your relationship with the internet.&lt;/p&gt;
&lt;p&gt;There&apos;s something deeply satisfying about opening your feed reader and seeing exactly what you asked for: nothing more, nothing less. No manipulation. No dark patterns. No algorithmic anxiety about what you might be missing. Just content from people you chose to follow, in the order they published it.&lt;/p&gt;
&lt;p&gt;In a web increasingly optimized for everyone&apos;s attention, RSS is optimized for yours.&lt;/p&gt;
&lt;p&gt;The technology is 29 years old. It&apos;s been declared dead a dozen times. And it&apos;s still the best way to read the internet in 2026.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Resources:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://miniflux.app&quot;&gt;Miniflux&lt;/a&gt; - Official site and documentation&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/miniflux/v2&quot;&gt;Miniflux GitHub&lt;/a&gt; - Source code&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/RSS-Bridge/rss-bridge&quot;&gt;RSS-Bridge&lt;/a&gt; - Generate feeds for sites without them&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/DIYgod/RSSHub&quot;&gt;RSSHub&lt;/a&gt; - Community-driven feed generator&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://awesome-selfhosted.net/tags/feed-readers.html&quot;&gt;awesome-selfhosted RSS readers&lt;/a&gt; - Comprehensive list of alternatives&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>open-web</category><category>self-hosting</category><category>web-standards</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/rss-miniflux-2026-a-split-comparison-showing-the-1770314061766.jpg" length="0" type="image/jpeg"/></item><item><title>Building Zero Crust: Distributed State Management in Electron</title><link>https://rye.dev/blog/building-zero-crust-distributed-state-electron/</link><guid isPermaLink="true">https://rye.dev/blog/building-zero-crust-distributed-state-electron/</guid><description>Two displays, one source of truth: how I built a dual-head POS simulator in Electron with centralized state, broadcast sync, and locked-down IPC.</description><pubDate>Wed, 28 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-zero-crust-distributed-state-electron-a-visual-metaphor-for-the-dual-featured-1769401609964.png&quot; alt=&quot;Building Zero Crust: Distributed State Management in Electron&quot; /&gt;&lt;/p&gt;&lt;p&gt;Point-of-sale systems come with a specific architectural constraint. You need two displays, one for the cashier and one for the customer, showing identical information but running on separate hardware with strict security boundaries. Get the synchronization wrong and customers see incorrect prices. Get the security wrong and you&apos;re open to price tampering.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/cameronrye/zero-crust&quot;&gt;Zero Crust&lt;/a&gt; is my exploration of these patterns in Electron. It&apos;s a reference implementation for distributed state management with the defense-in-depth security that desktop applications need.&lt;/p&gt;
&lt;h2&gt;The Dual-Head Challenge&lt;/h2&gt;
&lt;p&gt;In production POS deployments, the cashier terminal and customer-facing display are often separate physical devices. The cashier&apos;s screen shows product grids, payment controls, and management functions. The customer&apos;s screen shows only the cart, a simple display that trusts nothing.&lt;/p&gt;
&lt;p&gt;Electron&apos;s multi-window architecture maps cleanly onto this model. Each window runs in its own renderer process, sandboxed and isolated. The main process acts as the trusted coordinator. It&apos;s the only process with access to payment services, persistence, and the application state.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────┐     ┌─────────────────┐
│  Cashier Window │     │ Customer Window │
│   (Renderer)    │     │   (Renderer)    │
│   ┌─────────┐   │     │   ┌─────────┐   │
│   │ Cart UI │   │     │   │ Cart UI │   │
│   └─────────┘   │     │   └─────────┘   │
└────────┬────────┘     └────────┬────────┘
         │                       │
         │    IPC Commands       │
         ▼                       ▼
┌──────────────────────────────────────────┐
│            Main Process                   │
│  ┌──────────┐ ┌─────────┐ ┌──────────┐  │
│  │MainStore │ │Payment  │ │Broadcast │  │
│  │ (State)  │ │Service  │ │Service   │  │
│  └──────────┘ └─────────┘ └──────────┘  │
└──────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Centralized State with MainStore&lt;/h2&gt;
&lt;p&gt;The heart of Zero Crust is &lt;code&gt;MainStore&lt;/code&gt;, a centralized state container that serves as the single source of truth. Every piece of application state lives here: the cart items, transaction history, current session, and payment status.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// MainStore.ts - Centralized state management
export class MainStore {
  private state: InternalState;
  private listeners = new Set&amp;lt;Listener&amp;gt;();

  private updateState(recipe: (draft: InternalState) =&amp;gt; void): void {
    this.state = produce(this.state, (draft) =&amp;gt; {
      recipe(draft);
      draft.version++;
    });
    this.notifyListeners();
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key insight here is &lt;strong&gt;state versioning&lt;/strong&gt;. Every state update increments a version number. This allows renderers to detect stale state and provides an audit trail of state changes. Combined with Immer&apos;s structural sharing, updates are both immutable and efficient.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-zero-crust-distributed-state-electron-a-polished-visualization-of-th-1769401633388.png&quot; alt=&quot;Diagram of the Command Pattern and state broadcasting between the renderers and the main process.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Command Pattern for IPC&lt;/h2&gt;
&lt;p&gt;Renderers don&apos;t mutate state directly. They can&apos;t. They send &lt;strong&gt;commands&lt;/strong&gt; to the main process, which validates and processes them. This is the Command Pattern applied to IPC:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ipc-types.ts - Discriminated union of commands
export type Command =
  | { type: &apos;ADD_ITEM&apos;; sku: string }
  | { type: &apos;REMOVE_ITEM&apos;; sku: string }
  | { type: &apos;UPDATE_QUANTITY&apos;; sku: string; quantity: number }
  | { type: &apos;CLEAR_CART&apos; }
  | { type: &apos;START_PAYMENT&apos; }
  | { type: &apos;VOID_TRANSACTION&apos; };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that renderers send &lt;strong&gt;SKUs, not prices&lt;/strong&gt;. The main process looks up prices from its trusted product catalog. This ID-based messaging pattern prevents a compromised renderer from sending fake prices. The worst it can do is add items that exist.&lt;/p&gt;
&lt;h2&gt;Runtime Validation with Zod&lt;/h2&gt;
&lt;p&gt;TypeScript types vanish at runtime. When an IPC message crosses the process boundary, you have no guarantee it matches your type definitions. A malicious actor could send arbitrary data. This is where Zod comes in:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// schemas.ts - Runtime validation
export const AddItemSchema = z.object({
  type: z.literal(&apos;ADD_ITEM&apos;),
  sku: z.string().min(1).max(50),
});

export const CommandSchema = z.discriminatedUnion(&apos;type&apos;, [
  AddItemSchema,
  RemoveItemSchema,
  UpdateQuantitySchema,
  ClearCartSchema,
  StartPaymentSchema,
  VoidTransactionSchema,
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every incoming command is validated before processing. Invalid commands are rejected with detailed error messages for debugging. This transforms runtime errors from mysterious crashes into clear validation failures.&lt;/p&gt;
&lt;h2&gt;Defense in Depth: Electron Security&lt;/h2&gt;
&lt;p&gt;Zero Crust implements six layers of security, each catching threats that slip past others:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-zero-crust-distributed-state-electron-illustrates-the-concept-of-mul-1769401647983.jpg&quot; alt=&quot;Layered security barriers protecting the core application state.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Electron Fuses:&lt;/strong&gt; Compile-time flags that cannot be changed at runtime. Node.js integration is disabled at the binary level.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Context Isolation:&lt;/strong&gt; Renderer processes run in an isolated JavaScript context. They cannot access Node.js APIs, Electron internals, or the preload script&apos;s scope.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Zod Validation:&lt;/strong&gt; Every IPC message is validated against a strict schema. Malformed or unexpected data is rejected.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Sender Verification:&lt;/strong&gt; IPC handlers check &lt;code&gt;event.sender&lt;/code&gt; against known window IDs. Commands from unknown sources are dropped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Navigation Control:&lt;/strong&gt; All navigation is blocked except to &lt;code&gt;file://&lt;/code&gt; URLs. No external websites can be loaded into windows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Permission Denial:&lt;/strong&gt; All permission requests (camera, microphone, geolocation) are denied by default.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// SecurityHandlers.ts - Sender validation
function validateSender(event: IpcMainInvokeEvent): boolean {
  const webContents = event.sender;
  const knownIds = windowManager.getKnownWebContentsIds();
  return knownIds.includes(webContents.id);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The BroadcastService Pattern&lt;/h2&gt;
&lt;p&gt;State synchronization is notoriously tricky. Delta updates, conflict resolution, and eventual consistency are all PhD-level distributed systems problems. Zero Crust sidesteps that complexity with a brutally simple approach: &lt;strong&gt;broadcast the entire state on every change&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// BroadcastService.ts - Full-state sync
export class BroadcastService {
  constructor(mainStore: MainStore, windowManager: WindowManager) {
    mainStore.subscribe((state) =&amp;gt; {
      windowManager.broadcastState(state);
    });
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the cart changes, every renderer gets a complete snapshot of the new state. No diffing, no patches, no merge conflicts. Renderers replace their local state with whatever arrives.&lt;/p&gt;
&lt;p&gt;This pattern eliminates entire categories of bugs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No stale state:&lt;/strong&gt; Renderers always have the latest version&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No synchronization drift:&lt;/strong&gt; State is identical across all windows by construction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No ordering issues:&lt;/strong&gt; Each broadcast is a complete snapshot&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trivial debugging:&lt;/strong&gt; Log any state snapshot and you see exactly what all renderers see&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The performance cost? Negligible. A typical POS cart has maybe 20 items. Serializing and deserializing that with &lt;code&gt;structuredClone&lt;/code&gt; takes microseconds.&lt;/p&gt;
&lt;h2&gt;The Architecture Debug Window&lt;/h2&gt;
&lt;p&gt;Debugging distributed systems is hard. You can&apos;t set a breakpoint across process boundaries. You can&apos;t easily trace the flow of messages between windows. That&apos;s why Zero Crust includes a real-time Architecture Debug Window.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/debugger.png&quot; alt=&quot;Architecture Debug Window&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The debug window shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Event Timeline:&lt;/strong&gt; Every IPC message, state update, and trace event in chronological order&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture Graph:&lt;/strong&gt; Visual representation of windows and message flow with animated edges&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;State Inspector:&lt;/strong&gt; JSON tree view with diff highlighting showing exactly what changed&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live Statistics:&lt;/strong&gt; Events per second, average latency, state version&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The implementation uses a circular buffer to store trace events without unbounded memory growth:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// TraceService.ts - Event collection
export class TraceService {
  private events: TraceEvent[] = [];
  private maxEvents = 1000;

  record(event: Omit&amp;lt;TraceEvent, &apos;id&apos; | &apos;timestamp&apos;&amp;gt;): void {
    const fullEvent = {
      ...event,
      id: this.nextId++,
      timestamp: Date.now(),
    };
    this.events.push(fullEvent);
    if (this.events.length &amp;gt; this.maxEvents) {
      this.events.shift();
    }
    this.broadcast(fullEvent);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Critically, the debug window is &lt;strong&gt;lazy activated&lt;/strong&gt;. TraceService only collects events when the debug window is open. No overhead when you don&apos;t need it.&lt;/p&gt;
&lt;h2&gt;Integer Math for Currency&lt;/h2&gt;
&lt;p&gt;Here&apos;s a bug that bankrupts companies:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0.1 + 0.2 === 0.3  // false! It&apos;s 0.30000000000000004
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Floating-point arithmetic is fundamentally incompatible with financial calculations. The IEEE 754 standard cannot precisely represent most decimal values. Zero Crust solves this by storing all monetary values as &lt;strong&gt;integers representing cents&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// currency.ts - Integer-only currency
export type Cents = number &amp;amp; { __brand: &apos;Cents&apos; };

export function toCents(dollars: number): Cents {
  return Math.round(dollars * 100) as Cents;
}

export function formatCurrency(cents: Cents): string {
  return `$${(cents / 100).toFixed(2)}`;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The branded type &lt;code&gt;Cents&lt;/code&gt; makes it impossible to accidentally mix cents and dollars. TypeScript will error if you pass a regular number where &lt;code&gt;Cents&lt;/code&gt; is expected.&lt;/p&gt;
&lt;p&gt;This pattern extends to all calculations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tax is computed as &lt;code&gt;(subtotal * taxRate) / 100&lt;/code&gt;, rounded&lt;/li&gt;
&lt;li&gt;Discounts are stored and applied as cent values&lt;/li&gt;
&lt;li&gt;Totals are summed, never multiplied by fractional amounts&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Screenshots&lt;/h2&gt;
&lt;p&gt;Here&apos;s Zero Crust in action:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/cashier.png&quot; alt=&quot;Cashier Window&quot; /&gt;
&lt;em&gt;The cashier window with product grid and cart sidebar&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/customer.png&quot; alt=&quot;Customer Display&quot; /&gt;
&lt;em&gt;Customer display showing the synchronized cart state&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/transactions.png&quot; alt=&quot;Transaction History&quot; /&gt;
&lt;em&gt;Transaction history with completed orders&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;Building Zero Crust reinforced several principles:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simplicity beats cleverness.&lt;/strong&gt; Full-state broadcast is &quot;inefficient&quot; but eliminates entire bug categories. The debuggability alone is worth it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security is layers.&lt;/strong&gt; No single security measure is sufficient. Context isolation protects against XSS. Zod validation catches malformed data. Sender verification stops spoofed messages. Each layer catches what others miss.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TypeScript needs runtime backup.&lt;/strong&gt; Types are erased at runtime. Process boundaries need runtime validation. Zod provides this beautifully.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Debug tools pay dividends.&lt;/strong&gt; The Architecture Debug Window took significant effort to build. It&apos;s saved ten times that in debugging time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model real hardware.&lt;/strong&gt; Designing for dual-head from the start forced clean separation. The constraints improved the architecture.&lt;/p&gt;
&lt;h2&gt;Try It Yourself&lt;/h2&gt;
&lt;p&gt;Zero Crust is open source at &lt;a href=&quot;https://github.com/cameronrye/zero-crust&quot;&gt;github.com/cameronrye/zero-crust&lt;/a&gt;. Clone it, run &lt;code&gt;pnpm dev&lt;/code&gt;, and explore the architecture. The debug window (View &amp;gt; Architecture or Cmd+Shift+A) is the best way to understand the message flow.&lt;/p&gt;
&lt;p&gt;The patterns here aren&apos;t specific to point-of-sale. If you&apos;re working on any multi-window Electron app, I hope some of it transfers.&lt;/p&gt;
</content:encoded><category>electron</category><category>react</category><category>typescript</category><category>security</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/building-zero-crust-distributed-state-electron-a-visual-metaphor-for-the-dual-featured-1769401609964.png" length="0" type="image/png"/></item><item><title>Building Ask: A RAG-Powered Chatbot for My Portfolio</title><link>https://rye.dev/blog/building-ask-rag-portfolio-chatbot/</link><guid isPermaLink="true">https://rye.dev/blog/building-ask-rag-portfolio-chatbot/</guid><description>How I built Ask, the RAG chatbot on every page of this site: Cloudflare Workers AI, Vectorize, and the prompt-injection hardening it took to ship.</description><pubDate>Mon, 12 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-ask-rag-portfolio-chatbot-a-high-quality-3d-hero-image-r-featured-1768090320575.png&quot; alt=&quot;Building Ask: A RAG-Powered Chatbot for My Portfolio&quot; /&gt;&lt;/p&gt;&lt;p&gt;Portfolio sites are inherently passive. Visitors land on a page, scan for relevant information, and either find what they need or bounce. Traditional search helps, but it requires visitors to know what to look for. I wanted something different: an AI assistant that understands my work and can have a conversation about it.&lt;/p&gt;
&lt;p&gt;The result is Ask, a RAG-powered chatbot that lives on every page of rye.dev. It knows about my projects, can discuss my blog posts, and adapts its behavior based on which page you&apos;re viewing. This post documents how I built it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-ask-rag-portfolio-chatbot-a-visual-representation-of-the-1768090335771.jpg&quot; alt=&quot;A visual representation of the serverless/edge architecture described in the post, showing the flow of data between components.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Architecture&lt;/h2&gt;
&lt;p&gt;Ask runs entirely on Cloudflare&apos;s edge infrastructure. There&apos;s no origin server, no container to manage, no cold starts to worry about. The stack consists of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Frontend&lt;/strong&gt;: Preact component with Nanostores for state management&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API&lt;/strong&gt;: Astro API routes deployed to Cloudflare Workers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAG&lt;/strong&gt;: AI Search (AutoRAG) with Vectorize fallback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LLM&lt;/strong&gt;: Llama 3.3 70B via Workers AI&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability&lt;/strong&gt;: AI Gateway for request logging and analytics&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The edge-first design means responses start streaming in under 200ms from anywhere in the world. The entire knowledge base (blog posts, project descriptions, technical details) lives in Cloudflare R2 and gets indexed automatically.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-ask-rag-portfolio-chatbot-visualizes-the-concept-of-chun-1768090364218.png&quot; alt=&quot;Visualizes the concept of &apos;Chunking&apos; and vector embedding, illustrating how raw text is broken down and processed for the AI.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;RAG: Teaching the AI About My Work&lt;/h2&gt;
&lt;p&gt;A general-purpose LLM knows nothing about my specific projects. RAG (Retrieval-Augmented Generation) solves this by injecting relevant context into each request. When someone asks &quot;What MCP servers has Cameron built?&quot;, the system:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Searches the knowledge base for relevant content&lt;/li&gt;
&lt;li&gt;Retrieves the top matches (blog posts about gopher-mcp, openzim-mcp, etc.)&lt;/li&gt;
&lt;li&gt;Injects that context into the system prompt&lt;/li&gt;
&lt;li&gt;Lets the LLM generate a grounded response&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The chunking strategy matters. I split content by paragraphs, respecting a 2000-character maximum with 200-character overlap between chunks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function chunkText(
  text: string,
  maxChars = 2000,
  overlap = 200
): string[] {
  const chunks: string[] = [];
  const paragraphs = text.split(/\n\n+/);
  let currentChunk = &apos;&apos;;

  for (const paragraph of paragraphs) {
    const trimmed = paragraph.trim();
    if (!trimmed) continue;

    if (currentChunk &amp;amp;&amp;amp; currentChunk.length + trimmed.length + 2 &amp;gt; maxChars) {
      chunks.push(currentChunk.trim());
      // Start new chunk with overlap from previous
      const words = currentChunk.split(/\s+/);
      const overlapWords = words.slice(-Math.floor(overlap / 6));
      currentChunk = overlapWords.join(&apos; &apos;) + &apos;\n\n&apos; + trimmed;
    } else {
      currentChunk = currentChunk
        ? currentChunk + &apos;\n\n&apos; + trimmed
        : trimmed;
    }
  }

  if (currentChunk.trim()) {
    chunks.push(currentChunk.trim());
  }

  return chunks;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The overlap ensures that concepts spanning paragraph boundaries don&apos;t get lost. Each chunk gets embedded using BGE Base EN v1.5, producing 768-dimensional vectors stored in Cloudflare Vectorize.&lt;/p&gt;
&lt;h2&gt;Context-Aware Conversations&lt;/h2&gt;
&lt;p&gt;Ask adapts based on where you are on the site. On the homepage, you get general questions about my background. On a blog post, the starter questions relate to that specific article. On a project page, the questions focus on that project&apos;s tech and architecture. On a 404, Ask tries to help you find what you were looking for.&lt;/p&gt;
&lt;p&gt;This works through a page context system. Each page passes metadata to the chat component:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface PageContext {
  type: &apos;default&apos; | &apos;blog&apos; | &apos;project&apos; | &apos;404&apos;;
  title?: string;
  slug?: string;
  tags?: string[];
  description?: string;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The system prompt gets augmented with this context, so the LLM understands what the visitor is currently reading and can provide more relevant responses.&lt;/p&gt;
&lt;h2&gt;The System Prompt: Expert on My Work, Not Me&lt;/h2&gt;
&lt;p&gt;One design decision I&apos;m particularly happy with: Ask is an expert system &lt;em&gt;about&lt;/em&gt; my work, not a simulation of me. The distinction matters. The prompt explicitly states:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;You are Ask, an AI assistant on Cameron Rye&apos;s portfolio website at rye.dev. You are an expert system about Cameron&apos;s work, projects, and technical expertise—not Cameron himself.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This framing avoids the uncanny valley of AI pretending to be human while still providing helpful, knowledgeable responses. Ask can discuss my projects in detail, explain technical decisions, and point visitors to relevant content without ever claiming to be me.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-ask-rag-portfolio-chatbot-illustrates-the-security-layer-1768090395222.png&quot; alt=&quot;Illustrates the security layer and input sanitization, showing the filtering of &apos;malicious&apos; prompt injections versus &apos;safe&apos; user queries.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Security: Hardening Against Prompt Injection&lt;/h2&gt;
&lt;p&gt;Any public-facing LLM application needs security hardening. Ask implements multiple layers of defense:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Input Sanitization&lt;/strong&gt;: Before any processing, user input gets sanitized. Control characters are stripped, excessive whitespace is normalized, and the input is truncated to a reasonable length.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prompt Injection Detection&lt;/strong&gt;: A dedicated classifier runs on every message, looking for common injection patterns. This catches attempts to override the system prompt, extract internal instructions, or manipulate the AI&apos;s behavior:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const injectionPatterns = [
  /ignore\s+(all\s+)?(previous|above|prior)/i,
  /disregard\s+(all\s+)?(previous|above|prior)/i,
  /forget\s+(all\s+)?(previous|above|prior)/i,
  /new\s+instructions?:/i,
  /system\s*prompt/i,
  /you\s+are\s+now/i,
  /pretend\s+(you\s+are|to\s+be)/i,
  /act\s+as\s+(if|a|an)/i,
  /roleplay\s+as/i,
  /jailbreak/i,
  /bypass\s+(safety|filter|restriction)/i,
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rate Limiting&lt;/strong&gt;: A sliding window rate limiter prevents abuse. Each IP gets a limited number of requests per time window, with the limits stored in Turso (a distributed SQLite database). This prevents both denial-of-service attacks and excessive API costs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Response Filtering&lt;/strong&gt;: The LLM&apos;s output also gets checked before being sent to the client. Any response that appears to contain leaked system prompts or internal instructions gets blocked.&lt;/p&gt;
&lt;h2&gt;Streaming: Real-Time Response Delivery&lt;/h2&gt;
&lt;p&gt;Nobody wants to wait for a complete response before seeing anything. Ask uses Server-Sent Events (SSE) to stream tokens as they&apos;re generated:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const stream = new ReadableStream({
  async start(controller) {
    const encoder = new TextEncoder();

    for await (const chunk of aiStream) {
      const text = chunk.response || &apos;&apos;;
      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ text })}\n\n`)
      );
    }

    controller.enqueue(encoder.encode(&apos;data: [DONE]\n\n&apos;));
    controller.close();
  },
});

return new Response(stream, {
  headers: {
    &apos;Content-Type&apos;: &apos;text/event-stream&apos;,
    &apos;Cache-Control&apos;: &apos;no-cache&apos;,
    &apos;Connection&apos;: &apos;keep-alive&apos;,
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The frontend parses these events and updates the UI in real-time, giving that satisfying &quot;typing&quot; effect as the response streams in.&lt;/p&gt;
&lt;h2&gt;The UI: Minimal and Unobtrusive&lt;/h2&gt;
&lt;p&gt;The chat interface needed to be accessible without being intrusive. The solution: a floating button in the bottom-right corner that expands into a full chat panel. On mobile, it takes over the full screen. On desktop, it&apos;s a contained panel that doesn&apos;t interfere with the main content.&lt;/p&gt;
&lt;p&gt;The design uses a liquid glass aesthetic: translucent backgrounds with subtle blur effects that let the underlying page show through. This keeps the chat feeling integrated rather than bolted-on.&lt;/p&gt;
&lt;p&gt;State management uses Nanostores, a tiny (less than 1KB) state management library that works perfectly with Preact. The chat state (messages, loading status, error states) lives in a single store that components can subscribe to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const chatStore = atom&amp;lt;ChatState&amp;gt;({
  messages: [],
  isLoading: false,
  error: null,
  isOpen: false,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;RAG quality depends on chunking strategy.&lt;/strong&gt; My first attempt used fixed-size chunks that often split sentences mid-thought. Switching to paragraph-aware chunking with overlap dramatically improved retrieval quality.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;System prompts need iteration.&lt;/strong&gt; The initial prompt was too permissive, leading to responses that strayed from my actual work. Adding explicit constraints and examples of good responses helped focus the output.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Edge deployment changes everything.&lt;/strong&gt; Running on Cloudflare Workers means the entire request, from receiving the message to starting the stream, happens in under 50ms. There&apos;s no cold start penalty, no container spin-up, just immediate response.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security is non-negotiable.&lt;/strong&gt; Within hours of deploying the first version, I saw prompt injection attempts in the logs. The multi-layer security approach catches these before they can cause problems.&lt;/p&gt;
&lt;h2&gt;What&apos;s Next&lt;/h2&gt;
&lt;p&gt;Ask is live and working, but there&apos;s always room for improvement. Future enhancements I&apos;m considering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Conversation memory&lt;/strong&gt;: Currently each message is independent. Adding conversation history would enable more natural multi-turn dialogues.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Citation links&lt;/strong&gt;: When Ask references a blog post or project, it should link directly to that content.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Analytics integration&lt;/strong&gt;: Understanding what visitors ask about could inform future content.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The code is part of my portfolio site, which is open source. If you&apos;re building something similar, feel free to explore the implementation.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Ask is available on every page of rye.dev. Try it out: click the chat button in the bottom-right corner and ask about my projects, experience, or anything else you&apos;d like to know.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>ai</category><category>search</category><category>typescript</category><category>astro</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/building-ask-rag-portfolio-chatbot-a-high-quality-3d-hero-image-r-featured-1768090320575.png" length="0" type="image/png"/></item><item><title>Uzumaki: One Spiral Engine, Two Native Platforms</title><link>https://rye.dev/blog/uzumaki-cross-platform-spiral-visualization/</link><guid isPermaLink="true">https://rye.dev/blog/uzumaki-cross-platform-spiral-visualization/</guid><description>Ten spiral algorithms, six deployment targets: how I kept React and SwiftUI renders of Uzumaki mathematically identical across web and Apple platforms.</description><pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/projects/uzumaki/hero.png&quot; alt=&quot;Uzumaki: One Spiral Engine, Two Native Platforms&quot; /&gt;&lt;/p&gt;&lt;p&gt;Spirals appear everywhere in nature. The nautilus shell grows in a logarithmic spiral, maintaining its shape at every scale. Sunflower seeds arrange themselves in Vogel spirals, optimizing for space using the golden angle. Galaxy arms sweep outward in patterns described by the same mathematics that fascinated Archimedes over two millennia ago.&lt;/p&gt;
&lt;p&gt;Uzumaki started as a way to play with these patterns and actually see the equations behind them. It grew into a cross-platform application spanning six deployment targets: web browser, Progressive Web App, iOS, iPadOS, macOS, and watchOS.&lt;/p&gt;
&lt;h2&gt;Ten Algorithms, One Canvas&lt;/h2&gt;
&lt;p&gt;The core challenge was implementing ten distinct spiral algorithms with consistent behavior across platforms. Each spiral type follows a specific mathematical formula, most using polar coordinates where &lt;code&gt;r&lt;/code&gt; is the radius and &lt;code&gt;theta&lt;/code&gt; is the angle.&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;https://rye.dev/images/projects/uzumaki/fibonacci.png&quot; alt=&quot;Fibonacci golden spiral rendered with aurora color preset and glow effect&quot; width=&quot;1280&quot; height=&quot;720&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&amp;gt;&lt;/p&gt;
&lt;h3&gt;Polar Coordinate Spirals&lt;/h3&gt;
&lt;p&gt;The simpler spirals translate directly from mathematical formulas:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Archimedean: constant spacing between turns
r = a * theta;

// Fibonacci (Golden): self-similar, found in nature
r = a * Math.pow(PHI, (2 * theta) / Math.PI) * 0.1;

// Logarithmic: equiangular, seen in hurricanes
r = a * Math.exp(0.1 * theta);

// Fermat: parabolic, used in optics
r = a * Math.sqrt(Math.abs(theta)) * 2;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Construction Spirals&lt;/h3&gt;
&lt;p&gt;Other spirals require iterative construction rather than simple formulas:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Theodorus: built from right triangles
for (let n = 1; n &amp;lt;= numSteps; n++) {
  angle += Math.atan(1 / Math.sqrt(n));
  x += Math.cos(angle);
  y += Math.sin(angle);
}

// Vogel: phyllotaxis pattern (sunflower seeds)
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); // ~137.5 degrees
for (let n = 0; n &amp;lt; numSteps; n++) {
  const theta = n * GOLDEN_ANGLE + rotation;
  const r = scale * Math.sqrt(n) * 2;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Curlicue fractal produces particularly striking patterns by accumulating angles based on the golden ratio squared.&lt;/p&gt;
&lt;h2&gt;Web Performance: Workers and TypedArrays&lt;/h2&gt;
&lt;p&gt;Generating thousands of points per frame while maintaining 60fps required moving computation off the main thread. Web Workers handle spiral generation, but the real performance gain came from TypedArrays.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function generateSpiralTyped(params: SpiralParams): TypedSpiralPoints {
  const points = createTypedPoints(numSteps); // Float32Array
  const rotation = time * spinRate;

  for (let i = 0; i &amp;lt; numSteps; i++) {
    const theta = i * stepSize + rotation;
    const r = calculateRadius(i * stepSize, params);
    setPoint(points, i, r * Math.cos(theta), r * Math.sin(theta));
  }
  return points;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TypedArrays are transferable between the main thread and Web Workers without copying, eliminating serialization overhead. The interleaved &lt;code&gt;[x0, y0, x1, y1, ...]&lt;/code&gt; format maps directly to canvas drawing operations.&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;https://rye.dev/images/projects/uzumaki/mac-golden.png&quot; alt=&quot;Uzumaki running on macOS showing the Classic Golden spiral preset&quot; width=&quot;2560&quot; height=&quot;1600&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&amp;gt;&lt;/p&gt;
&lt;h2&gt;Swift Parity: SIMD Vectorization&lt;/h2&gt;
&lt;p&gt;The Swift implementation needed matching performance. Apple&apos;s SIMD framework enables vectorized math operations that process multiple points simultaneously:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;func generatePolarSpiral(params: SpiralParams) -&amp;gt; [SIMD2&amp;lt;Float&amp;gt;] {
  var points: [SIMD2&amp;lt;Float&amp;gt;] = []
  let rotation = params.time * params.spinRate
  
  for i in 0..&amp;lt;params.numSteps {
    let theta = Float(i) * params.stepSize + rotation
    let r = calculateRadius(Float(i) * params.stepSize, params)
    points.append(SIMD2(r * cos(theta), r * sin(theta)))
  }
  return points
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both implementations follow the same algorithm specification document, ensuring a spiral generated on web looks identical on watchOS.&lt;/p&gt;
&lt;h2&gt;Maintaining Feature Parity&lt;/h2&gt;
&lt;p&gt;Cross-platform development often leads to feature drift, where platforms diverge as each adds unique capabilities. Uzumaki maintains parity through:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Shared Algorithm Specification&lt;/strong&gt;: A single markdown document defines exact formulas and edge cases&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identical Presets&lt;/strong&gt;: Both platforms ship with the same ten curated spiral configurations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consistent Color Palettes&lt;/strong&gt;: Rainbow, Aurora, Neon, Matrix, and six other presets render identically&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Platform-Appropriate Controls&lt;/strong&gt;: Touch gestures on mobile, keyboard shortcuts on desktop, Digital Crown on watch&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;iOS 26 Liquid Glass&lt;/h2&gt;
&lt;p&gt;Apple&apos;s upcoming iOS 26 introduces the Liquid Glass design language. Uzumaki&apos;s Apple apps include conditional support that activates on iOS 26 while maintaining backward compatibility with current releases. The translucent, depth-aware interface style complements the mathematical visualizations without competing for attention.&lt;/p&gt;
&lt;h2&gt;watchOS: Spirals on Your Wrist&lt;/h2&gt;
&lt;p&gt;The watchOS build came with its own constraints. The small display forces aggressive simplification, but spirals still hold up well at reduced complexity.&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;https://rye.dev/images/projects/uzumaki/watch-sunflower.png&quot; alt=&quot;Uzumaki running on Apple Watch showing a Vogel sunflower spiral&quot; width=&quot;416&quot; height=&quot;496&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; /&amp;gt;&lt;/p&gt;
&lt;p&gt;Key adaptations for watchOS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Digital Crown&lt;/strong&gt;: Smooth zoom control with haptic feedback at preset boundaries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Swipe Navigation&lt;/strong&gt;: Horizontal swipes cycle through preset configurations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Complications&lt;/strong&gt;: Circular, corner, rectangular, and inline complications show static spiral art&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tap Gestures&lt;/strong&gt;: Single tap toggles animation, double tap resets zoom&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The complications put spiral art on the watch face, cycling to a different preset each hour.&lt;/p&gt;
&lt;h2&gt;Shareable URLs&lt;/h2&gt;
&lt;p&gt;One feature absent from native apps appears on web: shareable URLs. Every spiral configuration encodes into a URL that recreates the exact state:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function encodeState(params: SpiralParams): string {
  const state = {
    t: params.type,
    c: params.colorPreset,
    s: params.tightness,
    r: params.spinRate,
    z: params.zoom
  };
  return btoa(JSON.stringify(state));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Users can share spiral creations by copying the URL. The recipient sees the identical animation without any configuration.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Canvas rendering scales well.&lt;/strong&gt; Both HTML Canvas and SwiftUI Canvas handle thousands of animated points at 60fps when computation moves off the render thread.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TypedArrays are underutilized.&lt;/strong&gt; Most JavaScript developers default to regular arrays. For numerical computation, Float32Array offers significant performance gains and enables zero-copy Worker communication.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Algorithm documentation prevents drift.&lt;/strong&gt; Without a formal specification, subtle differences accumulate between implementations. The shared algorithm document caught several bugs during development.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Platform idioms matter.&lt;/strong&gt; Users expect swipe gestures on iOS and keyboard shortcuts on desktop. Forcing identical interaction patterns across platforms feels unnatural.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Explore mathematical spirals at &lt;a href=&quot;https://uzumaki.app&quot;&gt;uzumaki.app&lt;/a&gt; or browse the source code on &lt;a href=&quot;https://github.com/cameronrye/uzumaki&quot;&gt;GitHub&lt;/a&gt;. Download for &lt;a href=&quot;https://apps.apple.com/app/uzumaki/id6757408848&quot;&gt;iOS and iPadOS&lt;/a&gt; or &lt;a href=&quot;https://apps.apple.com/app/uzumaki/id6757408848?platform=mac&quot;&gt;macOS&lt;/a&gt; on the App Store.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>react</category><category>typescript</category><category>visualization</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/projects/uzumaki/hero.png" length="0" type="image/png"/></item><item><title>Building ClarissaBot: Vehicle Safety Intelligence with Azure AI Foundry</title><link>https://rye.dev/blog/building-clarissabot-azure-ai-foundry/</link><guid isPermaLink="true">https://rye.dev/blog/building-clarissabot-azure-ai-foundry/</guid><description>I built an AI agent that answers &apos;should I worry about my 2020 Model 3?&apos; with live NHTSA recall data: Azure AI Foundry, .NET, and RFT in practice.</description><pubDate>Sat, 20 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-clarissabot-azure-ai-foundry-a-digital-wireframe-of-a-car-s-featured-1766257591556.png&quot; alt=&quot;Building ClarissaBot: Vehicle Safety Intelligence with Azure AI Foundry&quot; /&gt;&lt;/p&gt;&lt;p&gt;Vehicle safety data exists in public databases, but getting at it means knowing where to look and how to read complex government datasets. ClarissaBot closes that gap. It&apos;s an AI agent that answers natural language questions about recalls, safety ratings, and consumer complaints by querying NHTSA data in real-time.&lt;/p&gt;
&lt;p&gt;This project became an exploration of Azure AI Foundry&apos;s capabilities: function calling, streaming responses, managed identity authentication, and the emerging practice of Reinforcement Fine-Tuning. Here&apos;s what I learned building it.&lt;/p&gt;
&lt;h2&gt;The Problem Space&lt;/h2&gt;
&lt;p&gt;Every year, NHTSA (National Highway Traffic Safety Administration) issues hundreds of vehicle recalls. Consumers can search their database, but the interface assumes you know exactly what you&apos;re looking for. Ask &quot;should I be worried about my 2020 Tesla Model 3?&quot; and you get a list of recall campaigns, not an answer.&lt;/p&gt;
&lt;p&gt;I wanted to build something that could:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Answer questions in natural language&lt;/li&gt;
&lt;li&gt;Pull real-time data from authoritative sources&lt;/li&gt;
&lt;li&gt;Maintain context across a conversation (&quot;what about complaints?&quot; after asking about recalls)&lt;/li&gt;
&lt;li&gt;Decode VINs to identify vehicles automatically&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What Azure AI Foundry Provides&lt;/h2&gt;
&lt;p&gt;Azure AI Foundry (formerly Azure Cognitive Services / Azure OpenAI) provides the infrastructure that makes ClarissaBot possible. Beyond just hosting models, it offers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Function Calling&lt;/strong&gt;: The model can decide to call external tools based on user intent&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Streaming Responses&lt;/strong&gt;: Server-Sent Events for real-time token delivery&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Managed Identity&lt;/strong&gt;: No API keys in configuration, just Azure RBAC&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reinforcement Fine-Tuning&lt;/strong&gt;: Train specialized models using custom graders&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The SDK integration with .NET is surprisingly clean. Using &lt;code&gt;Azure.AI.OpenAI&lt;/code&gt; and &lt;code&gt;DefaultAzureCredential&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var credential = new DefaultAzureCredential();
var client = new AzureOpenAIClient(new Uri(endpoint), credential);
var chatClient = client.GetChatClient(deploymentName);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No API keys to rotate. No secrets to manage. Just identity-based access.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-clarissabot-azure-ai-foundry-a-visual-representation-of-the-1766257607257.jpg&quot; alt=&quot;A visual representation of the ReAct pattern where the AI model connects to an external tool to retrieve data before answering.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Function Calling: Teaching the Model to Act&lt;/h2&gt;
&lt;p&gt;The core of ClarissaBot is function calling. Instead of training the model on vehicle data (which would become stale), I give it tools to query live APIs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ChatTool.CreateFunctionTool(
    &quot;check_recalls&quot;,
    &quot;Check for vehicle recalls from NHTSA.&quot;,
    BinaryData.FromObjectAsJson(new {
        type = &quot;object&quot;,
        properties = new {
            make = new { type = &quot;string&quot;, description = &quot;Vehicle manufacturer&quot; },
            model = new { type = &quot;string&quot;, description = &quot;Vehicle model name&quot; },
            year = new { type = &quot;integer&quot;, description = &quot;Model year&quot; }
        },
        required = new[] { &quot;make&quot;, &quot;model&quot;, &quot;year&quot; }
    }))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model receives tool definitions, decides when to call them, and synthesizes the results into conversational responses. It&apos;s the ReAct pattern in action: Reason about the task, Act by calling tools, Observe results, Repeat.&lt;/p&gt;
&lt;h2&gt;The Challenge of Vehicle Context&lt;/h2&gt;
&lt;p&gt;The hardest problem wasn&apos;t calling APIs. It was maintaining conversational context. When a user asks &quot;any recalls?&quot; after discussing their Tesla Model 3, the agent needs to remember what vehicle they&apos;re talking about.&lt;/p&gt;
&lt;p&gt;The solution tracks vehicle context across turns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public sealed class VehicleContextHistory
{
    private readonly List&amp;lt;(VehicleContext Vehicle, DateTime AccessedUtc)&amp;gt; _vehicles = [];
    
    public VehicleContext? Current =&amp;gt; _vehicles.Count &amp;gt; 0 ? _vehicles[^1].Vehicle : null;
    
    public bool AddOrUpdate(VehicleContext vehicle)
    {
        var existingIndex = _vehicles.FindIndex(v =&amp;gt; v.Vehicle.Key == vehicle.Key);
        if (existingIndex &amp;gt;= 0)
        {
            _vehicles.RemoveAt(existingIndex);
            _vehicles.Add((vehicle, DateTime.UtcNow));
            return false;
        }
        _vehicles.Add((vehicle, DateTime.UtcNow));
        return true;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Context gets injected into the system prompt on each turn, reminding the model which vehicles are being discussed.&lt;/p&gt;
&lt;h2&gt;Streaming: Making AI Feel Responsive&lt;/h2&gt;
&lt;p&gt;Nothing kills user experience like staring at a blank screen. ClarissaBot streams responses token-by-token using Server-Sent Events:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public async IAsyncEnumerable&amp;lt;StreamingEvent&amp;gt; ChatStreamRichAsync(
    string userMessage,
    string? conversationId = null,
    CancellationToken cancellationToken = default)
{
    // ... setup code ...
    
    await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
    {
        foreach (var contentPart in update.ContentUpdate)
        {
            if (!string.IsNullOrEmpty(contentPart.Text))
            {
                yield return new ContentChunkEvent(contentPart.Text);
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The frontend receives typed events: &lt;code&gt;ContentChunkEvent&lt;/code&gt; for text, &lt;code&gt;ToolCallEvent&lt;/code&gt; when querying NHTSA, &lt;code&gt;VehicleContextEvent&lt;/code&gt; when the vehicle changes. Users see the agent &quot;thinking&quot; in real-time.&lt;/p&gt;
&lt;h2&gt;Reinforcement Fine-Tuning: Training with Live Data&lt;/h2&gt;
&lt;p&gt;The most ambitious part of the project is preparing for Reinforcement Fine-Tuning (RFT). Instead of supervised fine-tuning with static examples, RFT uses a grader that evaluates model responses against live API data:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-clarissabot-azure-ai-foundry-a-diagrammatic-representation--1766257623041.jpg&quot; alt=&quot;A diagrammatic representation of the training loop where a grader evaluates and refines model outputs.&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def grade_response(response: str, expected: dict) -&amp;gt; float:
    &quot;&quot;&quot;Grades model response against live NHTSA data.&quot;&quot;&quot;
    api_result = query_nhtsa(expected[&apos;year&apos;], expected[&apos;make&apos;], expected[&apos;model&apos;])

    if expected[&apos;query_type&apos;] == &apos;recalls&apos;:
        return score_recall_response(response, api_result)
    elif expected[&apos;query_type&apos;] == &apos;safety_rating&apos;:
        return score_rating_response(response, api_result)
    # ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The training dataset includes 502 examples covering recalls, complaints, safety ratings, multi-turn conversations, and edge cases. The grader validates that responses accurately reflect real NHTSA data: if Tesla issued a recall, the model better mention it.&lt;/p&gt;
&lt;h2&gt;Infrastructure as Code with Bicep&lt;/h2&gt;
&lt;p&gt;The entire infrastructure deploys through Azure Bicep templates:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;module apiApp &apos;modules/container-app.bicep&apos; = {
  params: {
    name: &apos;${baseName}-api-${environment}&apos;
    containerAppsEnvironmentId: containerAppsEnv.outputs.id
    containerImage: apiImage
    useManagedIdentity: true
    envVars: [
      { name: &apos;AZURE_OPENAI_ENDPOINT&apos;, value: azureOpenAIEndpoint }
      { name: &apos;APPLICATIONINSIGHTS_CONNECTION_STRING&apos;, value: monitoring.outputs.appInsightsConnectionString }
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Container Apps provide serverless scaling: scale to zero when idle, burst to handle traffic. Combined with managed identity, the API authenticates to Azure OpenAI without any secrets.&lt;/p&gt;
&lt;h2&gt;Lessons Learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Function calling changes how you supply knowledge.&lt;/strong&gt; Instead of cramming knowledge into model weights, give it tools. The model reasons about &lt;em&gt;when&lt;/em&gt; to use tools; you implement &lt;em&gt;what&lt;/em&gt; tools do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Context management is underrated.&lt;/strong&gt; Users expect conversational continuity. Tracking vehicle context across turns transformed the experience from &quot;query interface&quot; to &quot;conversation.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Streaming is non-negotiable.&lt;/strong&gt; Even with fast responses, the perceived latency of waiting for a complete response feels slow. Token-by-token streaming makes AI feel alive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Managed identity simplifies everything.&lt;/strong&gt; No API key rotation, no secrets in configuration, no accidental exposure. Just RBAC permissions on Azure resources.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RFT trains against a moving target.&lt;/strong&gt; Training against live data means models stay current as the world changes. The grader becomes the source of truth.&lt;/p&gt;
&lt;h2&gt;What&apos;s Next&lt;/h2&gt;
&lt;p&gt;ClarissaBot currently uses GPT-4.1 through Azure OpenAI. The RFT training pipeline is ready for when Azure AI Foundry&apos;s reinforcement training becomes generally available. The goal: a specialized model that understands vehicle safety better than a general-purpose LLM.&lt;/p&gt;
&lt;p&gt;The project also serves as a template for building other domain-specific agents. The patterns (function calling, context management, streaming, managed identity) apply to any scenario where AI needs to interact with real-world data.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;ClarissaBot is open source at &lt;a href=&quot;https://github.com/cameronrye/clarissabot&quot;&gt;github.com/cameronrye/clarissabot&lt;/a&gt;. Try the live demo at &lt;a href=&quot;https://bot.clarissa.run&quot;&gt;bot.clarissa.run&lt;/a&gt; to check recalls on your vehicle.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>ai</category><category>agents</category><category>dotnet</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/building-clarissabot-azure-ai-foundry-a-digital-wireframe-of-a-car-s-featured-1766257591556.png" length="0" type="image/png"/></item><item><title>Building Clarissa: Learning How AI Agents Actually Work</title><link>https://rye.dev/blog/building-clarissa-ai-terminal-assistant/</link><guid isPermaLink="true">https://rye.dev/blog/building-clarissa-ai-terminal-assistant/</guid><description>I built a terminal AI agent from scratch to see how they really work: the ReAct loop, safe tool execution, and what context management actually takes.</description><pubDate>Sun, 07 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-clarissa-ai-terminal-assistant-a-visual-representation-of-an--featured-1765150768901.png&quot; alt=&quot;Building Clarissa: Learning How AI Agents Actually Work&quot; /&gt;&lt;/p&gt;&lt;p&gt;Building Clarissa started as a learning exercise. I wanted to understand how AI agents actually work. I&apos;d used Claude, ChatGPT, and various coding assistants plenty, but I couldn&apos;t have told you what really happens between the prompt and the tool call, and that bothered me. What I found was simpler than I expected in some places and a lot fussier in others.&lt;/p&gt;
&lt;p&gt;What follows is what building a terminal AI assistant from scratch taught me: the patterns that emerged, and the parts that turned out harder than they looked.&lt;/p&gt;
&lt;h2&gt;Why Build a Terminal AI Agent?&lt;/h2&gt;
&lt;p&gt;Existing AI interfaces felt disconnected from my actual workflow. I spend most of my day in the terminal, and switching to a browser or GUI to ask an AI for help created friction. More importantly, I wanted to understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How do AI agents decide when to use tools versus just respond?&lt;/li&gt;
&lt;li&gt;How do you manage context windows that can hold millions of tokens?&lt;/li&gt;
&lt;li&gt;What makes tool execution safe and reliable?&lt;/li&gt;
&lt;li&gt;How does the Model Context Protocol actually work?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The best way to learn was to build.&lt;/p&gt;
&lt;h2&gt;The ReAct Pattern: Reasoning + Acting&lt;/h2&gt;
&lt;p&gt;The core of Clarissa is the ReAct (Reasoning + Acting) pattern. This isn&apos;t some complex neural architecture; it&apos;s a surprisingly simple loop:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async run(userMessage: string): Promise&amp;lt;string&amp;gt; {
  this.messages.push({ role: &quot;user&quot;, content: userMessage });

  for (let i = 0; i &amp;lt; maxIterations; i++) {
    // Get LLM response
    const response = await llmClient.chatStreamComplete(
      this.messages,
      toolRegistry.getDefinitions()
    );

    this.messages.push(response);

    // Check for tool calls
    if (response.tool_calls?.length) {
      for (const toolCall of response.tool_calls) {
        const result = await toolRegistry.execute(
          toolCall.function.name,
          toolCall.function.arguments
        );
        this.messages.push({
          role: &quot;tool&quot;,
          tool_call_id: toolCall.id,
          content: result.content
        });
      }
      continue; // Loop back for next response
    }

    // No tool calls = final answer
    return response.content;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The LLM doesn&apos;t &quot;decide&quot; to use tools in some mysterious way. You send it available tool definitions, and it responds with either a message or a request to call specific tools. You execute those tools, feed the results back, and repeat until it responds without tool calls.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-clarissa-ai-terminal-assistant-a-diagrammatic-visualization-o-1765150787749.jpg&quot; alt=&quot;A diagram of the ReAct (Reasoning + Acting) loop: the LLM decides to use a tool, gets results, and loops back for the next step.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This loop is the entire agent. Everything else is infrastructure around it.&lt;/p&gt;
&lt;h2&gt;What I Learned About Tool Design&lt;/h2&gt;
&lt;p&gt;The most interesting challenge was designing tools that are both useful and safe. Early versions had tools that were too granular (read a single line) or too broad (execute arbitrary code). Finding the right level took a few passes.&lt;/p&gt;
&lt;h3&gt;Tool Confirmation&lt;/h3&gt;
&lt;p&gt;Potentially dangerous operations need confirmation. But what&apos;s &quot;dangerous&quot;? I settled on this heuristic:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No confirmation&lt;/strong&gt;: Reading files, listing directories, viewing git status&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confirmation required&lt;/strong&gt;: Writing files, executing shell commands, making commits&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;interface Tool {
  name: string;
  description: string;

### The Tool Registry Pattern

Rather than hardcoding tools, I built a registry that tools register themselves into:

```typescript
class ToolRegistry {
  private tools: Map&amp;lt;string, Tool&amp;gt; = new Map();

  register(tool: Tool): void {
    this.tools.set(tool.name, tool);
  }

  getDefinitions(): ToolDefinition[] {
    return Array.from(this.tools.values()).map(toolToDefinition);
  }

  async execute(name: string, args: string): Promise&amp;lt;ToolResult&amp;gt; {
    const tool = this.tools.get(name);
    const parsedArgs = JSON.parse(args);
    const validatedArgs = tool.parameters.parse(parsedArgs);
    return await tool.execute(validatedArgs);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern made MCP integration trivial. When connecting to an MCP server, I just convert its tools to my format and register them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const tools = mcpTools.map((mcpTool) =&amp;gt; ({
  name: `mcp_${serverName}_${mcpTool.name}`,
  description: mcpTool.description,
  parameters: jsonSchemaToZod(mcpTool.inputSchema),
  execute: async (input) =&amp;gt; client.callTool({ name: mcpTool.name, arguments: input }),
  requiresConfirmation: true  // MCP tools are external
}));

toolRegistry.registerMany(tools);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Context Management: The Underrated Challenge&lt;/h2&gt;
&lt;p&gt;Context windows are measured in tokens, but managing them well requires more than counting. Here&apos;s what I learned:&lt;/p&gt;
&lt;h3&gt;Token Estimation&lt;/h3&gt;
&lt;p&gt;You can&apos;t send requests to the API just to count tokens. You need local estimation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;estimateTokens(text: string): number {
  // Rough approximation: ~4 chars per token for English
  return Math.ceil(text.length / 4);
}

estimateMessageTokens(message: Message): number {
  let tokens = 0;
  if (message.content) tokens += this.estimateTokens(message.content);
  if (message.tool_calls) {
    for (const tc of message.tool_calls) {
      tokens += this.estimateTokens(tc.function.name);
      tokens += this.estimateTokens(tc.function.arguments);
    }
  }
  return tokens + 4;  // Role overhead
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-clarissa-ai-terminal-assistant-a-conceptual-illustration-of-t-1765150803838.jpg&quot; alt=&quot;An illustration of token management and smart truncation: older messages fade out while atomic groups of data stay intact.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Smart Truncation&lt;/h3&gt;
&lt;p&gt;When approaching the limit, you can&apos;t just drop the oldest messages. Tool calls and their results must stay together, or the LLM gets confused:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;truncateToFit(messages: Message[]): Message[] {
  // Group messages into atomic units
  // User message -&amp;gt; Assistant response -&amp;gt; Tool results
  const messageGroups: Message[][] = [];

  // Keep system prompt, add groups from newest to oldest
  // until we hit the limit
  for (const group of reversedGroups) {
    const groupTokens = group.reduce((sum, msg) =&amp;gt;
      sum + this.estimateMessageTokens(msg), 0);
    if (totalTokens + groupTokens &amp;lt;= availableTokens) {
      toAdd.unshift(...group);
      totalTokens += groupTokens;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was one of those bugs that took hours to track down. The LLM would suddenly start hallucinating tool results because it could see a tool call but not the corresponding result.&lt;/p&gt;
&lt;h2&gt;Building with Ink: React for the Terminal&lt;/h2&gt;
&lt;p&gt;Choosing Ink (React for CLIs) was initially just curiosity, but it proved invaluable. Terminal UIs have the same state management challenges as web UIs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function App() {
  const [messages, setMessages] = useState&amp;lt;DisplayMessage[]&amp;gt;([]);
  const [isThinking, setIsThinking] = useState(false);
  const [streamContent, setStreamContent] = useState(&apos;&apos;);

  const handleSubmit = async (input: string) =&amp;gt; {
    setIsThinking(true);
    await agent.run(input, {
      onStreamChunk: (chunk) =&amp;gt; setStreamContent(prev =&amp;gt; prev + chunk),
      onToolCall: (name) =&amp;gt; setMessages(prev =&amp;gt; [...prev, { type: &apos;tool&apos;, name }])
    });
    setIsThinking(false);
  };

  return (
    &amp;lt;Box flexDirection=&quot;column&quot;&amp;gt;
      {messages.map(msg =&amp;gt; &amp;lt;Message key={msg.id} {...msg} /&amp;gt;)}
      {isThinking &amp;amp;&amp;amp; &amp;lt;ThinkingIndicator /&amp;gt;}
      {streamContent &amp;amp;&amp;amp; &amp;lt;StreamingResponse content={streamContent} /&amp;gt;}
      &amp;lt;Input onSubmit={handleSubmit} /&amp;gt;
    &amp;lt;/Box&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The streaming response visualization was particularly satisfying. Tokens appear as they arrive, giving users immediate feedback that something is happening.&lt;/p&gt;
&lt;h2&gt;The Memory System: Persistent Context&lt;/h2&gt;
&lt;p&gt;Sessions persist conversation history, but users also wanted to tell the agent facts it should always remember:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MemoryManager {
  async add(content: string): Promise&amp;lt;Memory&amp;gt; {
    const memory = {
      id: this.generateId(),
      content: content.trim(),
      createdAt: new Date().toISOString(),
    };
    this.memories.push(memory);
    await this.save();
    return memory;
  }

  async getForPrompt(): Promise&amp;lt;string | null&amp;gt; {
    if (this.memories.length === 0) return null;
    const lines = this.memories.map((m) =&amp;gt; `- ${m.content}`);
    return `## Remembered Context\n${lines.join(&quot;\n&quot;)}`;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Memories get injected into the system prompt. Simple, but it transforms the experience. Tell Clarissa once that you prefer TypeScript over JavaScript, and it remembers across every session.&lt;/p&gt;
&lt;h2&gt;MCP Integration: Extending Without Modifying&lt;/h2&gt;
&lt;p&gt;The Model Context Protocol was the final piece. Rather than building every possible tool, Clarissa can connect to external MCP servers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mcp npx -y @modelcontextprotocol/server-filesystem /path/to/directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The integration was straightforward once the tool registry pattern was in place. The challenge was converting JSON Schema (what MCP uses) to Zod (what I use internally):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function jsonSchemaToZod(schema: unknown): z.ZodType {
  const s = schema as Record&amp;lt;string, unknown&amp;gt;;

  if (s.type === &quot;object&quot; &amp;amp;&amp;amp; s.properties) {
    const shape: Record&amp;lt;string, z.ZodType&amp;gt; = {};
    for (const [key, propSchema] of Object.entries(s.properties)) {
      shape[key] = jsonSchemaToZod(propSchema);
    }
    return z.object(shape);
  }

  if (s.type === &quot;string&quot;) return z.string();
  if (s.type === &quot;number&quot;) return z.number();
  if (s.type === &quot;boolean&quot;) return z.boolean();
  if (s.type === &quot;array&quot;) return z.array(jsonSchemaToZod(s.items));

  return z.unknown();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Key Learnings&lt;/h2&gt;
&lt;p&gt;Building Clarissa taught me several things that weren&apos;t obvious from using AI tools:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agents are loops, not magic.&lt;/strong&gt; The ReAct pattern itself is almost trivial. The complexity lives in the infrastructure around it: streaming, context management, tool safety.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tool design is UX design.&lt;/strong&gt; The tools you provide shape what the agent can do. Too few and it&apos;s limited. Too many and it gets confused. The sweet spot requires iteration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Context windows are precious.&lt;/strong&gt; Even with million-token windows, you can exhaust them quickly. Smart truncation and memory systems extend useful context far beyond raw limits.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Streaming matters.&lt;/strong&gt; Users hate staring at a blank screen. Showing tokens as they arrive transforms the experience from &quot;is this broken?&quot; to &quot;I can see it thinking.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Confirmation builds trust.&lt;/strong&gt; Approving dangerous operations prevents mistakes, but it also changes how people interact with the agent. They&apos;re more willing to ask for ambitious tasks.&lt;/p&gt;
&lt;h2&gt;Try It Yourself&lt;/h2&gt;
&lt;p&gt;Clarissa is open source and available on npm:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bun install -g clarissa
# or
npm install -g clarissa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set your OpenRouter API key and you&apos;re ready to go:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export OPENROUTER_API_KEY=your_key_here
clarissa
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The source code is at &lt;a href=&quot;https://github.com/cameronrye/clarissa&quot;&gt;github.com/cameronrye/clarissa&lt;/a&gt;, and the documentation at &lt;a href=&quot;https://clarissa.run&quot;&gt;clarissa.run&lt;/a&gt; covers everything from basic usage to MCP integration.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Clarissa taught me more than most projects I&apos;ve built. If you&apos;re curious how AI agents work, build one. The gap between using these tools and understanding them is smaller than it looks.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>ai</category><category>agents</category><category>typescript</category><category>mcp</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/building-clarissa-ai-terminal-assistant-a-visual-representation-of-an--featured-1765150768901.png" length="0" type="image/png"/></item><item><title>Retro Floppy: Building an Interactive 3.5&quot; Floppy Disk React Component</title><link>https://rye.dev/blog/retro-floppy-react-component/</link><guid isPermaLink="true">https://rye.dev/blog/retro-floppy-react-component/</guid><description>I recreated the 3.5-inch floppy disk as a React component (metal slider, write-protect tab and all): 1.44 MB of nostalgia for modern UIs.</description><pubDate>Sun, 23 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/retro-floppy-react-component-a-stylized-high-quality-3d-ren-featured-1764556329386.jpg&quot; alt=&quot;Retro Floppy: Building an Interactive 3.5&amp;quot; Floppy Disk React Component&quot; /&gt;&lt;/p&gt;&lt;p&gt;The 3.5-inch floppy disk is still one of the most recognizable icons in personal computing. It held all of 1.44 megabytes, yet those disks carried operating systems and save files people actually cared about. The Retro Floppy component rebuilds that artifact in React, interactive elements and animations included.&lt;/p&gt;
&lt;h2&gt;Anatomy of a Floppy Disk&lt;/h2&gt;
&lt;p&gt;Recreating the floppy disk faithfully requires attention to its distinctive features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Metal Slider&lt;/strong&gt;: The spring-loaded cover protecting the magnetic media&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Label Area&lt;/strong&gt;: Where users wrote cryptic file descriptions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Write-Protect Tab&lt;/strong&gt;: That small sliding switch that saved many files&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Hub Ring&lt;/strong&gt;: The metal center that the drive motor engaged&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each one is something to animate or make interactive.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/retro-floppy-react-component-an-exploded-view-diagram-showi-1764556347282.jpg&quot; alt=&quot;An exploded view diagram showing the different layers of the disk, visually representing the component composition described in the code.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Component Architecture&lt;/h2&gt;
&lt;p&gt;The component uses composition to separate visual elements:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface FloppyDiskProps {
  label?: string;
  color?: string;
  onClick?: () =&amp;gt; void;
  isInserted?: boolean;
}

export function FloppyDisk({ 
  label = &apos;UNTITLED&apos;, 
  color = &apos;#1a1a2e&apos;,
  onClick,
  isInserted = false 
}: FloppyDiskProps) {
  return (
    &amp;lt;div 
      className={`floppy-disk ${isInserted ? &apos;inserted&apos; : &apos;&apos;}`}
      style={{ &apos;--disk-color&apos;: color } as React.CSSProperties}
      onClick={onClick}
    &amp;gt;
      &amp;lt;MetalSlider /&amp;gt;
      &amp;lt;LabelArea text={label} /&amp;gt;
      &amp;lt;WriteProtectTab /&amp;gt;
      &amp;lt;HubRing /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CSS custom properties handle color theming without changing the component&apos;s structure.&lt;/p&gt;
&lt;h2&gt;The Metal Slider Animation&lt;/h2&gt;
&lt;p&gt;The sliding metal cover is the disk&apos;s most interactive element:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.metal-slider {
  position: absolute;
  width: 60%;
  height: 30%;
  background: linear-gradient(
    to bottom,
    #c0c0c0 0%,
    #808080 50%,
    #c0c0c0 100%
  );
  transform: translateX(0);
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  
  .floppy-disk:hover &amp;amp; {
    transform: translateX(30%);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The cubic-bezier timing function mimics the spring-loaded action of a real slider.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/retro-floppy-react-component-a-close-up-focusing-on-texture-1764556365254.jpg&quot; alt=&quot;A close-up focusing on texture and lighting, illustrating the goal of the CSS gradients and box-shadows discussed in the section.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Realistic Material Rendering&lt;/h2&gt;
&lt;p&gt;CSS gradients create the plastic texture:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.floppy-disk {
  background: linear-gradient(
    145deg,
    var(--disk-color) 0%,
    color-mix(in srgb, var(--disk-color) 80%, black) 100%
  );
  box-shadow:
    inset 2px 2px 4px rgba(255, 255, 255, 0.1),
    inset -2px -2px 4px rgba(0, 0, 0, 0.2),
    4px 4px 12px rgba(0, 0, 0, 0.3);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The combination of gradients and shadows creates depth that suggests the molded plastic of the original.&lt;/p&gt;
&lt;h2&gt;Label Typography&lt;/h2&gt;
&lt;p&gt;The label area deserves special attention. Many users remember handwritten labels in various states of legibility:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function LabelArea({ text }: { text: string }) {
  return (
    &amp;lt;div className=&quot;label-area&quot;&amp;gt;
      &amp;lt;div className=&quot;label-text&quot;&amp;gt;
        {text}
      &amp;lt;/div&amp;gt;
      &amp;lt;div className=&quot;label-lines&quot;&amp;gt;
        {[...Array(3)].map((_, i) =&amp;gt; (
          &amp;lt;div key={i} className=&quot;label-line&quot; /&amp;gt;
        ))}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;.label-area {
  background: #f5f5dc;
  border: 1px solid #ccc;
  padding: 8px;
}

.label-text {
  font-family: &apos;Courier New&apos;, monospace;
  font-size: 12px;
  text-transform: uppercase;
}

.label-lines {
  margin-top: 4px;
  
  .label-line {
    height: 1px;
    background: #ddd;
    margin: 4px 0;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ruled lines evoke office supply aesthetics of the era.&lt;/p&gt;
&lt;h2&gt;Insertion Animation&lt;/h2&gt;
&lt;p&gt;Simulating disk insertion adds another layer of interactivity:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@keyframes insert-disk {
  0% {
    transform: translateY(0) rotateX(0);
  }
  50% {
    transform: translateY(20px) rotateX(-5deg);
  }
  100% {
    transform: translateY(80%) rotateX(0);
    opacity: 0.7;
  }
}

.floppy-disk.inserted {
  animation: insert-disk 0.5s ease-in-out forwards;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The slight rotation mimics the angle at which disks were typically inserted into drives.&lt;/p&gt;
&lt;h2&gt;Sound Effects Integration&lt;/h2&gt;
&lt;p&gt;Audio feedback sells the nostalgia:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function useFloppySounds() {
  const clickSound = useRef(new Audio(&apos;/sounds/disk-click.mp3&apos;));
  const insertSound = useRef(new Audio(&apos;/sounds/disk-insert.mp3&apos;));
  
  return {
    playClick: () =&amp;gt; clickSound.current.play(),
    playInsert: () =&amp;gt; insertSound.current.play()
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The characteristic clicking and whirring of floppy drives remains deeply embedded in the memory of anyone who used them.&lt;/p&gt;
&lt;h2&gt;Accessibility Considerations&lt;/h2&gt;
&lt;p&gt;Interactive components must remain accessible:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;div
  className=&quot;floppy-disk&quot;
  role=&quot;button&quot;
  tabIndex={0}
  aria-label={`Floppy disk labeled ${label}`}
  onKeyDown={(e) =&amp;gt; {
    if (e.key === &apos;Enter&apos; || e.key === &apos; &apos;) {
      onClick?.();
    }
  }}
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keyboard navigation and screen reader support ensure the component works for all users.&lt;/p&gt;
&lt;h2&gt;Practical Applications&lt;/h2&gt;
&lt;p&gt;The component finds use in various contexts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retro-themed websites&lt;/strong&gt;: Adding period-appropriate UI elements&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save indicators&lt;/strong&gt;: Visual feedback for save operations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Portfolio pieces&lt;/strong&gt;: Showcasing creative CSS and React skills&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Educational content&lt;/strong&gt;: Illustrating computing history&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Performance Optimization&lt;/h2&gt;
&lt;p&gt;Animations should not impact performance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.floppy-disk {
  will-change: transform;
  transform: translateZ(0);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These hints enable GPU acceleration for smooth animations even on less powerful devices.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;See the Retro Floppy component in action at &lt;a href=&quot;https://cameronrye.github.io/retro-floppy/&quot;&gt;cameronrye.github.io/retro-floppy&lt;/a&gt; or explore the source code on &lt;a href=&quot;https://github.com/cameronrye/retro-floppy&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>react</category><category>typescript</category><category>css</category><category>retro-computing</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/retro-floppy-react-component-a-stylized-high-quality-3d-ren-featured-1764556329386.jpg" length="0" type="image/jpeg"/></item><item><title>DosKit: Running DOS Software in Modern Browsers with WebAssembly</title><link>https://rye.dev/blog/doskit-webassembly-dos-emulation/</link><guid isPermaLink="true">https://rye.dev/blog/doskit-webassembly-dos-emulation/</guid><description>DOS software is losing the hardware it ran on. DosKit is my answer: js-dos and WebAssembly wrapped into a foundation for running it in any browser.</description><pubDate>Sun, 16 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/doskit-webassembly-dos-emulation-a-visualization-of-the-bridge--featured-1764557591442.jpg&quot; alt=&quot;DosKit: Running DOS Software in Modern Browsers with WebAssembly&quot; /&gt;&lt;/p&gt;&lt;p&gt;The DOS era produced a lot of software worth keeping around: demos, games, and the productivity apps people ran for years. As the original hardware fails, that software gets harder to run. DosKit is my attempt to keep it runnable in a browser, using WebAssembly to execute the DOS binaries directly.&lt;/p&gt;
&lt;h2&gt;The Preservation Imperative&lt;/h2&gt;
&lt;p&gt;Old hardware fails, and modern operating systems drop support for the software that ran on it. Running that software in a browser sidesteps both problems: nothing to install, it works on any platform with a browser, and web standards tend to stick around.&lt;/p&gt;
&lt;p&gt;DosKit builds on js-dos, a WebAssembly port of DOSBox. It handles the fiddly parts of emulation setup for you but leaves the configuration exposed when you need to tune things.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/doskit-webassembly-dos-emulation-an-abstract-diagram-illustrati-1764557610398.jpg&quot; alt=&quot;An abstract diagram illustrating the translation of raw DOS binaries through the WebAssembly engine into smooth browser execution.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;WebAssembly: The Enabling Technology&lt;/h2&gt;
&lt;p&gt;WebAssembly makes browser-based DOS emulation practical by providing near-native execution speed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function initializeDosKit(containerElement, programUrl) {
  const bundle = await Dos(containerElement);
  const instance = await bundle.run(programUrl);
  
  return {
    instance,
    sendKey: (key) =&amp;gt; instance.sendKeyEvent(key, true),
    setSpeed: (cycles) =&amp;gt; instance.setConfig({ cycles })
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The compiled DOSBox core executes at speeds sufficient for even demanding DOS software, including action games and complex demos.&lt;/p&gt;
&lt;h2&gt;Cross-Platform Consistency&lt;/h2&gt;
&lt;p&gt;One of DosKit&apos;s primary goals is consistent behavior across platforms:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const platformConfig = {
  mobile: {
    touchControls: true,
    virtualKeyboard: true,
    audioContext: &apos;user-gesture-required&apos;
  },
  desktop: {
    touchControls: false,
    fullscreenSupport: true,
    keyboardCapture: true
  }
};

function detectPlatform() {
  const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
  return isMobile ? platformConfig.mobile : platformConfig.desktop;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mobile devices receive touch controls and virtual keyboards, while desktop browsers get full keyboard capture and fullscreen support.&lt;/p&gt;
&lt;h2&gt;Audio Handling Challenges&lt;/h2&gt;
&lt;p&gt;Browser audio policies require careful handling. Modern browsers block autoplay, requiring user interaction before audio can begin:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class AudioManager {
  constructor() {
    this.context = null;
    this.initialized = false;
  }
  
  async initialize() {
    if (this.initialized) return;
    
    this.context = new AudioContext();
    if (this.context.state === &apos;suspended&apos;) {
      await this.context.resume();
    }
    this.initialized = true;
  }
}

// Initialize on first user interaction
document.addEventListener(&apos;click&apos;, () =&amp;gt; {
  audioManager.initialize();
}, { once: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That keeps audio working without fighting the browser&apos;s autoplay rules.&lt;/p&gt;
&lt;h2&gt;File System Abstraction&lt;/h2&gt;
&lt;p&gt;DOS programs expect a filesystem. DosKit provides virtual filesystem support:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function mountFilesystem(instance, files) {
  for (const [path, content] of Object.entries(files)) {
    await instance.fs.writeFile(path, content);
  }
}

// Example: Mount a configuration file
await mountFilesystem(dosInstance, {
  &apos;/CONFIG.SYS&apos;: &apos;FILES=40\nBUFFERS=25&apos;,
  &apos;/AUTOEXEC.BAT&apos;: &apos;@ECHO OFF\nPATH C:\\;C:\\DOS&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Programs can come from a URL, IndexedDB, or a user upload, and the DOS side sees a normal filesystem either way.&lt;/p&gt;
&lt;h2&gt;Performance Tuning&lt;/h2&gt;
&lt;p&gt;DOS software varies dramatically in resource requirements. DosKit provides configuration options:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const performanceProfiles = {
  &apos;8086&apos;: { cycles: 300, type: &apos;real&apos; },
  &apos;286&apos;: { cycles: 3000, type: &apos;real&apos; },
  &apos;386&apos;: { cycles: 8000, type: &apos;real&apos; },
  &apos;486&apos;: { cycles: 25000, type: &apos;real&apos; },
  &apos;max&apos;: { cycles: &apos;max&apos;, type: &apos;auto&apos; }
};

function applyPerformanceProfile(instance, profile) {
  const config = performanceProfiles[profile];
  instance.setConfig({
    cycles: config.cycles,
    cycleType: config.type
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cycle-accurate emulation ensures software runs at authentic speeds, important for games with timing-dependent mechanics.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/doskit-webassembly-dos-emulation-a-smartphone-screen-running-a--1764557630204.jpg&quot; alt=&quot;A smartphone screen running a retro game with a visible virtual joystick overlay, highlighting mobile compatibility.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Touch Controls for Mobile&lt;/h2&gt;
&lt;p&gt;Mobile support requires virtual input devices:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class VirtualJoystick {
  constructor(container) {
    this.element = document.createElement(&apos;div&apos;);
    this.element.className = &apos;virtual-joystick&apos;;
    container.appendChild(this.element);
    
    this.bindTouchEvents();
  }
  
  bindTouchEvents() {
    this.element.addEventListener(&apos;touchmove&apos;, (e) =&amp;gt; {
      const touch = e.touches[0];
      const rect = this.element.getBoundingClientRect();
      const x = (touch.clientX - rect.left) / rect.width;
      const y = (touch.clientY - rect.top) / rect.height;
      
      this.emitDirection(x, y);
    });
  }
  
  emitDirection(x, y) {
    // Convert position to arrow key presses
    if (x &amp;lt; 0.3) this.sendKey(&apos;ArrowLeft&apos;);
    if (x &amp;gt; 0.7) this.sendKey(&apos;ArrowRight&apos;);
    if (y &amp;lt; 0.3) this.sendKey(&apos;ArrowUp&apos;);
    if (y &amp;gt; 0.7) this.sendKey(&apos;ArrowDown&apos;);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These controls make DOS software accessible on devices that never existed during the DOS era.&lt;/p&gt;
&lt;h2&gt;State Preservation&lt;/h2&gt;
&lt;p&gt;Save states enable users to pause and resume sessions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function saveState(instance) {
  const state = await instance.saveState();
  const blob = new Blob([state], { type: &apos;application/octet-stream&apos; });
  
  // Store in IndexedDB for persistence
  await stateStorage.save(&apos;last-session&apos;, blob);
}

async function loadState(instance) {
  const blob = await stateStorage.load(&apos;last-session&apos;);
  if (blob) {
    const state = await blob.arrayBuffer();
    await instance.loadState(state);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Close the tab, come back later, and pick up where you left off.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Old software doesn&apos;t have to end up in a museum or rot on dead hardware. WebAssembly is fast enough for faithful emulation, and the rest of the browser platform covers input, audio, and storage. What you get is DOS software you can open and run without hunting down a period-correct PC.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Experience DOS classics at &lt;a href=&quot;https://doskit.net&quot;&gt;doskit.net&lt;/a&gt; or explore the source at &lt;a href=&quot;https://github.com/cameronrye/doskit&quot;&gt;github.com/cameronrye/doskit&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>webassembly</category><category>javascript</category><category>retro-computing</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/doskit-webassembly-dos-emulation-a-visualization-of-the-bridge--featured-1764557591442.jpg" length="0" type="image/jpeg"/></item><item><title>Frostpane: A Modern CSS Library for Frosted Glass Effects</title><link>https://rye.dev/blog/frostpane-liquid-glass-css/</link><guid isPermaLink="true">https://rye.dev/blog/frostpane-liquid-glass-css/</guid><description>Frostpane is my SCSS library for liquid-glass UI: backdrop-filter blur, custom-property theming, and effects that stay fast across browsers.</description><pubDate>Sat, 08 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/frostpane-liquid-glass-css-a-visually-striking-hero-image-featured-1764556408355.jpg&quot; alt=&quot;Frostpane: A Modern CSS Library for Frosted Glass Effects&quot; /&gt;&lt;/p&gt;&lt;p&gt;Frosted glass is everywhere in interface design right now: translucent panels, blurred backgrounds, and thin borders that give a layout some depth. Frostpane is my SCSS library for building those effects without fighting performance or browser support every time.&lt;/p&gt;
&lt;h2&gt;The Anatomy of Frosted Glass Effects&lt;/h2&gt;
&lt;p&gt;The effect comes from a handful of CSS properties stacked together:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.frost-panel {
  background: rgba(255, 255, 255, 0.15);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 16px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each property contributes to the effect: the semi-transparent background provides the base layer, &lt;code&gt;backdrop-filter&lt;/code&gt; creates the blur on content behind the element, the subtle border adds definition, and the shadow creates depth.&lt;/p&gt;
&lt;h2&gt;SCSS Architecture for Flexibility&lt;/h2&gt;
&lt;p&gt;Frostpane combines CSS custom properties with SCSS mixins so the look stays adjustable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;:root {
  --frost-blur: 10px;
  --frost-saturation: 180%;
  --frost-opacity: 0.15;
  --frost-border-opacity: 0.2;
  --frost-radius: 16px;
}

@mixin frost-base($blur: var(--frost-blur)) {
  backdrop-filter: blur($blur) saturate(var(--frost-saturation));
  -webkit-backdrop-filter: blur($blur) saturate(var(--frost-saturation));
  background: rgba(255, 255, 255, var(--frost-opacity));
  border: 1px solid rgba(255, 255, 255, var(--frost-border-opacity));
  border-radius: var(--frost-radius);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The custom properties give you runtime theming with sensible defaults. You can override individual values in the browser without recompiling the stylesheet.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/frostpane-liquid-glass-css-a-side-by-side-comparison-show-1764556449823.jpg&quot; alt=&quot;A side-by-side comparison showing how liquid glass effects adapt to light and dark color schemes.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Light and Dark Mode Variants&lt;/h2&gt;
&lt;p&gt;Liquid glass requires different treatments for light and dark backgrounds:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@mixin frost-light {
  @include frost-base;
  background: rgba(255, 255, 255, 0.25);
  border-color: rgba(255, 255, 255, 0.3);
}

@mixin frost-dark {
  @include frost-base;
  background: rgba(0, 0, 0, 0.25);
  border-color: rgba(255, 255, 255, 0.1);
}

@media (prefers-color-scheme: dark) {
  .frost-panel {
    @include frost-dark;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;prefers-color-scheme&lt;/code&gt; query swaps variants automatically, so the panels match whichever theme the user is running.&lt;/p&gt;
&lt;h2&gt;Performance Considerations&lt;/h2&gt;
&lt;p&gt;Backdrop filters can impact rendering performance, particularly on lower-powered devices. Frostpane includes performance-conscious defaults:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@mixin frost-performant {
  @include frost-base;
  
  @media (prefers-reduced-motion: reduce) {
    backdrop-filter: none;
    background: rgba(255, 255, 255, 0.85);
  }
  
  // Fallback for unsupported browsers
  @supports not (backdrop-filter: blur(1px)) {
    background: rgba(255, 255, 255, 0.9);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Browsers without &lt;code&gt;backdrop-filter&lt;/code&gt; get a solid background, and anyone who has asked for reduced motion drops the blur entirely.&lt;/p&gt;
&lt;h2&gt;Animation Integration&lt;/h2&gt;
&lt;p&gt;A little animation sells the effect:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@mixin frost-animated {
  @include frost-base;
  transition: 
    backdrop-filter 0.3s ease,
    background 0.3s ease,
    transform 0.3s ease;
  
  &amp;amp;:hover {
    --frost-blur: 15px;
    --frost-opacity: 0.2;
    transform: translateY(-2px);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On hover the blur and opacity ramp up and the panel lifts a couple of pixels, all transitioned rather than snapped.&lt;/p&gt;
&lt;h2&gt;Highlight Effects&lt;/h2&gt;
&lt;p&gt;Adding highlights creates the impression of light catching the glass surface:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@mixin frost-highlight {
  @include frost-base;
  position: relative;
  
  &amp;amp;::before {
    content: &apos;&apos;;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 1px;
    background: linear-gradient(
      90deg,
      transparent,
      rgba(255, 255, 255, 0.4),
      transparent
    );
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This subtle gradient along the top edge suggests a light source above the element, adding to the three-dimensional illusion.&lt;/p&gt;
&lt;h2&gt;Browser Compatibility&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;backdrop-filter&lt;/code&gt; has broad support now, but it&apos;s still worth handling the browsers that lack it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.frost-panel {
  // Solid fallback for older browsers
  background: rgba(255, 255, 255, 0.9);
  
  @supports (backdrop-filter: blur(1px)) {
    background: rgba(255, 255, 255, 0.15);
    backdrop-filter: blur(10px);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The feature query means older browsers get a solid panel instead of a transparent, unreadable one.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/frostpane-liquid-glass-css-a-practical-application-shot-s-1764556472515.jpg&quot; alt=&quot;A practical application shot showing how the different components (nav, modal, card) look when composed together in a full UI.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Component Variations&lt;/h2&gt;
&lt;p&gt;Frostpane includes pre-built component styles for common use cases:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.frost-card {
  @include frost-base;
  padding: 1.5rem;
}

.frost-nav {
  @include frost-base;
  position: fixed;
  top: 0;
  width: 100%;
  z-index: 100;
}

.frost-modal {
  @include frost-base;
  max-width: 500px;
  margin: auto;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Treat them as starting points and adjust from there to fit whatever you&apos;re building.&lt;/p&gt;
&lt;h2&gt;Integration Patterns&lt;/h2&gt;
&lt;p&gt;Dropping it into an existing project is straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Import the library
@use &apos;frostpane&apos; as frost;

// Apply to custom components
.my-sidebar {
  @include frost.frost-base;
  @include frost.frost-highlight;
  width: 280px;
  padding: 1rem;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The namespaced &lt;code&gt;@use&lt;/code&gt; keeps Frostpane&apos;s mixins from colliding with anything else in your styles.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;See Frostpane in action at &lt;a href=&quot;https://cameronrye.github.io/frostpane/&quot;&gt;cameronrye.github.io/frostpane&lt;/a&gt; or explore the source code on &lt;a href=&quot;https://github.com/cameronrye/frostpane&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>css</category><category>open-source</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/frostpane-liquid-glass-css-a-visually-striking-hero-image-featured-1764556408355.jpg" length="0" type="image/jpeg"/></item><item><title>Building ClaytonRye.com for My Father&apos;s 77th Birthday</title><link>https://rye.dev/blog/building-claytonrye-com-for-my-fathers-77th-birthday/</link><guid isPermaLink="true">https://rye.dev/blog/building-claytonrye-com-for-my-fathers-77th-birthday/</guid><description>A website honoring my father Clayton Rye&apos;s five decades as a documentary filmmaker, Vietnam veteran, and film professor, launched for his 77th birthday.</description><pubDate>Wed, 29 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-claytonrye-com-for-my-fathers-77th-birthday-a-visual-metaphor-for-the-proj-featured-1764559980326.jpg&quot; alt=&quot;Building ClaytonRye.com for My Father&amp;#39;s 77th Birthday&quot; /&gt;&lt;/p&gt;&lt;p&gt;Today, October 29, 2025, my father Clayton Rye turns 77. To celebrate, I&apos;m launching &lt;a href=&quot;https://claytonrye.com/&quot;&gt;ClaytonRye.com&lt;/a&gt;, a website honoring his life as an award-winning documentary filmmaker, Vietnam War veteran, and Professor Emeritus at Ferris State University.&lt;/p&gt;
&lt;p&gt;It&apos;s a birthday gift, but really it&apos;s a record of his life&apos;s work: giving voice to the voiceless, preserving stories that might otherwise be forgotten, and teaching students that filmmaking is both a craft and a moral responsibility.&lt;/p&gt;
&lt;h2&gt;A Life Worth Documenting&lt;/h2&gt;
&lt;p&gt;My father&apos;s story begins with the war. From 1968 to 1970, he served in the U.S. Army&apos;s 1st Airborne Division as a radio operator, reaching the rank of Sergeant First Class. The war left a mark that shaped how he told stories for the next five decades: its complexity, its moral ambiguity, its human cost.&lt;/p&gt;
&lt;p&gt;After Vietnam, he studied visual storytelling, earning a BA in Advertising from Michigan State University and an MFA in Cinema from the University of Southern California. Plenty of his classmates chased commercial work. Clayton went toward documentary, toward the stories that mattered and the voices that needed amplifying.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-claytonrye-com-for-my-fathers-77th-birthday-a-moody-atmospheric-shot-of-ph-1764559996130.jpg&quot; alt=&quot;A moody, atmospheric shot of physical archival items (film, photos, audio gear) representing the content being preserved.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Documentarian&apos;s Mission&lt;/h2&gt;
&lt;p&gt;Over his career, Clayton made films that stand as historical documents. He wasn&apos;t in it for entertainment or profit. The point was to bear witness, to record testimony, to make sure important stories survived for future generations.&lt;/p&gt;
&lt;h3&gt;Ten Vietnam Vets (1980s)&lt;/h3&gt;
&lt;p&gt;One of his earliest major works, &lt;em&gt;Ten Vietnam Vets&lt;/em&gt;, featured firsthand accounts from fellow veterans. Having served himself, Clayton brought unique credibility and empathy to these interviews. The film won multiple awards including First Place at the Northwest Film Studies Center Festival and a Special Jury Award at the San Francisco International Film Festival. More importantly, it was selected for permanent preservation in the Texas Tech University and LaSalle University Vietnam Archives, so these testimonies would endure.&lt;/p&gt;
&lt;h3&gt;Jim Crow&apos;s Museum (2004)&lt;/h3&gt;
&lt;p&gt;In collaboration with Dr. David Pilgrim at Ferris State University, Clayton created a documentary exploring the Jim Crow Museum of Racist Memorabilia. The film examines how objects of oppression can become tools for education. Confronting the painful artifacts of racism, it argues, can teach tolerance and promote social justice. The documentary won Best Documentary at multiple festivals and was broadcast on PBS stations nationwide.&lt;/p&gt;
&lt;h3&gt;Detroit Civil Rights Trilogy (2010)&lt;/h3&gt;
&lt;p&gt;Perhaps his most significant work, the &lt;em&gt;Detroit Civil Rights Trilogy&lt;/em&gt; brought to light three pivotal stories from Michigan&apos;s civil rights history:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Last Survivor of the Ford Hunger March&lt;/strong&gt;: Dave Moore&apos;s firsthand account of the 1932 Ford Hunger March at the River Rouge plant, where police opened fire on over 3,000 unemployed workers during the Great Depression, killing five.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rosa Parks of the Boblo Boat&lt;/strong&gt;: Sara Elizabeth Haskell&apos;s 1945 challenge to segregation in Detroit, a full decade before Rosa Parks&apos; famous bus protest. When she was denied access to the dance floor on the Boblo Island ferry, she fought back and took her case to the Michigan Supreme Court.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mr. Interlocutor of Mount Clemens&lt;/strong&gt;: Duane Gerlach&apos;s story of performing in blackface minstrel shows and his journey from participant to advocate, examining how these racist performances shaped American culture.&lt;/p&gt;
&lt;p&gt;The trilogy won First Place for Documentary Feature at the Made-in-Michigan Film Festival in 2010, but its real value lies in preserving these stories before they were lost forever. Dave Moore was the last living survivor of the Ford Hunger March. Without Clayton&apos;s work, his testimony would have died with him.&lt;/p&gt;
&lt;h2&gt;The Educator&apos;s Legacy&lt;/h2&gt;
&lt;p&gt;In 1988, Clayton joined the faculty at Ferris State University, where he would spend the next 23 years teaching film production, television, and digital media production. Originally hired to teach film production, he adapted as the media landscape evolved, helping students master both traditional filmmaking techniques and emerging digital technologies.&lt;/p&gt;
&lt;p&gt;His teaching came down to one belief: media creators have a responsibility to tell truthful, meaningful stories. Every frame, every edit, every story choice carried weight, he told his students. The technical skill was the easy part. What mattered more was listening, researching, and approaching subjects with respect and empathy.&lt;/p&gt;
&lt;p&gt;Plenty of his students credit Clayton with teaching them that media can be a force for good. His legacy lives on in his films, and in the work of the filmmakers he mentored over more than two decades.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-claytonrye-com-for-my-fathers-77th-birthday-an-abstract-representation-of--1764560012786.jpg&quot; alt=&quot;An abstract representation of the &apos;stack&apos;: transforming raw film content into structured digital data/code.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Building a Digital Legacy&lt;/h2&gt;
&lt;p&gt;When I started thinking about what to give my father for his 77th birthday, the answer was clear: his work needed to be preserved and made accessible. His documentaries are historical records. His story deserves to be told. And future generations should be able to find his life&apos;s work and learn from it: researchers, educators, students, family members.&lt;/p&gt;
&lt;h3&gt;The Technical Challenge&lt;/h3&gt;
&lt;p&gt;Building ClaytonRye.com wasn&apos;t a typical portfolio site or marketing page. It came with its own constraints, so it needed to be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Archival&lt;/strong&gt;: Complete documentation of his filmography&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Respectful&lt;/strong&gt;: Design that honored both the filmmaker and his subjects&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessible&lt;/strong&gt;: Fast, responsive, and usable by everyone&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Discoverable&lt;/strong&gt;: Properly structured for search engines and researchers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enduring&lt;/strong&gt;: Built to last, not dependent on trendy frameworks or services&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I built it on Astro, a static site generator that ships minimal JavaScript and puts content ahead of complexity. The result is fast and accessible, and it should still work years from now.&lt;/p&gt;
&lt;h3&gt;Design Philosophy&lt;/h3&gt;
&lt;p&gt;Every design decision followed Clayton&apos;s approach to his films: keep it restrained, keep the focus on the stories.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Typography&lt;/strong&gt;: Playfair Display for headings, a classic serif that carries some weight and dignity. The hierarchy keeps things clear without getting in the way.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Color Palette&lt;/strong&gt;: A gold accent (#c9a961) adds warmth without pulling attention off the content. It holds up in both light and dark modes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Layout&lt;/strong&gt;: Open layouts with generous whitespace. The design stays out of the way of the content.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance&lt;/strong&gt;: Images are responsive and optimized, videos load lazily, and pages come in fast.&lt;/p&gt;
&lt;h3&gt;Content Organization&lt;/h3&gt;
&lt;p&gt;The site is organized around five main sections:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Films&lt;/strong&gt;: Complete filmography with detailed information about each work, awards, distribution, and historical context. Featured presentation of the &lt;em&gt;Detroit Civil Rights Trilogy&lt;/em&gt; with embedded trailers and supplementary materials.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;About&lt;/strong&gt;: A full biography covering his path from Vietnam veteran to documentarian: education, career timeline, teaching philosophy, and key collaborations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Service&lt;/strong&gt;: Dedicated documentation of his Vietnam War service, including complete service record, historical context, and the connection between his military experience and documentary work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Writing&lt;/strong&gt;: Showcase of his written work, including his book &lt;em&gt;Peckerwood&lt;/em&gt; and screenplay development.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Videos&lt;/strong&gt;: A video archive with trailers, full documentaries where available, and supplementary content.&lt;/p&gt;
&lt;h3&gt;Technical Implementation&lt;/h3&gt;
&lt;p&gt;The site uses modern web tools while staying simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Astro&lt;/strong&gt;: Static site generation with component islands for interactivity&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom Backend&lt;/strong&gt;: Content management and media handling&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Theme Switching&lt;/strong&gt;: Light/dark/system mode with localStorage persistence&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Video Integration&lt;/strong&gt;: Lightweight &lt;code&gt;lite-youtube&lt;/code&gt; component for performance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured Data&lt;/strong&gt;: Schema.org markup for discoverability&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Responsive Images&lt;/strong&gt;: Optimized images with modern formats (WebP, AVIF)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessibility&lt;/strong&gt;: WCAG AA compliant with semantic HTML and keyboard navigation&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Stories That Matter&lt;/h2&gt;
&lt;p&gt;What strikes me most about my father&apos;s work is how consistently he went after stories that matter. He never chased commercial success or trendy subjects. He went looking for the forgotten, the marginalized, the voices nobody else was recording.&lt;/p&gt;
&lt;p&gt;Dave Moore&apos;s testimony about the Ford Hunger March. Sara Elizabeth Haskell&apos;s fight against segregation a decade before Rosa Parks. The painful history of blackface minstrel shows. Vietnam veterans&apos; firsthand accounts. The Jim Crow Museum&apos;s mission to teach tolerance through confronting intolerance.&lt;/p&gt;
&lt;p&gt;These aren&apos;t easy stories, and they aren&apos;t comfortable. But they&apos;re essential, and without documentarians like Clayton Rye, they&apos;d be lost.&lt;/p&gt;
&lt;h2&gt;Preserving What Matters&lt;/h2&gt;
&lt;p&gt;Most video today is made for views: algorithmic feeds, engagement metrics, the whole machine. My father&apos;s work is a reminder of what documentary can be instead, a tool for education, empathy, and historical preservation. His films don&apos;t chase likes. They record testimony and try to do right by the people in them.&lt;/p&gt;
&lt;p&gt;Building ClaytonRye.com made me think hard about what actually matters here. It isn&apos;t flashy animations or trendy design patterns. It&apos;s clear presentation, accessibility, and something that will still be around in twenty years.&lt;/p&gt;
&lt;h2&gt;The Gift of Time&lt;/h2&gt;
&lt;p&gt;My father is 77 today. The last survivor of the Ford Hunger March was in his 90s when Clayton interviewed him. Sara Elizabeth Haskell&apos;s story might have been lost if not documented. The Vietnam veterans in &lt;em&gt;Ten Vietnam Vets&lt;/em&gt; are aging, their numbers dwindling.&lt;/p&gt;
&lt;p&gt;Time is the enemy of memory. Stories fade, witnesses die, history gets forgotten or rewritten. Documentary filmmakers like my father push back against that. They preserve and they document, so the stories outlast the people who lived them.&lt;/p&gt;
&lt;p&gt;This website is my contribution to that fight. By making his work accessible and properly documented, I&apos;m helping make sure his five decades of storytelling keep teaching people long after any of us are gone.&lt;/p&gt;
&lt;h2&gt;Happy Birthday, Dad&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE] A Personal Note
Building this website has been one of the most meaningful projects of my career. Not for the technical challenges or the design work, but because it made me sit with the full scope of my father&apos;s life. Going through his filmography, watching his documentaries, and writing all of this down left me with a lot of respect and gratitude.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Happy 77th birthday, Dad. Thank you for showing me that technology and creativity can serve purposes beyond profit and entertainment. Thank you for demonstrating that storytelling is a moral responsibility. Thank you for spending five decades giving voice to the voiceless and preserving stories that matter.&lt;/p&gt;
&lt;p&gt;This website is my attempt to honor that legacy and ensure your work continues to inspire future generations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Visit ClaytonRye.com: &lt;a href=&quot;https://claytonrye.com/&quot;&gt;claytonrye.com&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Technical Notes&lt;/h2&gt;
&lt;p&gt;For anyone curious about how it&apos;s built, here are a few of the patterns I used:&lt;/p&gt;
&lt;h3&gt;Static Site Generation with Astro&lt;/h3&gt;
&lt;p&gt;Astro fit this project well. The site ships minimal JavaScript, just what theme switching and video embedding need. Content pages are pre-rendered HTML, so they load instantly and work everywhere.&lt;/p&gt;
&lt;h3&gt;Performance Optimization&lt;/h3&gt;
&lt;p&gt;A few things keep the site fast and accessible:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Image Optimization&lt;/strong&gt;: Responsive images with modern formats&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lazy Loading&lt;/strong&gt;: Videos and below-the-fold images load on-demand&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Critical CSS&lt;/strong&gt;: Inline critical styles for instant rendering&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Font Optimization&lt;/strong&gt;: Efficient web font loading with system font fallbacks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimal JavaScript&lt;/strong&gt;: Only essential interactivity included&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Accessibility First&lt;/h3&gt;
&lt;p&gt;WCAG AA compliance ensures the site is accessible to everyone:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Semantic HTML&lt;/strong&gt;: Proper heading hierarchy and landmark regions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keyboard Navigation&lt;/strong&gt;: Full keyboard accessibility throughout&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Screen Reader Support&lt;/strong&gt;: ARIA labels and descriptive text&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Color Contrast&lt;/strong&gt;: Compliant contrast ratios in both light and dark modes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Focus Management&lt;/strong&gt;: Clear focus indicators and logical tab order&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Structured Data&lt;/h3&gt;
&lt;p&gt;Schema.org markup handles discoverability:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Person Schema&lt;/strong&gt;: Detailed biographical information&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FAQPage Schema&lt;/strong&gt;: Common questions about Clayton&apos;s work&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BreadcrumbList Schema&lt;/strong&gt;: Clear navigation hierarchy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Optimized Metadata&lt;/strong&gt;: Proper titles, descriptions, and social sharing&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The complete source code and technical details are documented in the &lt;a href=&quot;https://rye.dev/projects/claytonrye-com/&quot;&gt;ClaytonRye.com project page&lt;/a&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Have stories about Clayton&apos;s films or teaching? I&apos;d love to hear them. His work touched many lives, and preserving those connections is part of honoring his legacy.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>personal</category><category>astro</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/building-claytonrye-com-for-my-fathers-77th-birthday-a-visual-metaphor-for-the-proj-featured-1764559980326.jpg" length="0" type="image/jpeg"/></item><item><title>The Web Audio API: A Cautionary Tale of Ambitious Design and Practical Limitations</title><link>https://rye.dev/blog/web-audio-api-design-philosophy-and-reality/</link><guid isPermaLink="true">https://rye.dev/blog/web-audio-api-design-philosophy-and-reality/</guid><description>Why the Web Audio API&apos;s ambitious design collided with what developers actually needed, and what its history teaches about web standards.</description><pubDate>Mon, 20 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/web-audio-api-design-philosophy-and-reality-a-visual-metaphor-for-the-caut-featured-1764556521977.jpg&quot; alt=&quot;The Web Audio API: A Cautionary Tale of Ambitious Design and Practical Limitations&quot; /&gt;&lt;/p&gt;&lt;p&gt;The Web Audio API is one of the most ambitious and controversial additions to the web platform. It was designed to bring professional grade audio processing to browsers, and it promised to run game audio engines and full digital audio workstations (DAWs) entirely in the browser. Nearly a decade after its initial release, it has widespread browser support and some impressive demos to show for it. The story underneath is messier: design compromises, unmet expectations, and an unresolved argument over what audio on the web should be.&lt;/p&gt;
&lt;p&gt;The API&apos;s troubled history has more to teach than most technical critiques do. It shows how web standards actually get made, what goes wrong when you design by committee, and how far apart audio professionals and working developers can be about what developers actually need.&lt;/p&gt;
&lt;h2&gt;What Is the Web Audio API?&lt;/h2&gt;
&lt;p&gt;The Web Audio API is a high-level JavaScript API for processing and synthesizing audio in web applications. Unlike the simple &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element designed for basic playback, the Web Audio API provides a sophisticated graph-based system for routing and processing audio.&lt;/p&gt;
&lt;p&gt;The API is built around an &lt;strong&gt;audio routing graph&lt;/strong&gt; made up of interconnected nodes:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/web-audio-api-design-philosophy-and-reality-a-clean-schematic-visualizatio-1764556538061.jpg&quot; alt=&quot;A clean, schematic visualization of the Source -&amp;gt; Processing -&amp;gt; Destination node concept described in the text.&quot; /&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Source nodes&lt;/strong&gt; (oscillators, audio buffers, media elements)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Processing nodes&lt;/strong&gt; (filters, compressors, reverb, analyzers)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Destination nodes&lt;/strong&gt; (speakers, recording outputs)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;According to the specification itself, the API has lofty ambitions: &quot;It is a goal of this specification to include the capabilities found in modern game audio engines as well as some of the mixing, processing, and filtering tasks that are found in modern desktop audio production applications.&quot;&lt;/p&gt;
&lt;p&gt;That breadth became both the API&apos;s main selling point and its central problem.&lt;/p&gt;
&lt;h2&gt;The Design Philosophy: Everything and the Kitchen Sink&lt;/h2&gt;
&lt;p&gt;The Web Audio API emerged from work by Chris Rogers at Google, based heavily on Apple&apos;s Core Audio framework. The design philosophy was clear: provide a comprehensive set of built-in audio processing nodes that would cover most common use cases without requiring developers to write low-level audio processing code.&lt;/p&gt;
&lt;p&gt;The API includes nodes for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DynamicsCompressorNode&lt;/strong&gt; - Audio compression&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ConvolverNode&lt;/strong&gt; - Reverb and spatial effects&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BiquadFilterNode&lt;/strong&gt; - Various filter types&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WaveShaperNode&lt;/strong&gt; - Distortion effects&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PannerNode&lt;/strong&gt; - 3D spatial audio&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AnalyserNode&lt;/strong&gt; - Frequency analysis for visualizations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reasoning seemed sound: JavaScript was too slow for real-time audio processing, and garbage collection would cause audio glitches. By providing these effects as native browser implementations, developers could build sophisticated audio applications without worrying about performance.&lt;/p&gt;
&lt;p&gt;But this approach raised an immediate question: &lt;strong&gt;Who is this API actually designed for?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The Identity Crisis: Who Needs This?&lt;/h2&gt;
&lt;p&gt;Jasper St. Pierre, in his incisive 2017 blog post &lt;a href=&quot;https://blog.mecheye.net/2017/09/i-dont-know-who-the-web-audio-api-is-designed-for/&quot;&gt;&quot;I don&apos;t know who the Web Audio API is designed for,&quot;&lt;/a&gt; articulated a fundamental problem: the API seems to fall between multiple stools.&lt;/p&gt;
&lt;h3&gt;Not for Game Developers&lt;/h3&gt;
&lt;p&gt;Game developers typically use established audio middleware like FMOD or Wwise. These systems provide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Precisely specified behavior across platforms&lt;/li&gt;
&lt;li&gt;Extensive plugin ecosystems&lt;/li&gt;
&lt;li&gt;Professional tooling and workflows&lt;/li&gt;
&lt;li&gt;Deterministic, well-documented effects&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Web Audio API&apos;s built-in nodes, by contrast, are often underspecified. As St. Pierre notes: &quot;Something like the DynamicsCompressorNode is practically a joke: basic features from a real compressor are basically missing, and the behavior that is there is underspecified such that I can&apos;t even trust it to sound correct between browsers.&quot;&lt;/p&gt;
&lt;p&gt;With the advent of WebAssembly, game developers can now compile their existing FMOD or Wwise code to run in the browser. Why would they abandon their proven tools for an underspecified browser API?&lt;/p&gt;
&lt;h3&gt;Not for Audio Professionals&lt;/h3&gt;
&lt;p&gt;Professional audio applications require:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Precise control over every parameter&lt;/li&gt;
&lt;li&gt;Extensive effect libraries and third-party plugins&lt;/li&gt;
&lt;li&gt;Sample-accurate timing&lt;/li&gt;
&lt;li&gt;Deterministic behavior for reproducible results&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Web Audio API&apos;s canned effects don&apos;t come close to meeting these needs. A professional wouldn&apos;t use a browser&apos;s built-in compressor when they could use industry-standard plugins with decades of refinement.&lt;/p&gt;
&lt;h3&gt;Not for Simple Use Cases Either&lt;/h3&gt;
&lt;p&gt;Perhaps most frustratingly, the API also fails developers with simple needs: those who just want to generate and play audio samples programmatically.&lt;/p&gt;
&lt;p&gt;St. Pierre provides a telling example. Here&apos;s what a simple, hypothetical audio API might look like for playing a 440Hz sine wave:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const frequency = 440;
const stream = window.audio.newStream(1, 44100);
stream.onfillsamples = function(samples) {
    const startTime = stream.currentTime;
    for (var i = 0; i &amp;lt; samples.length; i++) {
        const t = startTime + (i / stream.sampleRate);
        samples[i] = Math.sin(t * frequency) * 0x7FFF;
    }
};
stream.play();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clean, simple, understandable. But the Web Audio API makes this surprisingly difficult.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/web-audio-api-design-philosophy-and-reality-an-abstract-representation-of--1764556563265.jpg&quot; alt=&quot;An abstract representation of the performance issues and garbage collection glitches discussed in the &apos;Performance Paradox&apos; section.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Performance Paradox&lt;/h2&gt;
&lt;p&gt;The Web Audio API&apos;s approach to avoiding JavaScript performance problems created new performance problems of its own.&lt;/p&gt;
&lt;h3&gt;The ScriptProcessorNode Debacle&lt;/h3&gt;
&lt;p&gt;The original mechanism for custom audio processing was &lt;code&gt;ScriptProcessorNode&lt;/code&gt;. It had several critical flaws:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No resampling support&lt;/strong&gt; - The sample rate is global to the AudioContext and can&apos;t be changed. If your hardware uses 48kHz but you want to generate 44.1kHz audio, you&apos;re out of luck.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Main thread execution&lt;/strong&gt; - Audio processing runs on the main thread, making glitches inevitable when the page is busy.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Deprecated before alternatives existed&lt;/strong&gt; - ScriptProcessorNode was deprecated in 2014 in favor of &quot;Audio Workers,&quot; which were never implemented. They were then replaced by AudioWorklets, which took years to ship.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;The BufferSourceNode Garbage Problem&lt;/h3&gt;
&lt;p&gt;The alternative approach using &lt;code&gt;AudioBufferSourceNode&lt;/code&gt; has its own issues. To play continuous audio, you must:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a new AudioBuffer for each chunk&lt;/li&gt;
&lt;li&gt;Create a new AudioBufferSourceNode for each chunk&lt;/li&gt;
&lt;li&gt;Schedule it to play at the right time&lt;/li&gt;
&lt;li&gt;Hope the garbage collector doesn&apos;t cause glitches&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;As St. Pierre discovered: &quot;Every 85 milliseconds we are allocating two new GC&apos;d objects.&quot; The documentation helpfully states that BufferSourceNodes are &quot;cheap to create&quot; and &quot;will automatically be garbage-collected at an appropriate time.&quot;&lt;/p&gt;
&lt;p&gt;But as St. Pierre pointedly notes: &quot;I know I&apos;m fighting an uphill battle here, but a GC is not what we need during realtime audio playback.&quot;&lt;/p&gt;
&lt;h3&gt;Floating Point Everything&lt;/h3&gt;
&lt;p&gt;Another performance issue: the API forces everything into Float32Arrays. While this provides precision, it&apos;s slower than integer arithmetic for many operations. As St. Pierre observes: &quot;16 bits is enough for everybody and for an output format it&apos;s more than enough. Integer Arithmetic Units are very fast workers and there&apos;s no huge reason to shun them out of the equation.&quot;&lt;/p&gt;
&lt;h2&gt;The Road Not Taken: Mozilla&apos;s Audio Data API&lt;/h2&gt;
&lt;p&gt;Robert O&apos;Callahan, a Mozilla engineer who was deeply involved in the Web Audio standardization process, provides crucial historical context in his 2017 post &lt;a href=&quot;https://robert.ocallahan.org/2017/09/some-opinions-on-history-of-web-audio.html&quot;&gt;&quot;Some Opinions On The History Of Web Audio.&quot;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Mozilla had proposed an alternative: the &lt;strong&gt;Audio Data API&lt;/strong&gt;. It was much simpler:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;setup()&lt;/code&gt; - Configure the audio stream&lt;/li&gt;
&lt;li&gt;&lt;code&gt;currentSampleOffset()&lt;/code&gt; - Get current playback position&lt;/li&gt;
&lt;li&gt;&lt;code&gt;writeAudio()&lt;/code&gt; - Write audio samples&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This push-based API was straightforward, supported runtime resampling, and didn&apos;t require breaking audio into garbage-collected buffers. It focused on the fundamental primitive: giving developers a way to generate and play audio samples.&lt;/p&gt;
&lt;h3&gt;Why Did Web Audio Win?&lt;/h3&gt;
&lt;p&gt;O&apos;Callahan identifies several factors:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Performance concerns&lt;/strong&gt; - The working group believed JavaScript was too slow for audio processing and GC would cause glitches. (Ironically, the Web Audio API&apos;s own design introduced GC issues.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Audio professional authority&lt;/strong&gt; - &quot;Audio professionals like Chris Rogers assured me they had identified a set of primitives that would suffice for most use cases. Since most of the Audio WG were audio professionals and I wasn&apos;t, I didn&apos;t have much defense against &apos;audio professionals say...&apos; arguments.&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Lack of engagement&lt;/strong&gt; - Apple&apos;s participation declined after the initial proposal. Microsoft never engaged meaningfully. Mozilla was largely alone in pushing for changes.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Shipping before standardization&lt;/strong&gt; - Google and Apple shipped Web Audio with a webkit prefix and evangelized it to developers. Once developers started using it, Mozilla had to implement it for compatibility.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;O&apos;Callahan reflects: &quot;What could I have done better? I probably should have reduced the scope of my spec proposal... But I don&apos;t think that, or anything else I can think of, would have changed the outcome.&quot;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/web-audio-api-design-philosophy-and-reality-visualizes-the-friction-and-in-1764556592975.jpg&quot; alt=&quot;Visualizes the friction and integration challenges between the legacy Web Audio API and modern WebAssembly.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Modern Challenges: WebAssembly Integration&lt;/h2&gt;
&lt;p&gt;Fast forward to 2025, and WebAssembly has transformed what&apos;s possible in browsers. Developers can now compile C++ audio processing code to run at near-native speeds. This should be the perfect complement to Web Audio, right?&lt;/p&gt;
&lt;p&gt;Daniel Barta&apos;s recent article &lt;a href=&quot;https://danielbarta.com/web-audio-web-assembly/&quot;&gt;&quot;Web Audio + WebAssembly: Lessons Learned&quot;&lt;/a&gt; reveals that integration remains problematic.&lt;/p&gt;
&lt;h3&gt;The Worker Problem&lt;/h3&gt;
&lt;p&gt;AudioContext cannot be used in Web Workers. This is a fundamental limitation that has been marked as an &quot;urgent priority&quot; for over &lt;strong&gt;eight years&lt;/strong&gt; without resolution.&lt;/p&gt;
&lt;p&gt;Since WebAssembly instances typically run in workers for performance reasons, this creates an architectural problem. You can&apos;t have your WebAssembly audio processing code directly interact with the AudioContext.&lt;/p&gt;
&lt;h3&gt;No Shared Memory&lt;/h3&gt;
&lt;p&gt;The Web Audio API doesn&apos;t support SharedArrayBuffer for data exchange. This has also been a documented, high-priority issue for over &lt;strong&gt;seven years&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Without shared memory, you must copy audio data between threads, introducing exactly the kind of inefficiency the API was supposed to avoid.&lt;/p&gt;
&lt;h3&gt;Incomplete Tooling&lt;/h3&gt;
&lt;p&gt;Emscripten provides helper methods for Web Audio, but as Barta discovered, &quot;their implementation is incomplete.&quot; The available methods were designed as basic helpers for testing, not production use.&lt;/p&gt;
&lt;p&gt;Barta concludes: &quot;A seamless experience seems within reach, and I am optimistic it will soon be realized. With these APIs and Chromium open for contributions, anyone—myself included—can actively participate in addressing these challenges.&quot;&lt;/p&gt;
&lt;p&gt;That optimism is admirable, but the fact that critical issues have remained unresolved for 7-8 years suggests systemic problems beyond just needing more contributors.&lt;/p&gt;
&lt;h2&gt;What Went Wrong? Lessons in API Design&lt;/h2&gt;
&lt;p&gt;The Web Audio API&apos;s struggles illuminate several important principles:&lt;/p&gt;
&lt;h3&gt;1. Beware the &quot;Everything API&quot;&lt;/h3&gt;
&lt;p&gt;The API tried to be everything: a simple playback system, a game audio engine, and a professional audio workstation. This led to a bloated specification that serves none of these use cases particularly well.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Focus on core primitives first. Let higher-level abstractions emerge from the community.&lt;/p&gt;
&lt;h3&gt;2. Don&apos;t Assume You Know What Users Need&lt;/h3&gt;
&lt;p&gt;The working group assumed developers needed canned audio effects more than they needed simple, efficient sample playback. This assumption proved wrong.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Talk to actual developers building real applications, not just audio professionals who understand the domain.&lt;/p&gt;
&lt;h3&gt;3. Shipping Beats Standardization&lt;/h3&gt;
&lt;p&gt;Google and Apple shipped Web Audio before the spec was finalized, forcing other browsers to implement it for compatibility. This locked in design decisions before they could be properly evaluated.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: The &quot;ship it and see&quot; approach can be valuable, but it can also entrench poor designs.&lt;/p&gt;
&lt;h3&gt;4. The Extensible Web Principle Came Too Late&lt;/h3&gt;
&lt;p&gt;Shortly after Web Audio was standardized, the &quot;Extensible Web&quot; philosophy became popular: provide low-level primitives and let developers build higher-level abstractions.&lt;/p&gt;
&lt;p&gt;Web Audio is the antithesis of this approach. It provides high-level abstractions (DynamicsCompressorNode) without solid low-level primitives (efficient sample generation).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Low-level primitives should come first. They&apos;re harder to add later.&lt;/p&gt;
&lt;h3&gt;5. Authority Isn&apos;t Always Right&lt;/h3&gt;
&lt;p&gt;The working group deferred to &quot;audio professionals&quot; who assured them the API would meet developer needs. Those professionals were wrong about what web developers actually needed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Domain expertise is valuable, but it&apos;s not a substitute for user research and iterative design.&lt;/p&gt;
&lt;h2&gt;Current State: Adoption and Usage&lt;/h2&gt;
&lt;p&gt;Despite its flaws, the Web Audio API has achieved significant adoption:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Universal browser support&lt;/strong&gt; - All major browsers now implement the API&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Impressive demonstrations&lt;/strong&gt; - Developers have built synthesizers, DAWs, games, and visualizations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Active ecosystem&lt;/strong&gt; - Libraries like Tone.js provide higher-level abstractions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, usage patterns suggest most applications use a small subset of the API:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Simple playback with AudioBufferSourceNode&lt;/li&gt;
&lt;li&gt;Basic visualization with AnalyserNode&lt;/li&gt;
&lt;li&gt;Occasional use of GainNode for volume control&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The sophisticated graph routing and built-in effects that drove the API&apos;s design are used far less frequently. Most complex audio processing happens in WebAssembly, not through Web Audio nodes.&lt;/p&gt;
&lt;h2&gt;The Path Forward&lt;/h2&gt;
&lt;p&gt;What would it take to fix the Web Audio API? Several improvements are needed:&lt;/p&gt;
&lt;h3&gt;Short Term&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Implement AudioWorklet everywhere&lt;/strong&gt; - This provides efficient, worker-based audio processing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add SharedArrayBuffer support&lt;/strong&gt; - Enable zero-copy data sharing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Support AudioContext in workers&lt;/strong&gt; - Remove the artificial limitation&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Long Term&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Provide a simple sample playback API&lt;/strong&gt; - Something like Mozilla&apos;s original Audio Data API&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Better specify existing nodes&lt;/strong&gt; - Make behavior consistent across browsers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embrace WebAssembly&lt;/strong&gt; - Design for integration with compiled audio code&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;The Realistic Outlook&lt;/h3&gt;
&lt;p&gt;The fact that critical issues have remained &quot;urgent priorities&quot; for 7-8 years suggests these fixes may never arrive. The Web Audio API may be locked into its current design indefinitely.&lt;/p&gt;
&lt;p&gt;For developers, this means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use WebAssembly for complex processing&lt;/strong&gt; - Don&apos;t rely on built-in nodes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep it simple&lt;/strong&gt; - Use the minimal subset of the API you need&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expect inconsistencies&lt;/strong&gt; - Test across browsers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consider alternatives&lt;/strong&gt; - For some use cases, the &lt;code&gt;&amp;lt;audio&amp;gt;&lt;/code&gt; element may be sufficient&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Conclusion: A Cautionary Tale&lt;/h2&gt;
&lt;p&gt;The Web Audio API is a cautionary tale about the challenges of designing web standards. It shows what happens when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ambitious goals override practical needs&lt;/li&gt;
&lt;li&gt;Authority substitutes for user research&lt;/li&gt;
&lt;li&gt;Shipping precedes standardization&lt;/li&gt;
&lt;li&gt;High-level abstractions come before low-level primitives&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It also shows how resilient the web platform is. Despite its flaws, developers have built remarkable things with the Web Audio API. Libraries have emerged to paper over its rough edges, and WebAssembly provides an escape hatch for performance-critical code.&lt;/p&gt;
&lt;p&gt;The API&apos;s greatest legacy may not be the features it provides, but the lessons it teaches about web standards development. Future API designers would do well to study both its ambitions and its failures.&lt;/p&gt;
&lt;p&gt;As Jasper St. Pierre concluded his critique: &quot;Can the ridiculous overeagerness of Web Audio be reversed? Can we bring back a simple &apos;play audio&apos; API and bring back the performance gains once we see what happens in the wild? I don&apos;t know... But I would really, really like to see it happen.&quot;&lt;/p&gt;
&lt;p&gt;Seven years later, we&apos;re still waiting.&lt;/p&gt;
&lt;h2&gt;Further Reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.mecheye.net/2017/09/i-dont-know-who-the-web-audio-api-is-designed-for/&quot;&gt;I don&apos;t know who the Web Audio API is designed for&lt;/a&gt; - Jasper St. Pierre&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://robert.ocallahan.org/2017/09/some-opinions-on-history-of-web-audio.html&quot;&gt;Some Opinions On The History Of Web Audio&lt;/a&gt; - Robert O&apos;Callahan&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://danielbarta.com/web-audio-web-assembly/&quot;&gt;Web Audio + WebAssembly: Lessons Learned&lt;/a&gt; - Daniel Barta&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.w3.org/TR/webaudio/&quot;&gt;Web Audio API Specification&lt;/a&gt; - W3C&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API&quot;&gt;MDN Web Audio API Documentation&lt;/a&gt; - Mozilla&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;What are your experiences with the Web Audio API? Have you encountered the issues discussed here, or found creative workarounds? Share your thoughts in the comments below.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>web-audio-api</category><category>web-standards</category><category>javascript</category><category>webassembly</category><category>api-design</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/web-audio-api-design-philosophy-and-reality-a-visual-metaphor-for-the-caut-featured-1764556521977.jpg" length="0" type="image/jpeg"/></item><item><title>Building an Interactive Circle of Fifths: Music Theory Meets Web Audio</title><link>https://rye.dev/blog/circle-of-fifths-music-theory/</link><guid isPermaLink="true">https://rye.dev/blog/circle-of-fifths-music-theory/</guid><description>I built an interactive Circle of Fifths that plays what it shows. Web Audio synthesis turns a centuries-old theory diagram into something you can hear.</description><pubDate>Mon, 13 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/circle-of-fifths-music-theory-a-stylized-digital-art-interpr-featured-1764557665901.jpg&quot; alt=&quot;Building an Interactive Circle of Fifths: Music Theory Meets Web Audio&quot; /&gt;&lt;/p&gt;&lt;p&gt;The Circle of Fifths packs a surprising amount of music theory into a simple circular diagram, encoding the harmonic relationships between all twelve keys. I wanted a version you could actually hear, so I built an interactive one that plays audio when you click. That turns a centuries-old teaching tool into something you learn by ear as much as by eye.&lt;/p&gt;
&lt;h2&gt;The Circle of Fifths: Encoding Harmonic Space&lt;/h2&gt;
&lt;p&gt;For those unfamiliar with music theory, the Circle of Fifths arranges all twelve musical keys in a circular pattern where each adjacent key differs by a perfect fifth interval. Moving clockwise adds sharps; moving counter-clockwise adds flats. This arrangement reveals fundamental relationships that govern Western harmony.&lt;/p&gt;
&lt;p&gt;The circle shows several things at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Key Signatures&lt;/strong&gt;: The number of sharps or flats in each key&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relative Majors and Minors&lt;/strong&gt;: Major and minor keys that share the same key signature&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chord Progressions&lt;/strong&gt;: Common harmonic movements map to geometric patterns on the circle&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modulation Paths&lt;/strong&gt;: Adjacent keys provide the smoothest key changes&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Web Audio API: Bringing Theory to Life&lt;/h2&gt;
&lt;p&gt;The Web Audio API generates musical tones directly in the browser. Unlike pre-recorded samples, synthesized audio can respond dynamically to what the user does:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const audioContext = new AudioContext();

function playNote(frequency, duration = 0.5) {
  const oscillator = audioContext.createOscillator();
  const gainNode = audioContext.createGain();
  
  oscillator.connect(gainNode);
  gainNode.connect(audioContext.destination);
  
  oscillator.frequency.value = frequency;
  oscillator.type = &apos;sine&apos;;
  
  gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
  gainNode.gain.exponentialRampToValueAtTime(
    0.01, audioContext.currentTime + duration
  );
  
  oscillator.start(audioContext.currentTime);
  oscillator.stop(audioContext.currentTime + duration);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now clicking a key gives immediate audio feedback, so you hear the tonic, dominant, and subdominant relationships that define tonal harmony.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/circle-of-fifths-music-theory-a-visualization-of-the-audio-s-1764557686459.jpg&quot; alt=&quot;A visualization of the audio signal path (Oscillator -&amp;gt; Gain -&amp;gt; Destination) discussed in the Web Audio API section.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Frequency Calculations and Equal Temperament&lt;/h2&gt;
&lt;p&gt;Converting musical notes to frequencies requires understanding equal temperament tuning, where each semitone represents a frequency ratio of the twelfth root of two:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const A4_FREQUENCY = 440; // Hz
const SEMITONE_RATIO = Math.pow(2, 1/12);

function noteToFrequency(note, octave) {
  const noteIndex = [&apos;C&apos;, &apos;C#&apos;, &apos;D&apos;, &apos;D#&apos;, &apos;E&apos;, &apos;F&apos;, 
                     &apos;F#&apos;, &apos;G&apos;, &apos;G#&apos;, &apos;A&apos;, &apos;A#&apos;, &apos;B&apos;].indexOf(note);
  const semitonesFromA4 = (octave - 4) * 12 + (noteIndex - 9);
  return A4_FREQUENCY * Math.pow(SEMITONE_RATIO, semitonesFromA4);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That math keeps the pitches accurate across the whole circle, from C major through all twelve keys.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/circle-of-fifths-music-theory-a-diagram-illustrating-the-geo-1764557700841.jpg&quot; alt=&quot;A diagram illustrating the geometric calculations required to position keys on the circle, bridging the math section and the design section.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Interactive Visualization Design&lt;/h2&gt;
&lt;p&gt;The circular layout requires careful geometric calculations to position elements correctly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function getKeyPosition(index, radius) {
  const angle = (index * 30 - 90) * (Math.PI / 180);
  return {
    x: radius * Math.cos(angle),
    y: radius * Math.sin(angle)
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each key occupies 30 degrees of the circle (360/12), with the -90 degree offset placing C major at the top. The inner ring displays relative minors, maintaining the same angular relationship while using a smaller radius.&lt;/p&gt;
&lt;h2&gt;Educational Features&lt;/h2&gt;
&lt;p&gt;The app also does some teaching:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scale Display&lt;/strong&gt;: Clicking a key shows all notes in that major or minor scale&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chord Highlighting&lt;/strong&gt;: Visualize which chords naturally occur in each key&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Progression Patterns&lt;/strong&gt;: Highlight common chord progressions like I-IV-V-I&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audio Playback&lt;/strong&gt;: Hear scales and chords to connect visual patterns with sound&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Responsive Design Considerations&lt;/h2&gt;
&lt;p&gt;Musical apps have their own responsive-design problems. Touch targets have to work for both precise mouse clicks and finger taps, and the circular layout has to stay legible across screen sizes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.circle-key {
  min-width: 44px;
  min-height: 44px;
  cursor: pointer;
  transition: transform 0.2s ease;
}

.circle-key:hover,
.circle-key:focus {
  transform: scale(1.1);
}

@media (max-width: 600px) {
  .circle-container {
    transform: scale(0.8);
    transform-origin: center top;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Performance Optimization&lt;/h2&gt;
&lt;p&gt;Audio applications require careful performance management to prevent clicks and latency:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Pre-warm the audio context on first user interaction
document.addEventListener(&apos;click&apos;, () =&amp;gt; {
  if (audioContext.state === &apos;suspended&apos;) {
    audioContext.resume();
  }
}, { once: true });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Modern browsers suspend the audio context to block autoplay, so this pattern wakes it up on the first click and keeps audio responsive after that.&lt;/p&gt;
&lt;h2&gt;Extending the Foundation&lt;/h2&gt;
&lt;p&gt;The same patterns carry over to other music education tools:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Interval Training&lt;/strong&gt;: Recognizing the sound of fifths, fourths, and other intervals&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chord Quality Recognition&lt;/strong&gt;: Distinguishing major, minor, diminished, and augmented chords&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sight-Reading Assistance&lt;/strong&gt;: Connecting key signatures to scale patterns&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Seeing the theory and hearing it at the same time sticks better than either one on its own.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Try the interactive Circle of Fifths at &lt;a href=&quot;https://cameronrye.github.io/circle-of-fifths/&quot;&gt;cameronrye.github.io/circle-of-fifths&lt;/a&gt; or explore the source code on &lt;a href=&quot;https://github.com/cameronrye/circle-of-fifths&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>javascript</category><category>web-audio-api</category><category>music</category><category>visualization</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/circle-of-fifths-music-theory-a-stylized-digital-art-interpr-featured-1764557665901.jpg" length="0" type="image/jpeg"/></item><item><title>Second Reality: 32 Years of Demoscene Excellence</title><link>https://rye.dev/blog/second-reality-32nd-anniversary/</link><guid isPermaLink="true">https://rye.dev/blog/second-reality-32nd-anniversary/</guid><description>Future Crew&apos;s Second Reality turns 32. I downloaded it on a BBS as a kid; now it runs instantly in your browser via DosKit.</description><pubDate>Tue, 07 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/second-reality-32nd-anniversary-a-vintage-monitor-projecting-r-featured-1764556642273.jpg&quot; alt=&quot;Second Reality: 32 Years of Demoscene Excellence&quot; /&gt;&lt;/p&gt;&lt;p&gt;Thirty-two years ago today, on October 7, 1993, Future Crew released Second Reality at Assembly &apos;93 in Helsinki, Finland. It won first place in the PC demo competition and changed what people thought was possible on IBM-compatible hardware. For a generation of developers, including me, it was the moment computing stopped being a tool and became an art form.&lt;/p&gt;
&lt;p&gt;I still remember the anticipation. The modem&apos;s carrier tone. The glacial progress bar as the file downloaded from a local BBS at 14.4 kbps. The nervous excitement of typing &lt;code&gt;SECOND.EXE&lt;/code&gt; and hoping my 486 DX2/66 was fast enough. Then the screen exploded with impossible graphics, pulsing to a soundtrack that shouldn&apos;t have been possible on PC hardware.&lt;/p&gt;
&lt;p&gt;That moment changed everything for me. Today you can see it for yourself, instantly, in the browser, with nothing to download: &lt;a href=&quot;https://doskit.net/?app=secondreality&quot;&gt;doskit.net/?app=secondreality&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;The BBS Era: When Demos Were Treasures&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE] Historical Context
In 1993, the World Wide Web was barely a year old and virtually unknown. The internet as we know it didn&apos;t exist for most people. Instead we had BBSes (Bulletin Board Systems): single-computer servers you dialed into with a modem, one user at a time.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The demoscene of the early 1990s lived in a completely different world. There was no YouTube to watch demos. No GitHub to download source code. No Discord servers to discuss techniques. There were BBSes, hundreds of them, each a small island of digital culture reachable only through a direct modem connection.&lt;/p&gt;
&lt;p&gt;Finding Second Reality meant:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: Hearing about it through word-of-mouth, reading about it in a text file, or seeing it mentioned in another demo&apos;s credits&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Location&lt;/strong&gt;: Finding a BBS that had it (not guaranteed; many boards had limited storage)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Access&lt;/strong&gt;: Hoping the BBS wasn&apos;t busy (most had 1-4 phone lines maximum)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Download&lt;/strong&gt;: Waiting hours for the 2.4MB file to transfer, praying the connection didn&apos;t drop&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verification&lt;/strong&gt;: Checking the file wasn&apos;t corrupted during transfer&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Experience&lt;/strong&gt;: Finally running it on your hardware, hoping it was compatible&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This friction made demos precious. You didn&apos;t casually click a link and watch. You invested time, effort, and often money (long-distance phone charges were real). When you finally got Second Reality running, you&apos;d watched that progress bar for hours. You&apos;d earned it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/second-reality-32nd-anniversary-a-stylized-network-map-visuali-1764556661196.jpg&quot; alt=&quot;A stylized network map visualizing the isolated nature of Bulletin Board Systems and the difficulty of finding files.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Technical Achievements That Defined an Era&lt;/h2&gt;
&lt;p&gt;What made Second Reality legendary wasn&apos;t how it looked. It did things people thought were impossible on PC hardware.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/second-reality-32nd-anniversary-an-abstract-architectural-diag-1764556682312.jpg&quot; alt=&quot;An abstract architectural diagram representing the &apos;Loader&apos; and the 32 independent parts of the demo.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;The Architecture: Elegant Modularity&lt;/h3&gt;
&lt;p&gt;When the source code was released in 2013 for the demo&apos;s 20th anniversary, the demoscene expected a monolithic mess of assembly code. Instead they found a modular architecture with real software engineering behind it.&lt;/p&gt;
&lt;p&gt;The structure was unusual for the time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Loader&lt;/strong&gt;: A minimal 20KB engine that handled initialization and part sequencing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The DIS (Demo Interrupt Server)&lt;/strong&gt;: A custom interrupt handler providing services to all parts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;32 Independent Executables&lt;/strong&gt;: Each visual effect was a self-contained DOS program&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This let development run in parallel. Multiple team members could work on different parts at once without conflicts. Each part had a 450KB memory budget and full autonomy within it. When a part finished, the loader overwrote it with the next one. Simple memory management that worked.&lt;/p&gt;
&lt;p&gt;The codebase metrics tell the story:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Language                files       code
Assembly                   99     33,350
C++                       121     24,551
C/C++ Header                8        654
Make                       17        294
DOS Batch                  71        253
Total:                    316     59,102
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This wasn&apos;t assembly hackers pushing hardware limits. It was a team picking the right tool for each job: assembly for performance-critical rendering, C++ for logic and coordination, makefiles for build automation. The codebase was nearly twice the size of the original Doom engine, and it stayed maintainable because the architecture was disciplined.&lt;/p&gt;
&lt;h3&gt;The Copper Simulator: Amiga Envy Solved&lt;/h3&gt;
&lt;p&gt;One of Second Reality&apos;s best tricks was simulating the Amiga&apos;s Copper coprocessor on PC hardware. The Copper was a favorite feature of Amiga computers: a specialized processor that could run programmed instruction streams in sync with the video hardware, which made effects possible that were difficult or impossible on PCs.&lt;/p&gt;
&lt;p&gt;Future Crew didn&apos;t accept this limitation. They built a Copper simulator using the PC&apos;s 8254 Programmable Interval Timer (PIT) and 8259 Programmable Interrupt Controller (PIC). By carefully programming timer interrupts synchronized with VGA vertical retrace, they achieved similar capabilities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Triggering custom routines at specific scanline positions&lt;/li&gt;
&lt;li&gt;Changing palettes mid-frame for color cycling effects&lt;/li&gt;
&lt;li&gt;Synchronizing visual effects with music timing&lt;/li&gt;
&lt;li&gt;Enabling effects previously thought to require dedicated hardware&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That took real systems-level engineering: a deep understanding of PC hardware architecture, interrupt handling, and precise timing control.&lt;/p&gt;
&lt;h3&gt;Development vs. Production: Seamless Workflow&lt;/h3&gt;
&lt;p&gt;The attention to developer experience was well ahead of its time. The team built infrastructure that made moving from development to production painless:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Development Mode:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DIS loaded as a TSR (Terminate and Stay Resident) program&lt;/li&gt;
&lt;li&gt;Each part ran as an independent executable&lt;/li&gt;
&lt;li&gt;Individual testing without running the full demo&lt;/li&gt;
&lt;li&gt;Standard DOS file I/O for loading assets&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Production Mode:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DIS embedded in the main executable&lt;/li&gt;
&lt;li&gt;All parts encrypted and appended to SECOND.EXE&lt;/li&gt;
&lt;li&gt;Custom DOS interrupt handlers for file operations&lt;/li&gt;
&lt;li&gt;Single 1.45MB executable containing everything&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The clever part was that a part didn&apos;t need to know which mode it was running in. The same code worked in both. It&apos;s the kind of abstraction modern developers take for granted in tools like Docker, and Future Crew built it in 1993 with assembly and C++.&lt;/p&gt;
&lt;h2&gt;Cultural Impact: From Demoscene to Game Industry&lt;/h2&gt;
&lt;p&gt;Second Reality&apos;s influence reached well beyond the demoscene. Several Future Crew members went on to found Remedy Entertainment, the studio behind Max Payne, Alan Wake, and Control. The same skill and vision that went into Second Reality became the foundation of a major game studio.&lt;/p&gt;
&lt;p&gt;This pattern repeated across the industry. The demoscene became a training ground for game developers, graphics programmers, and technical artists. The skills demos demanded (extreme optimization, creative problem-solving under constraints, real-time graphics programming) carried straight over to game development.&lt;/p&gt;
&lt;p&gt;The demoscene taught a generation of developers that constraints breed creativity. When you have 450KB for an entire visual effect including code and assets, you learn to be resourceful. When you&apos;re targeting a 486 CPU without hardware acceleration, you learn to optimize. When you&apos;re competing at Assembly, you learn to push boundaries.&lt;/p&gt;
&lt;h2&gt;The Spirit of Second Reality in Modern Development&lt;/h2&gt;
&lt;p&gt;What strikes me most about Second Reality, three decades later, is how much of it still applies:&lt;/p&gt;
&lt;p&gt;The part-based system anticipated modern microservices and component-based design: each part was independently testable, deployable, and replaceable. The dev/prod workflow anticipated a lot of what we now call developer experience, because the team understood that good tools let people do good work. The optimization those real-time effects demanded on limited hardware taught lessons that still apply to web performance, mobile development, and embedded systems.&lt;/p&gt;
&lt;p&gt;The architecture also let people work in parallel with no source control at all, which teams with Git and CI/CD pipelines can still learn from. And none of it was only technical. The mix of music, visuals, and pacing added up to something you felt, not just something you admired.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/second-reality-32nd-anniversary-a-modern-laptop-running-the-vi-1764556699823.jpg&quot; alt=&quot;A modern laptop running the vintage demo in a browser window, illustrating the ease of access via DosKit.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;From BBS to Browser: DosKit and Instant Access&lt;/h2&gt;
&lt;p&gt;The contrast between 1993 and 2025 is striking. What once required hours of downloading and specific hardware now runs instantly in any modern browser. That gap is what led me to build &lt;a href=&quot;https://doskit.net&quot;&gt;DosKit&lt;/a&gt;, a tool for making DOS software accessible straight from the browser.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!TIP] Experience Second Reality Now
DosKit gives you instant browser-based access to Second Reality and other classic DOS software. No installation, no configuration. Just click, and you&apos;re looking at computing history.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;DosKit uses WebAssembly to run a complete DOS environment in your browser. No installation. No configuration. No downloads. Just click:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Try Second Reality now: &lt;a href=&quot;https://doskit.net/?app=secondreality&quot;&gt;doskit.net/?app=secondreality&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For complete technical details about DosKit&apos;s architecture and implementation, see the &lt;a href=&quot;https://rye.dev/projects/doskit/&quot;&gt;DosKit&lt;/a&gt; project page.&lt;/p&gt;
&lt;p&gt;The technical implementation combines several modern web technologies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;js-dos&lt;/strong&gt;: A DOS emulator compiled to WebAssembly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;URL-based configuration&lt;/strong&gt;: Apps load via query parameters&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instant initialization&lt;/strong&gt;: Pre-configured DOS environment&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-platform compatibility&lt;/strong&gt;: Works on desktop, mobile, tablets&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What took hours in 1993 now takes seconds. The BBS download that required dedication and patience is a single click today. The demo itself is unchanged: the same code, the same effects, the same music that amazed people 32 years ago.&lt;/p&gt;
&lt;p&gt;This accessibility matters. Second Reality still works as a lesson in software engineering, graphics programming, and creative problem-solving. Making it easy to reach means new generations can run it, learn from it, and take something from it.&lt;/p&gt;
&lt;h2&gt;Lessons for Modern Developers&lt;/h2&gt;
&lt;p&gt;A few of those lessons hold up well past its era:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Constraints Drive Innovation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The 486 CPU, VGA graphics, and 450KB memory limit forced creative solutions. We usually have nearly unlimited resources now, but self-imposed constraints can still lead to better design. Try building a feature in half the memory budget. Optimize for slower devices. Limits like that expose waste and push you toward simpler solutions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Architecture Enables Collaboration&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The modular part system let developers work independently. Modern microservices and component architectures serve the same purpose. Good architecture isn&apos;t about following patterns. It&apos;s about letting your team work effectively.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Developer Experience Compounds&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The smooth dev/prod workflow saved the team a lot of time. Time you put into tooling, build systems, and developer experience keeps paying off over a project&apos;s life. The best teams treat developer experience as a first-class concern.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Performance Is a Feature&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Second Reality&apos;s optimization wasn&apos;t optional. It was the whole point. Modern web applications often neglect performance and assume fast networks and fast devices. But performance is user experience. Every millisecond matters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Technical Excellence Serves Artistic Vision&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Second Reality wasn&apos;t only impressive to engineers. It was beautiful, and it stuck with you. The technical work served the art. Modern software should aim for the same union of solid engineering and user experience.&lt;/p&gt;
&lt;h2&gt;The Enduring Legacy&lt;/h2&gt;
&lt;p&gt;Thirty-two years later, Second Reality still holds up. Not because the graphics impress (though they&apos;re charming), but because the engineering, the problem-solving, and the artistic vision are still worth studying.&lt;/p&gt;
&lt;p&gt;The demo represents a moment when a small team in Finland showed the world what was possible with dedication, skill, and creativity. They didn&apos;t have the best tools, the fastest hardware, or unlimited resources. They had constraints, talent, and vision.&lt;/p&gt;
&lt;p&gt;That combination produced something that outlasted the hardware it ran on, the BBS networks that distributed it, and the era that created it. Second Reality endures because the work was good: technical, artistic, and collaborative.&lt;/p&gt;
&lt;p&gt;And now, with tools like DosKit, all of that is more accessible than ever. The hours-long BBS download is a single click. The specific hardware requirements are gone; it just runs in a browser. The treasure you once had to work for is free to anyone curious enough to click a link.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Experience Second Reality today: &lt;a href=&quot;https://doskit.net/?app=secondreality&quot;&gt;doskit.net/?app=secondreality&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The demo that changed my life as a kid is now just a URL away. That would have looked like science fiction in 1993. Yet here we are, 32 years on, with the past instantly available in the present.&lt;/p&gt;
&lt;p&gt;Happy anniversary, Second Reality. Thank you for showing us what&apos;s possible.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Further Exploration:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://rye.dev/projects/doskit/&quot;&gt;DosKit Project Page&lt;/a&gt; - Complete technical documentation and architecture details&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/cameronrye/doskit&quot;&gt;DosKit on GitHub&lt;/a&gt; - The open-source tool enabling instant DOS software access&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doskit.net&quot;&gt;Try DosKit Live&lt;/a&gt; - Experience the platform with curated DOS software&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/mtuomi/SecondReality&quot;&gt;Second Reality Source Code&lt;/a&gt; - Released for the 20th anniversary&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://fabiensanglard.net/second_reality/index.php&quot;&gt;Fabien Sanglard&apos;s Code Review&lt;/a&gt; - Comprehensive technical analysis&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.pouet.net/prod.php?which=63&quot;&gt;Second Reality on Pouët&lt;/a&gt; - Demoscene database entry with community comments&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Have your own Second Reality memories? I&apos;d love to hear them. The demoscene community thrives on shared experiences and collective nostalgia for an era when computing felt like magic.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>retro-computing</category><category>webassembly</category><category>personal</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/second-reality-32nd-anniversary-a-vintage-monitor-projecting-r-featured-1764556642273.jpg" length="0" type="image/jpeg"/></item><item><title>The /llms.txt Standard: An Elegant Solution Nobody&apos;s Using</title><link>https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/</link><guid isPermaLink="true">https://rye.dev/blog/llms-txt-standard-elegant-solution-nobody-using/</guid><description>The /llms.txt standard is an elegant proposal for AI-friendly web content. It has one fundamental problem: no major AI platform actually uses it.</description><pubDate>Fri, 12 Sep 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/llms-txt-standard-elegant-solution-nobody-using-a-visual-metaphor-for-the-igno-featured-1764559700102.jpg&quot; alt=&quot;The /llms.txt Standard: An Elegant Solution Nobody&amp;#39;s Using&quot; /&gt;&lt;/p&gt;&lt;p&gt;There&apos;s something beautifully ironic happening on the web right now. Hundreds of websites have implemented a new standard called &lt;code&gt;/llms.txt&lt;/code&gt;, a carefully crafted markdown file designed to help AI systems understand their content. Developers have built tools to generate these files. Community directories catalog implementations. SEO platforms flag sites that don&apos;t have one.&lt;/p&gt;
&lt;p&gt;There&apos;s just one problem: not a single major AI platform actually uses it.&lt;/p&gt;
&lt;p&gt;No, really. Not OpenAI. Not Google. Not Anthropic. Not Meta. The very systems that &lt;code&gt;/llms.txt&lt;/code&gt; was designed to serve don&apos;t even check if the file exists. Server logs confirm it: when AI crawlers visit your website, they sail right past your lovingly crafted llms.txt file without a second glance.&lt;/p&gt;
&lt;p&gt;A failed web standard is only part of it. This is also a case study in the power dynamics of the AI era, the difficulty of grassroots standardization, and the growing tension between publishers and the platforms that increasingly control how their content reaches users. The /llms.txt saga says something about who holds power in the AI/web ecosystem, and it isn&apos;t the people creating content.&lt;/p&gt;
&lt;h2&gt;What is /llms.txt?&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/2025/10/2025-10-05-whiteboard-illustration-of-coffee-processing-steps-with-blurred-figure-in-foreground..jpg&quot; alt=&quot;Workflow diagram on whiteboard&quot; /&gt;
&lt;em&gt;Photo by Michael Burrows on Pexels&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;On September 3, 2024, Jeremy Howard, co-founder of fast.ai and creator of the popular nbdev framework, published a proposal for a new web standard. The idea was simple: websites would create a markdown file at &lt;code&gt;/llms.txt&lt;/code&gt; that provides AI systems with a curated, structured overview of their content.&lt;/p&gt;
&lt;p&gt;The problem Howard identified was real. &quot;Large language models increasingly rely on website information,&quot; he wrote, &quot;but face a critical limitation: context windows are too small to handle most websites in their entirety.&quot; Converting complex HTML pages (navigation menus, advertisements, JavaScript, formatting) into clean, LLM-friendly text is both difficult and imprecise.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/llms-txt-standard-elegant-solution-nobody-using-visualizes-the-technical-purpo-1764559716534.jpg&quot; alt=&quot;Visualizes the technical purpose of /llms.txt: converting complex HTML chaos into clean, structured Markdown context.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;/llms.txt&lt;/code&gt; solution follows a specific structure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An H1 heading with the project or site name (the only required element)&lt;/li&gt;
&lt;li&gt;A blockquote containing a concise summary&lt;/li&gt;
&lt;li&gt;Optional detailed information about the project&lt;/li&gt;
&lt;li&gt;H2-delimited sections containing markdown lists of links to key resources&lt;/li&gt;
&lt;li&gt;An optional &quot;Optional&quot; section for secondary content that can be skipped&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here&apos;s a simplified example from the FastHTML project:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# FastHTML

&amp;gt; FastHTML is a python library which brings together Starlette, Uvicorn, 
&amp;gt; HTMX, and fastcore&apos;s `FT` &quot;FastTags&quot; into a library for creating 
&amp;gt; server-rendered hypermedia applications.

## Docs

- [FastHTML quick start](https://fastht.ml/docs/tutorials/quickstart_for_web_devs.html.md): 
  A brief overview of many FastHTML features
- [HTMX reference](https://github.com/bigskysoftware/htmx/blob/master/www/content/reference.md): 
  Brief description of all HTMX attributes

## Optional

- [Starlette full documentation](https://example.com/starlette-sml.md): 
  A subset of the Starlette documentation useful for FastHTML development
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The proposal also suggested that individual pages offer markdown versions by appending &lt;code&gt;.md&lt;/code&gt; to their URLs, so &lt;code&gt;example.com/docs/guide.html&lt;/code&gt; would also be available at &lt;code&gt;example.com/docs/guide.html.md&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The Technical Elegance&lt;/h2&gt;
&lt;p&gt;From a design perspective, &lt;code&gt;/llms.txt&lt;/code&gt; is actually quite clever. It follows the established pattern of &lt;code&gt;/robots.txt&lt;/code&gt; and &lt;code&gt;/sitemap.xml&lt;/code&gt;, simple text files in the root directory that help automated systems understand websites. Markdown is a good choice of format: human-readable, machine-parseable, and already familiar to developers.&lt;/p&gt;
&lt;p&gt;The standard strikes a nice balance between structure and flexibility. The required elements ensure consistency, while the open-ended sections allow sites to organize information in ways that make sense for their specific content. The &quot;Optional&quot; section is particularly thoughtful. It acknowledges that LLMs with different context window sizes might need different amounts of information.&lt;/p&gt;
&lt;p&gt;An ecosystem quickly emerged around the standard. The Python package &lt;code&gt;llms_txt2ctx&lt;/code&gt; provides both a CLI tool and library for parsing llms.txt files and generating LLM-ready context. JavaScript implementations appeared. WordPress plugins and Drupal modules made implementation trivial for non-technical users. Community directories like llmstxt.site and directory.llmstxt.cloud began cataloging implementations.&lt;/p&gt;
&lt;p&gt;The proposal even inspired creative extensions. Some projects generate &quot;llms-full.txt&quot; files containing the complete text of all linked documents, creating a single massive file that LLMs with large context windows could consume in one go. Guillaume Laforge, a developer advocate, demonstrated feeding his entire blog (682,000 tokens!) to Google&apos;s Gemini using this approach, enabling sophisticated queries across his complete writing history.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/llms-txt-standard-elegant-solution-nobody-using-represents-the-server-log-data-1764559735516.jpg&quot; alt=&quot;Represents the server log data mentioned in the text: traffic exists, but none of it is engaging with the llms.txt protocol.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Adoption Reality Check&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/2025/10/2025-10-05-text-henwumetzzo.jpg&quot; alt=&quot;Abstract representation of disconnected network&quot; /&gt;
&lt;em&gt;Photo by David Pupăză on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Here&apos;s where the story takes a turn. Despite the technical elegance, the growing ecosystem, and hundreds of implementations, &lt;code&gt;/llms.txt&lt;/code&gt; faces a fundamental problem: the platforms it was designed for aren&apos;t using it.&lt;/p&gt;
&lt;p&gt;In July 2025, nearly a year after the proposal&apos;s launch, Ahrefs published a blunt analysis: &quot;no major LLM provider currently supports llms.txt. Not OpenAI. Not Anthropic. Not Google.&quot; This wasn&apos;t speculation. It was based on server log analysis showing that AI crawlers simply don&apos;t request llms.txt files when they visit websites.&lt;/p&gt;
&lt;p&gt;Google&apos;s John Mueller, a Search Relations team member, was even more direct: &quot;none of the AI services have said they&apos;re using LLMs.TXT (and you can tell when you look at your server logs that they don&apos;t even check for it).&quot; He compared the protocol to the keywords meta tag, a once-popular HTML element that search engines eventually ignored because it was too easily manipulated.&lt;/p&gt;
&lt;p&gt;The irony deepens when you look at who&apos;s implementing llms.txt. Anthropic, the company behind Claude, publishes its own llms.txt file. But Anthropic doesn&apos;t state that its crawlers actually use the standard when visiting other sites. It&apos;s the equivalent of putting up a sign in your window while ignoring everyone else&apos;s signs.&lt;/p&gt;
&lt;p&gt;This has created a strange situation where SEO tools are recommending something that provides no demonstrated benefit. Semrush began flagging missing llms.txt files as site issues, prompting frustrated discussions in marketing forums. &quot;Why should I incentivize people to get everything they need from an AI response and NOT visit their website?&quot; one marketer asked, capturing the deeper tension.&lt;/p&gt;
&lt;p&gt;Ryan Law, Director of Content Marketing at Ahrefs, put it succinctly: &quot;llms.txt is a proposed standard. I could also propose a standard (let&apos;s call it please-send-me-traffic-robot-overlords.txt), but unless the major LLM providers agree to use it, it&apos;s pretty meaningless.&quot;&lt;/p&gt;
&lt;h2&gt;Why Platforms Aren&apos;t Adopting It&lt;/h2&gt;
&lt;p&gt;The non-adoption of &lt;code&gt;/llms.txt&lt;/code&gt; isn&apos;t random. It reflects fundamental misalignments in incentives and power.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Traffic Paradox&lt;/strong&gt;: AI platforms face a basic conflict. Publishers want AI systems to send users to their websites. But platforms like Google, OpenAI, and Anthropic increasingly want to answer questions directly, keeping users within their own interfaces. Google&apos;s AI Overviews, for instance, have reduced organic clicks by 34.5% according to recent studies. Why would these platforms adopt a standard that makes it easier to send users away?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Existing Alternatives&lt;/strong&gt;: From the platforms&apos; perspective, they already have tools for understanding websites. Sitemaps list all pages. Structured data markup (Schema.org) provides semantic information. robots.txt indicates crawling preferences. The platforms have sophisticated systems for extracting and understanding content from HTML. They don&apos;t necessarily need publishers to create special markdown files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Control vs. Cooperation&lt;/strong&gt;: The &lt;code&gt;/llms.txt&lt;/code&gt; proposal assumes a cooperative model where publishers and platforms work together. But the current AI/web ecosystem is increasingly adversarial. According to HUMAN Security, 80% of companies now actively block AI crawlers. Publishers feel their content is being used without fair compensation. Platforms feel entitled to crawl public web content. A voluntary standard requires trust that simply doesn&apos;t exist.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Standardization Chicken-and-Egg&lt;/strong&gt;: For &lt;code&gt;/llms.txt&lt;/code&gt; to succeed, it needs critical mass. But publishers won&apos;t invest in creating comprehensive llms.txt files if platforms don&apos;t use them. And platforms won&apos;t build support for a standard that few sites implement. Without a forcing function such as regulatory requirements or industry consortium agreements, this deadlock persists.&lt;/p&gt;
&lt;p&gt;Brett Tabke, CEO of Pubcon and WebmasterWorld, argued that the whole thing is redundant: &quot;XML sitemaps and robots.txt already serve this purpose.&quot; From a platform perspective, he might be right.&lt;/p&gt;
&lt;h2&gt;What This Tells Us About the AI/Web Ecosystem&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;/llms.txt&lt;/code&gt; story reveals deeper truths about how AI is reshaping the web.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Power Asymmetry&lt;/strong&gt;: The most obvious lesson is about power. Publishers can propose standards, build tools, and implement files on their servers. But if platforms choose not to participate, none of it matters. This is fundamentally different from earlier web standards like RSS or microformats, which succeeded because they provided value to publishers independent of platform adoption. You could use RSS to syndicate your content whether or not Google Reader existed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Illusion of Control&lt;/strong&gt;: Many publishers are implementing &lt;code&gt;/llms.txt&lt;/code&gt; because it feels like taking control in an uncertain moment. &quot;Everyone&apos;s scrambling in a dark room where nothing&apos;s clearly visible,&quot; one SEO practitioner wrote. Creating an llms.txt file is concrete, actionable, and follows best practices. But it&apos;s performative: a ritual that provides psychological comfort without functional benefit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Grassroots vs. Platform-Driven Standards&lt;/strong&gt;: The web has a history of both grassroots standards (like markdown itself) and platform-driven standards (like AMP). The successful grassroots standards typically solved problems for creators independent of platform adoption. The &lt;code&gt;/llms.txt&lt;/code&gt; proposal, despite its grassroots origins, requires platform cooperation to function. It&apos;s a grassroots standard whose value depends on the platforms, perhaps an inherently unstable combination.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Broader Context&lt;/strong&gt;: This is happening against a backdrop of increasing tension between publishers and AI platforms. AI search visitors convert at 4.4 times higher rates than traditional organic visitors, making AI traffic valuable. But AI Overviews and chatbot answers are reducing the traffic publishers receive. Meanwhile, platforms face their own challenges. Google&apos;s AI Overviews have significant spam problems, and the quality of AI-generated answers remains inconsistent.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;/llms.txt&lt;/code&gt; saga is a microcosm of these larger conflicts. Publishers want standards that give them agency. Platforms want flexibility to optimize their systems. Users want accurate, helpful answers. These interests don&apos;t naturally align.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/llms-txt-standard-elegant-solution-nobody-using-illustrates-the-future-scenari-1764559753319.jpg&quot; alt=&quot;Illustrates the &apos;Future Scenarios&apos; section, visualizing the three potential outcomes: niche use, regulation, or evolution.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Future Scenarios: Where Does This Go?&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/2025/10/2025-10-05-railroad-crossing-sign-with-trees-in-background-uxuw2xdfwe0.jpg&quot; alt=&quot;Railroad crossing representing diverging paths&quot; /&gt;
&lt;em&gt;Photo by MICHAEL CHIARA on Unsplash&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Looking ahead, several scenarios seem possible:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 1: Permanent Niche Status&lt;/strong&gt;
The most likely outcome is that &lt;code&gt;/llms.txt&lt;/code&gt; remains a niche practice among developer-focused sites and AI enthusiasts. It becomes a signal of technical sophistication rather than a functional standard, similar to how some sites still maintain RSS feeds even though RSS usage has declined. There&apos;s no harm in this, but also limited benefit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 2: Regulatory or Consortium-Driven Adoption&lt;/strong&gt;
If regulations emerge requiring AI platforms to respect publisher preferences, &lt;code&gt;/llms.txt&lt;/code&gt; could become part of the compliance framework. Alternatively, an industry consortium (perhaps involving publishers, platforms, and civil society groups) could negotiate standards for AI/web interaction, with llms.txt as one component. This would require significant external pressure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 3: Evolution into Something Else&lt;/strong&gt;
The core ideas behind &lt;code&gt;/llms.txt&lt;/code&gt;, structured and curated content for AI systems, might evolve into different implementations. Perhaps platforms develop their own submission systems (like Google Search Console but for AI). Or maybe the approach merges with existing standards like structured data markup. The specific llms.txt format might fade, but the underlying need persists.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 4: Unexpected Platform Adoption&lt;/strong&gt;
It&apos;s possible that a major platform could adopt &lt;code&gt;/llms.txt&lt;/code&gt; as a differentiator. A new AI search engine trying to compete with Google might embrace it as a way to build publisher goodwill. Or an existing platform might adopt it in response to competitive pressure or regulatory scrutiny. This seems unlikely but not impossible.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What Would Need to Change?&lt;/strong&gt;
For meaningful adoption, we&apos;d need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Clear value proposition for platforms (not just publishers)&lt;/li&gt;
&lt;li&gt;Incentive alignment or regulatory requirements&lt;/li&gt;
&lt;li&gt;Demonstration of superior results compared to existing methods&lt;/li&gt;
&lt;li&gt;Critical mass of high-quality implementations&lt;/li&gt;
&lt;li&gt;Platform commitment to transparency about usage&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these seem imminent.&lt;/p&gt;
&lt;h2&gt;What Should You Actually Do?&lt;/h2&gt;
&lt;p&gt;If you&apos;re a publisher or developer wondering whether to implement &lt;code&gt;/llms.txt&lt;/code&gt;, here&apos;s a practical framework:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don&apos;t implement it if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&apos;re doing it solely for SEO benefit (there is none currently)&lt;/li&gt;
&lt;li&gt;You&apos;re hoping it will increase AI-driven traffic (it won&apos;t)&lt;/li&gt;
&lt;li&gt;You&apos;re resource-constrained and need to prioritize&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Consider implementing it if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&apos;re in the developer tools or technical documentation space where early adopters might manually use it&lt;/li&gt;
&lt;li&gt;You want to signal technical sophistication to your audience&lt;/li&gt;
&lt;li&gt;You&apos;re already creating markdown documentation and it&apos;s trivial to add&lt;/li&gt;
&lt;li&gt;You&apos;re experimenting with AI-assisted documentation systems&lt;/li&gt;
&lt;li&gt;You want to be prepared if adoption happens later&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Focus instead on:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Creating high-quality, well-structured content&lt;/li&gt;
&lt;li&gt;Using existing standards properly (sitemaps, structured data)&lt;/li&gt;
&lt;li&gt;Optimizing for how AI systems actually work today&lt;/li&gt;
&lt;li&gt;Building direct relationships with your audience&lt;/li&gt;
&lt;li&gt;Diversifying traffic sources beyond search and AI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The harsh reality is that AI optimization remains more art than science. Aleyda Solis, a respected SEO expert, released AI search optimization guidelines that focus on content structure, crawlability, and quality. Those fundamentals matter regardless of specific standards.&lt;/p&gt;
&lt;h2&gt;The Value of the Attempt&lt;/h2&gt;
&lt;p&gt;Despite its current limitations, the &lt;code&gt;/llms.txt&lt;/code&gt; proposal isn&apos;t worthless. It represents an important attempt to establish norms for AI/web interaction. It sparked conversations about publisher agency, platform responsibility, and the future of web standards. It demonstrated what a cooperative approach could look like, even if cooperation isn&apos;t currently happening.&lt;/p&gt;
&lt;p&gt;Jeremy Howard&apos;s proposal also highlighted a real problem: the web wasn&apos;t designed for AI consumption, and AI systems weren&apos;t designed for the web&apos;s complexity. That tension won&apos;t resolve itself. We need standards, protocols, and norms for this new era. The &lt;code&gt;/llms.txt&lt;/code&gt; approach might not be the answer, but asking the question was valuable.&lt;/p&gt;
&lt;p&gt;There&apos;s also something admirable about the attempt to solve problems through open standards rather than proprietary systems. In an era of increasing platform consolidation, grassroots standardization efforts matter, even when they fail. They remind us that the web&apos;s architecture isn&apos;t predetermined, that alternatives exist, and that communities can propose different futures.&lt;/p&gt;
&lt;h2&gt;Conclusion: Lessons from a Standard in Limbo&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;/llms.txt&lt;/code&gt; story is still being written, but its current chapter offers clear lessons. Technical elegance doesn&apos;t guarantee adoption. Grassroots enthusiasm can&apos;t overcome platform indifference. Standards that require cooperation struggle in adversarial environments. Power matters more than good ideas.&lt;/p&gt;
&lt;p&gt;But perhaps the most important lesson is about the changing nature of the web itself. The era when publishers and platforms had aligned interests, when helping search engines understand your content meant more traffic, is ending. The AI age introduces new dynamics where platforms can extract value from content without sending users to sources. In this environment, voluntary standards face steep challenges.&lt;/p&gt;
&lt;p&gt;For now, &lt;code&gt;/llms.txt&lt;/code&gt; exists in a strange limbo: implemented but unused, promoted but ineffective, elegant but irrelevant. It&apos;s a monument to good intentions in an ecosystem increasingly defined by conflicting interests.&lt;/p&gt;
&lt;p&gt;It might succeed, evolve into something else, or fade into obscurity. Either way, the &lt;code&gt;/llms.txt&lt;/code&gt; experiment will remain a case study in the challenges of standardization in the AI era. It shows us both the possibilities of cooperative approaches and the harsh realities of power asymmetries.&lt;/p&gt;
&lt;p&gt;The web has always been shaped by the tension between openness and control, cooperation and competition, idealism and pragmatism. The &lt;code&gt;/llms.txt&lt;/code&gt; standard embodies all these tensions. Its fate will tell us something important about which forces prevail in the AI age.&lt;/p&gt;
&lt;p&gt;For now, the elegant solution sits unused, waiting for a problem that the powerful have chosen not to solve.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Further Reading:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://llmstxt.org/&quot;&gt;Official llms.txt specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.fastht.ml/docs/llms.txt&quot;&gt;FastHTML llms.txt example&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ahrefs.com/blog/what-is-llms-txt/&quot;&gt;Ahrefs analysis on llms.txt adoption&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://llmstxt.site/&quot;&gt;Community directory of implementations&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>ai</category><category>web-standards</category><category>llms-txt</category><category>seo</category><category>machine-learning</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/llms-txt-standard-elegant-solution-nobody-using-a-visual-metaphor-for-the-igno-featured-1764559700102.jpg" length="0" type="image/jpeg"/></item><item><title>AT Protocol MCP Server: Bridging AI and Bluesky&apos;s Decentralized Social Network</title><link>https://rye.dev/blog/atproto-mcp-bluesky-integration/</link><guid isPermaLink="true">https://rye.dev/blog/atproto-mcp-bluesky-integration/</guid><description>My MCP server for the AT Protocol: zero-config public reads of Bluesky data, authenticated writes when you need them.</description><pubDate>Sat, 30 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/atproto-mcp-bluesky-integration-a-conceptual-visualization-of--featured-1764556747954.jpg&quot; alt=&quot;AT Protocol MCP Server: Bridging AI and Bluesky&amp;#39;s Decentralized Social Network&quot; /&gt;&lt;/p&gt;&lt;p&gt;I built the &lt;strong&gt;AT Protocol MCP Server&lt;/strong&gt; to let LLMs talk directly to the AT Protocol, the network behind Bluesky and a growing set of other decentralized social apps. It&apos;s a Model Context Protocol implementation: an AI client connects to it and gets a set of tools for reading and writing AT Protocol data.&lt;/p&gt;
&lt;p&gt;The gap it fills: AI tools have no standard, secure way to read from or write to these networks. AT Protocol is worth the effort because it&apos;s built differently from a centralized platform. Your data lives in a repository you control, you can move it between providers, and you pick your own feeds and moderation. That openness is what makes it a natural fit for automated analysis.&lt;/p&gt;
&lt;h2&gt;How AT Protocol Is Different&lt;/h2&gt;
&lt;p&gt;AT Protocol isn&apos;t federation in the ActivityPub sense, and it isn&apos;t a walled-garden platform either. It sits in between, and that middle ground is what makes it worth building against for AI integration.&lt;/p&gt;
&lt;h3&gt;Architectural Foundations&lt;/h3&gt;
&lt;p&gt;A few design choices set it apart from other social networking architectures:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repository-based data model&lt;/strong&gt;: your data lives in a personal data repository (PDR) that you control, so you can move it between service providers without losing it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global state&lt;/strong&gt;: relays aggregate the network into a globally consistent view, which sidesteps the sync problems that federated protocols run into&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Algorithmic marketplace&lt;/strong&gt;: content hosting and content discovery are separate, so you choose your own feed algorithms and moderation policies&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lexicon schema system&lt;/strong&gt;: extensible schema definitions let the protocol add new record types without breaking backward compatibility or interoperability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For an AI client, that means you can read the social graph and public content freely, without fighting a platform that treats the data as its own.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/atproto-mcp-bluesky-integration-visualizes-the-at-protocol-s-u-1764556766494.jpg&quot; alt=&quot;The AT Protocol architecture, where user data exists independently of the applications that display it.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Design Decisions&lt;/h2&gt;
&lt;h3&gt;Zero-Configuration Public Access&lt;/h3&gt;
&lt;p&gt;The server works with no authentication at all. That was deliberate: a lot of what people want from AT Protocol is public data, and forcing an auth setup before you can read a single profile is friction nobody needs.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Public data access requires no configuration
const profile = await mcpClient.callTool(&apos;get_user_profile&apos;, {
  identifier: &apos;user.bsky.social&apos;
});

const posts = await mcpClient.callTool(&apos;search_posts&apos;, {
  query: &apos;artificial intelligence&apos;,
  limit: 20
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An LLM client can start reading AT Protocol data right away, which keeps prototyping fast and cuts the integration work for the common cases.&lt;/p&gt;
&lt;h3&gt;Progressive Authentication Model&lt;/h3&gt;
&lt;p&gt;When you need to write posts or read private data, the server supports two auth methods: app passwords and OAuth.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// App password authentication for development
const authenticatedClient = new ATProtoMCPServer({
  identifier: &apos;user.bsky.social&apos;,
  password: &apos;app-specific-password&apos;
});

// OAuth flow for production deployments
const oauthClient = await mcpClient.callTool(&apos;start_oauth_flow&apos;, {
  clientId: process.env.ATPROTO_CLIENT_ID,
  redirectUri: &apos;https://app.example.com/callback&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;App passwords are easy for local development; OAuth is the right choice for anything you deploy.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/atproto-mcp-bluesky-integration-illustrates-the-function-of-th-1764556789102.jpg&quot; alt=&quot;The MCP server translating natural-language intent into structured protocol actions.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Implementation Notes&lt;/h2&gt;
&lt;h3&gt;Official SDK Integration&lt;/h3&gt;
&lt;p&gt;It&apos;s built on the official &lt;code&gt;@atproto/api&lt;/code&gt; SDK, so it stays compliant as the protocol changes and I don&apos;t have to hand-roll the wire format:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { BskyAgent } from &apos;@atproto/api&apos;;

export class ATProtoMCPServer {
  private agent: BskyAgent;

  constructor(config: ServerConfig) {
    this.agent = new BskyAgent({
      service: config.service || &apos;https://bsky.social&apos;
    });
  }

  async searchPosts(params: SearchParams): Promise&amp;lt;SearchResults&amp;gt; {
    const response = await this.agent.app.bsky.feed.searchPosts({
      q: params.query,
      limit: params.limit,
      cursor: params.cursor
    });

    return this.transformSearchResults(response.data);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The SDK tracks the spec, and the MCP layer keeps the protocol details behind a small set of tools.&lt;/p&gt;
&lt;h3&gt;Tool Coverage&lt;/h3&gt;
&lt;p&gt;The tools cover most of what the protocol can do:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Social Operations&lt;/strong&gt;: Post creation with rich text formatting, threading, reactions (likes, reposts), and social graph management (follows, blocks, mutes)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content Discovery&lt;/strong&gt;: search, custom feed access, timeline retrieval, and thread navigation&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Media Handling&lt;/strong&gt;: Image and video upload with automatic optimization, link preview generation, and rich embed support&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real-time Streaming&lt;/strong&gt;: WebSocket-based event streams for live notifications, timeline updates, and social graph changes&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Moderation Tools&lt;/strong&gt;: Content and user reporting, muting, blocking, and list management for community curation&lt;/p&gt;
&lt;h3&gt;Performance&lt;/h3&gt;
&lt;p&gt;A few things matter once this is running under real load:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Connection pooling&lt;/strong&gt;: persistent connections to AT Protocol services cut the per-request latency and hold up better under high volume.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Caching&lt;/strong&gt;: a layered cache, keyed by record type, that follows AT Protocol&apos;s cache semantics and skips redundant network requests:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface CacheStrategy {
  profileCache: LRUCache&amp;lt;string, Profile&amp;gt;;
  postCache: LRUCache&amp;lt;string, Post&amp;gt;;
  feedCache: LRUCache&amp;lt;string, FeedView&amp;gt;;
  ttl: number;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt;: backs off and retries when the service returns a rate-limit error, so you stay under AT Protocol&apos;s limits without leaving throughput on the table:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class RateLimiter {
  async executeWithBackoff&amp;lt;T&amp;gt;(
    operation: () =&amp;gt; Promise&amp;lt;T&amp;gt;
  ): Promise&amp;lt;T&amp;gt; {
    try {
      return await operation();
    } catch (error) {
      if (this.isRateLimitError(error)) {
        await this.exponentialBackoff();
        return this.executeWithBackoff(operation);
      }
      throw error;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/atproto-mcp-bluesky-integration-depicts-the-production-ready-c-1764556809602.jpg&quot; alt=&quot;The containerized deployment architecture.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Production Deployment Architecture&lt;/h2&gt;
&lt;h3&gt;Infrastructure&lt;/h3&gt;
&lt;p&gt;The repo ships with what you need to run this in production:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Docker&lt;/strong&gt;: multi-stage builds that run as a non-root user, which keeps the image small and the attack surface minimal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kubernetes&lt;/strong&gt;: Helm charts and deployment manifests for running it on a cluster.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Observability&lt;/strong&gt;: Prometheus metrics, structured logging, and health-check endpoints.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security&lt;/strong&gt;: input validation, credential sanitization, CORS configuration, and secret management.&lt;/p&gt;
&lt;h3&gt;Deployment Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# docker-compose.yml
version: &apos;3.8&apos;
services:
  atproto-mcp:
    image: atproto-mcp:latest
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
      - ATPROTO_IDENTIFIER=${ATPROTO_IDENTIFIER}
      - ATPROTO_PASSWORD=${ATPROTO_PASSWORD}
    ports:
      - &quot;3000:3000&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:3000/health&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What You Can Build With It&lt;/h2&gt;
&lt;h3&gt;Social Media Analytics and Research&lt;/h3&gt;
&lt;p&gt;Because AT Protocol data is open, you can run analysis that would be locked away on a closed platform:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Analyze engagement patterns across custom feeds
const feeds = await mcpClient.callTool(&apos;get_custom_feed&apos;, {
  feed: &apos;at://did:plc:example/app.bsky.feed.generator/tech-news&apos;
});

// Track topic evolution and community dynamics
const searchResults = await mcpClient.callTool(&apos;search_posts&apos;, {
  query: &apos;machine learning&apos;,
  since: &apos;2025-01-01&apos;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Content Automation and Management&lt;/h3&gt;
&lt;p&gt;The write tools cover posting, so you can build content creation and curation workflows on top of them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Create rich text posts with mentions and links
await mcpClient.callTool(&apos;create_rich_text_post&apos;, {
  text: &apos;Exploring @user.bsky.social insights on AI: https://example.com&apos;,
  facets: [
    { type: &apos;mention&apos;, value: &apos;user.bsky.social&apos; },
    { type: &apos;link&apos;, value: &apos;https://example.com&apos; }
  ]
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Community Management and Moderation&lt;/h3&gt;
&lt;p&gt;The moderation and list tools let you build AI-assisted community management, from muting and blocking to curating lists.&lt;/p&gt;
&lt;h2&gt;What&apos;s Next&lt;/h2&gt;
&lt;p&gt;A few things I&apos;d like to add as the AT Protocol ecosystem grows:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Graph analysis&lt;/strong&gt;: tools for mapping community structure and how information moves across the AT Protocol network.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Automation&lt;/strong&gt;: content scheduling, engagement automation, and AI-driven curation workflows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cross-protocol bridges&lt;/strong&gt;: connectors to other decentralized protocols, so you can manage accounts across platforms from one place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lexicon support&lt;/strong&gt;: picking up new AT Protocol lexicons and record types automatically as the spec adds them.&lt;/p&gt;
&lt;h2&gt;Wrapping Up&lt;/h2&gt;
&lt;p&gt;That&apos;s the AT Protocol MCP Server: public reads with no setup, authenticated writes when you need them, and enough deployment tooling to run it for real. If you&apos;re building AI tools that touch Bluesky or anything else on AT Protocol, it should save you from wiring up the protocol yourself.&lt;/p&gt;
&lt;p&gt;The thing I like about decentralized social is that the data is yours to work with. This is my attempt to make that data reachable from an LLM.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Resources&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/cameronrye/atproto-mcp&quot;&gt;GitHub Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cameronrye.github.io/atproto-mcp/&quot;&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://atproto.com/&quot;&gt;AT Protocol Specification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://bsky.app/&quot;&gt;Bluesky Social&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>mcp</category><category>ai</category><category>typescript</category><category>decentralization</category><category>atproto</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/atproto-mcp-bluesky-integration-a-conceptual-visualization-of--featured-1764556747954.jpg" length="0" type="image/jpeg"/></item><item><title>Project: AT Protocol MCP Server</title><link>https://rye.dev/projects/atproto-mcp/</link><guid isPermaLink="true">https://rye.dev/projects/atproto-mcp/</guid><description>Lets AI assistants read and write to Bluesky over the AT Protocol: zero-config public reads, OAuth-scoped writes, and full social graph access.</description><pubDate>Sat, 30 Aug 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/atproto-mcp-detail-light.webp&quot; alt=&quot;AT Protocol MCP Server screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import MCPToolDemo from &apos;../../components/demos/MCPToolDemo.tsx&apos;;
import { ATPROTO_MCP_TOOLS } from &apos;../../data/lab-experiments&apos;;&lt;/p&gt;
&lt;p&gt;The AT Protocol MCP Server bridges AI assistants with Bluesky and the decentralized social web. It provides zero-configuration public access for reading, with optional OAuth for authenticated operations.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;MCPToolDemo client:visible serverName=&quot;atproto-mcp&quot; tools={ATPROTO_MCP_TOOLS} /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero Configuration&lt;/strong&gt;: Immediate access to public AT Protocol data&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full Protocol Coverage&lt;/strong&gt;: Posts, profiles, feeds, and social graph&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OAuth Support&lt;/strong&gt;: Secure authentication for write operations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Production Ready&lt;/strong&gt;: Docker, Kubernetes, and enterprise deployment support&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why this project mattered&lt;/h2&gt;
&lt;p&gt;AT Protocol is interesting for reasons beyond powering Bluesky: it&apos;s decentralized, strongly typed, and split across multiple services with different responsibilities. That gives developers a lot of control, but it&apos;s awkward for LLMs and agent frameworks that need a stable interface.&lt;/p&gt;
&lt;p&gt;This project turned that complexity into a predictable MCP surface area. Instead of asking an LLM to understand handles, DIDs, AppView reads, PDS writes, and OAuth on its own, the server exposes those capabilities as discoverable tools with clear inputs and outputs.&lt;/p&gt;
&lt;h2&gt;Architecture decisions&lt;/h2&gt;
&lt;p&gt;The most important design choice was separating read-only and authenticated operations.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Public tools route through the public AT Protocol APIs so an assistant can explore profiles, feeds, and search results immediately.&lt;/li&gt;
&lt;li&gt;Authenticated tools are isolated behind OAuth so write access is explicit and scoped.&lt;/li&gt;
&lt;li&gt;The MCP layer keeps protocol vocabulary intact enough for power users, while still normalizing the shape of requests for agent use.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That split makes the project useful in two modes: instant exploration with zero setup, and production workflows where identity and write access matter.&lt;/p&gt;
&lt;h2&gt;Tradeoffs and implementation lessons&lt;/h2&gt;
&lt;p&gt;One of the main tradeoffs was how much protocol detail to hide. Abstract too much, and the tool becomes misleading. Expose too much, and the tool stops being ergonomic. I aimed for a middle path: tool names and parameters reflect the underlying network model, but the server handles the plumbing around discovery, authentication, and response shaping.&lt;/p&gt;
&lt;p&gt;That approach made it easier to support both hobbyist experimentation and more serious deployment scenarios such as hosted MCP servers, containers, and Kubernetes-based setups.&lt;/p&gt;
&lt;h2&gt;Outcome&lt;/h2&gt;
&lt;p&gt;The server is a translation layer, not a wrapper around Bluesky endpoints. It makes a decentralized protocol practical inside AI workflows: reads work with zero setup, writes require explicit OAuth grants, and the tool surface mirrors the protocol&apos;s own vocabulary of handles, DIDs, and feeds.&lt;/p&gt;
</content:encoded><category>TypeScript</category><category>MCP</category><category>AT Protocol</category><category>Bluesky</category><category>OAuth</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/atproto-mcp-detail-light.webp" length="0" type="image/webp"/></item><item><title>Infrastructure Sovereignty and the Economics of Decentralized Social Protocols</title><link>https://rye.dev/blog/infrastructure-sovereignty-decentralized-social-protocols/</link><guid isPermaLink="true">https://rye.dev/blog/infrastructure-sovereignty-decentralized-social-protocols/</guid><description>Examining the technical architecture trade-offs and governance challenges in AT Protocol&apos;s approach to decentralized social media infrastructure.</description><pubDate>Fri, 25 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/infrastructure-sovereignty-decentralized-social-protocols-a-visual-metaphor-for-infrastr-featured-1764557749310.jpg&quot; alt=&quot;Infrastructure Sovereignty and the Economics of Decentralized Social Protocols&quot; /&gt;&lt;/p&gt;&lt;p&gt;&lt;em&gt;This analysis builds upon Dan Abramov&apos;s excellent explanation of AT Protocol in &lt;a href=&quot;https://overreacted.io/open-social/&quot;&gt;&quot;Open Social&quot;&lt;/a&gt;, examining the deeper technical architecture trade-offs and governance implications of decentralized social media infrastructure.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Dan&apos;s explanation of AT Protocol&apos;s architecture is clear, and it lays out the technical advantages well. The broader discussion around decentralized social protocols raises questions worth examining from a systems architecture perspective, especially the practical side of building global social infrastructure.&lt;/p&gt;
&lt;h2&gt;The Infrastructure Sovereignty Question&lt;/h2&gt;
&lt;p&gt;AT Protocol gives you data sovereignty. Users control their repositories and can migrate between hosting providers. But it introduces a subtler dependency: infrastructure sovereignty. The global relay and AppView architecture creates its own kind of lock-in. Users may own their data, but whether that data is actually useful depends entirely on the availability and neutrality of massive aggregation infrastructure.&lt;/p&gt;
&lt;p&gt;This is a fundamental trade-off. Email worked as a federated protocol precisely because it doesn&apos;t require global state consistency. Social media expects real-time, globally consistent feeds, and that expectation pushes toward centralized aggregation points. AT Protocol&apos;s answer is clever, but it necessarily concentrates power in the hands of whoever runs the relays and AppViews.&lt;/p&gt;
&lt;p&gt;The comparison to Google Reader is particularly apt. Google provided immense value by aggregating RSS feeds, but when they discontinued the service, the entire ecosystem fragmented. AT Protocol faces similar risks: the protocol may be open, but the practical infrastructure required for global social media operates at a scale that few organizations can sustain.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/infrastructure-sovereignty-decentralized-social-protocols-visualizes-the-economic-sustai-1764557768361.jpg&quot; alt=&quot;Visualizes the &apos;Economic Sustainability&apos; aspect, highlighting that the heavy lifting of data processing (Relays/AppViews) requires significant resources and investment.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Economic Sustainability and Governance Models&lt;/h2&gt;
&lt;p&gt;The economics of operating global social infrastructure are harder than the current discourse tends to admit. Running relays that process millions of events per second, and AppViews that serve billions of queries, takes serious compute and operational expertise. The current model assumes altruistic infrastructure providers, and that assumption gets shaky at scale.&lt;/p&gt;
&lt;p&gt;History suggests infrastructure providers eventually go looking for sustainable business models. The ad-driven approach that led to the enshittification of centralized platforms could just as easily show up in the AT Protocol ecosystem. A relay operator facing mounting costs might give paying customers preferential treatment; an AppView might start filtering content to optimize for engagement metrics.&lt;/p&gt;
&lt;p&gt;The PLC directory governance model shows the problem. The cryptographic verification gives you technical integrity, but the practical operation of identity resolution is a single point of failure. The planned transition to an independent entity is encouraging. The underlying question remains: how do we keep critical infrastructure neutral and accessible as economic pressure mounts?&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/infrastructure-sovereignty-decentralized-social-protocols-illustrates-the-technical-arch-1764557785052.jpg&quot; alt=&quot;Illustrates the &apos;Technical Architecture&apos; trade-offs, specifically the centralization of aggregation (the sphere) required to achieve global consistency in a decentralized network.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Technical Architecture Implications&lt;/h2&gt;
&lt;p&gt;In distributed-systems terms, AT Protocol essentially picks consistency and partition tolerance over availability, in the CAP sense. The global relay architecture guarantees every participant sees the same state, but it pays for that with massive, always-available infrastructure. That choice ripples out into protocol evolution, caching strategies, and failure modes.&lt;/p&gt;
&lt;p&gt;The lexicon system for schema evolution is sophisticated, but it opens the door to fragmentation at the application layer. As schemas evolve and new record types appear, keeping everything interoperable gets harder. The &quot;open union&quot; approach buys flexibility, and it also creates situations where different applications read the same data differently.&lt;/p&gt;
&lt;p&gt;Developer experience is another factor. Building on AT Protocol means understanding repositories, DIDs, lexicons, and the relay architecture. That&apos;s a lot more to learn than a traditional API integration, and the added complexity may limit adoption among developers who care more about shipping fast than architectural purity.&lt;/p&gt;
&lt;h2&gt;Practical Adoption Considerations&lt;/h2&gt;
&lt;p&gt;Network effects loom large for any social protocol. AT Protocol&apos;s technical advantages are real, but adoption depends on hitting critical mass in a market where users care about immediate utility, not long-term data portability. Most people don&apos;t understand or care about repository ownership until they hit platform lock-in themselves.&lt;/p&gt;
&lt;p&gt;The payoff has to be immediate and tangible. Bluesky&apos;s success so far comes mostly from being a better product than the alternatives, not from its underlying protocol architecture. That suggests protocol adoption depends more on application quality than technical superiority, which matches how technology adoption usually goes.&lt;/p&gt;
&lt;h2&gt;Strategic Implications for Open Social Infrastructure&lt;/h2&gt;
&lt;p&gt;The broader question is whether we can build sustainable, neutral infrastructure for global social communication. AT Protocol is a serious attempt at it, but getting there takes more than good architecture. It needs economic models that actually pay for themselves, governance that holds up under pressure, and broad adoption across a lot of different players.&lt;/p&gt;
&lt;p&gt;The comparison to open source infrastructure is useful but incomplete. Open source succeeded partly because the marginal cost of distributing software is close to zero. Social infrastructure needs ongoing operational investment, and it doesn&apos;t scale on the same economics.&lt;/p&gt;
&lt;p&gt;Maybe the most promising thing about AT Protocol is that it leaves room to experiment with different sustainability models. Different relays and AppViews could try subscriptions, cooperative ownership, or public funding, letting the ecosystem feel its way toward patterns that actually hold up.&lt;/p&gt;
&lt;h2&gt;Future Considerations&lt;/h2&gt;
&lt;p&gt;AT Protocol is a thoughtful approach to the core challenges of decentralized social media, but its success depends on solving problems that reach well beyond protocol design. The technical architecture is sound. The economic and governance problems are the ones that will need continued work and careful attention to incentive alignment.&lt;/p&gt;
&lt;p&gt;The conversation shouldn&apos;t stop at whether AT Protocol is technically superior to the alternatives. The harder question is how we build sustainable, neutral infrastructure for global social communication. That means treating economic sustainability, governance models, and adoption incentives with the same rigor we bring to the technical architecture.&lt;/p&gt;
&lt;p&gt;The stakes are real. If we solve these problems, AT Protocol could be the &quot;open social&quot; equivalent of open source infrastructure. If we don&apos;t, we risk building new forms of centralization that reproduce the exact problems we set out to solve.&lt;/p&gt;
&lt;p&gt;Getting there will take continued experimentation, close attention to the patterns that emerge, and a willingness to revisit architectural decisions as real operational experience comes in. The technical foundation is promising. Whether it succeeds comes down to whether we can line up those technical capabilities with sustainable economic and governance models.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This analysis examines the practical implications of architectural choices in decentralized social protocols and the challenges of building sustainable open infrastructure for global social communication.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>protocols</category><category>decentralization</category><category>atproto</category><category>open-web</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/infrastructure-sovereignty-decentralized-social-protocols-a-visual-metaphor-for-infrastr-featured-1764557749310.jpg" length="0" type="image/jpeg"/></item><item><title>Wassette: Microsoft&apos;s WebAssembly Runtime for Secure AI Tool Execution</title><link>https://rye.dev/blog/wassette-webassembly-mcp-runtime/</link><guid isPermaLink="true">https://rye.dev/blog/wassette-webassembly-mcp-runtime/</guid><description>Most MCP servers run with full system access. I dig into Wassette, Microsoft&apos;s WebAssembly runtime that sandboxes AI tools behind capability grants.</description><pubDate>Wed, 18 Jun 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/wassette-webassembly-mcp-runtime-a-visual-metaphor-for-wassette-featured-1764556852449.jpg&quot; alt=&quot;Wassette: Microsoft&amp;#39;s WebAssembly Runtime for Secure AI Tool Execution&quot; /&gt;&lt;/p&gt;&lt;p&gt;AI agents can now run external tools and reach into system resources, and the security model most software execution relies on wasn&apos;t built for that. Microsoft&apos;s Wassette takes a different line. It uses WebAssembly&apos;s sandbox to run AI tools in a secure runtime, exposed through the Model Context Protocol (MCP).&lt;/p&gt;
&lt;p&gt;Today most MCP servers run with unrestricted system access. Wassette replaces that with a capability-based model, where a tool gets fine-grained control over exactly which resources it can touch without giving up the flexibility that made MCP worth using in the first place.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/wassette-webassembly-mcp-runtime-an-architectural-visualization-1764556873674.jpg&quot; alt=&quot;An architectural visualization showing the relationship between the runtime, the sandboxed tool, and the permission layer that guards system resources.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Understanding Wassette&apos;s Architecture&lt;/h2&gt;
&lt;p&gt;Wassette (pronounced &quot;Wass-ette,&quot; a portmanteau of &quot;Wasm&quot; and &quot;Cassette&quot;) is an open-source MCP server implementation that runs WebAssembly Components in a secure sandbox environment. Unlike traditional MCP servers that execute as standalone processes with full system privileges, Wassette constrains tool execution within WebAssembly&apos;s security boundaries while providing controlled access to system resources through explicit capability grants.&lt;/p&gt;
&lt;p&gt;The architecture centers on three core principles:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sandboxed Execution&lt;/strong&gt;: Every tool runs within WebAssembly&apos;s memory-safe, isolated execution environment, preventing unauthorized access to system resources, memory corruption vulnerabilities, and arbitrary code execution.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Capability-Based Security&lt;/strong&gt;: Tools must explicitly request and receive permission for specific operations, including file system access, network connections, and environment variable access, following the principle of least privilege.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Component Model Integration&lt;/strong&gt;: Wassette uses the WebAssembly Component Model (WASM Components) to give tools strongly-typed interfaces and interoperability across languages.&lt;/p&gt;
&lt;h3&gt;The Security Imperative&lt;/h3&gt;
&lt;p&gt;Current MCP deployment patterns expose significant attack surfaces. Traditional approaches include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Direct Binary Execution&lt;/strong&gt;: Tools run via package managers like &lt;code&gt;npx&lt;/code&gt; or &lt;code&gt;uvx&lt;/code&gt; with full system privileges&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Container Isolation&lt;/strong&gt;: While providing some boundaries, containers lack fine-grained permission controls&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Standalone Processes&lt;/strong&gt;: MCP servers communicate via stdio or sockets but inherit host process privileges&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These patterns create vulnerabilities where a malicious or compromised tool can read arbitrary files, open unauthorized network connections, or run system commands. Wassette closes this off with a zero-trust model: nothing is available until a capability is explicitly granted.&lt;/p&gt;
&lt;h2&gt;Technical Implementation and Architecture&lt;/h2&gt;
&lt;p&gt;Wassette is built in Rust on top of the Wasmtime runtime, which gives it a memory-safe, fast foundation for running WebAssembly. A few key pieces:&lt;/p&gt;
&lt;h3&gt;Core Runtime Components&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Wasmtime Integration&lt;/strong&gt;: Wassette builds on Wasmtime, Mozilla&apos;s production-ready WebAssembly runtime, inheriting its security properties and performance optimizations. Wasmtime provides the foundational sandboxing that isolates WebAssembly modules from the host system.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MCP Protocol Bridge&lt;/strong&gt;: The runtime translates between MCP&apos;s JSON-RPC protocol and WebAssembly Component interfaces, so existing MCP clients work unchanged while type safety comes from the Component Model&apos;s interface definitions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Permission Engine&lt;/strong&gt;: A policy engine manages capability grants and revocations, supporting both static policy files and dynamic permission changes through MCP tools.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/wassette-webassembly-mcp-runtime-illustrates-the-component-mode-1764556893315.jpg&quot; alt=&quot;Illustrates the &apos;Component Model Integration&apos; and &apos;Language Agnostic&apos; features, showing how different languages combine into one secure runtime.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Component Model Integration&lt;/h3&gt;
&lt;p&gt;Wassette&apos;s use of the WebAssembly Component Model is a step up from plain WebAssembly modules. Components provide:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Strongly-Typed Interfaces&lt;/strong&gt;: Tools expose their capabilities through WebAssembly Interface Types (WIT), enabling compile-time verification of interface compatibility and runtime type safety.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Language Agnostic Development&lt;/strong&gt;: Components can be written in any language that compiles to WebAssembly, including Rust, JavaScript, Python, Go, and C++, while maintaining interface compatibility.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Composability&lt;/strong&gt;: Components can be composed and linked together, enabling complex tool chains while maintaining isolation boundaries.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example WIT definition for a simple time server component:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package local:time-server;

world time-server {
    export get-current-time: func() -&amp;gt; string;
    export get-timezone: func() -&amp;gt; string;
    export format-time: func(timestamp: u64, format: string) -&amp;gt; string;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This interface definition is completely generic. There&apos;s nothing MCP-specific about it. Wassette automatically exposes these functions as MCP tools by introspecting the component&apos;s interface.&lt;/p&gt;
&lt;h2&gt;Security Model and Permission System&lt;/h2&gt;
&lt;p&gt;Wassette&apos;s permission system gives granular control over resource access, split across three resource categories:&lt;/p&gt;
&lt;h3&gt;File System Access Control&lt;/h3&gt;
&lt;p&gt;Storage permissions control access to file system resources through URI-based patterns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions:
  storage:
    allow:
      - uri: &quot;fs://workspace/**&quot;
        access: [&quot;read&quot;, &quot;write&quot;]
      - uri: &quot;fs://config/app.yaml&quot;
        access: [&quot;read&quot;]
    deny:
      - uri: &quot;fs://system/**&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The permission system supports glob patterns for flexible path matching while maintaining security boundaries. Components can request read-only or read-write access to specific paths, and permissions can be granted or revoked dynamically.&lt;/p&gt;
&lt;h3&gt;Network Access Management&lt;/h3&gt;
&lt;p&gt;Network permissions control outbound connections to specific hosts and protocols:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions:
  network:
    allow:
      - host: &quot;api.openai.com&quot;
        protocols: [&quot;https&quot;]
      - host: &quot;*.github.com&quot;
        protocols: [&quot;https&quot;]
    deny:
      - host: &quot;localhost&quot;
      - host: &quot;127.0.0.1&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach prevents tools from establishing unauthorized connections while enabling legitimate API access. The permission system can restrict access by hostname, IP address, port, and protocol.&lt;/p&gt;
&lt;h3&gt;Environment Variable Access&lt;/h3&gt;
&lt;p&gt;Environment variable permissions control access to system configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions:
  environment:
    allow:
      - key: &quot;API_KEY&quot;
      - key: &quot;USER_CONFIG_*&quot;
    deny:
      - key: &quot;SYSTEM_*&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Components must explicitly request access to environment variables, preventing unauthorized access to sensitive configuration data.&lt;/p&gt;
&lt;h2&gt;Practical Implementation Examples&lt;/h2&gt;
&lt;h3&gt;Building a Weather Component&lt;/h3&gt;
&lt;p&gt;Here&apos;s a practical example: a weather component that ties external API access to Wassette&apos;s permission system.&lt;/p&gt;
&lt;p&gt;First, define the component interface in WIT:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package weather:api;

world weather-server {
    export get-weather: func(location: string) -&amp;gt; result&amp;lt;weather-data, error-info&amp;gt;;
    export get-forecast: func(location: string, days: u32) -&amp;gt; result&amp;lt;forecast-data, error-info&amp;gt;;
}

record weather-data {
    location: string,
    temperature: f32,
    humidity: f32,
    description: string,
    timestamp: u64,
}

record forecast-data {
    location: string,
    days: list&amp;lt;daily-forecast&amp;gt;,
}

record daily-forecast {
    date: string,
    high-temp: f32,
    low-temp: f32,
    description: string,
}

record error-info {
    code: u32,
    message: string,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The implementation in Rust would look like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;use weather_api::*;

struct WeatherComponent;

impl Guest for WeatherComponent {
    fn get_weather(location: String) -&amp;gt; Result&amp;lt;WeatherData, ErrorInfo&amp;gt; {
        // Implementation requires network permission for weather API
        let api_key = std::env::var(&quot;WEATHER_API_KEY&quot;)
            .map_err(|_| ErrorInfo {
                code: 401,
                message: &quot;API key not configured&quot;.to_string(),
            })?;

        // Make HTTP request to weather service
        // This requires network permission for the weather API host
        let response = make_weather_request(&amp;amp;location, &amp;amp;api_key)?;

        Ok(WeatherData {
            location,
            temperature: response.temp,
            humidity: response.humidity,
            description: response.description,
            timestamp: current_timestamp(),
        })
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To use this component, you would need to grant appropriate permissions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Load the weather component
wassette load-component oci://ghcr.io/example/weather:latest

# Grant network permission for weather API
wassette grant-network-permission &amp;lt;component-id&amp;gt; api.openweathermap.org

# Grant environment variable access for API key
wassette grant-environment-variable-permission &amp;lt;component-id&amp;gt; WEATHER_API_KEY
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;File System Operations Component&lt;/h3&gt;
&lt;p&gt;Here&apos;s an example of a component that performs file system operations with appropriate permission controls:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;package filesystem:ops;

world filesystem-server {
    export read-file: func(path: string) -&amp;gt; result&amp;lt;string, error-info&amp;gt;;
    export write-file: func(path: string, content: string) -&amp;gt; result&amp;lt;unit, error-info&amp;gt;;
    export list-directory: func(path: string) -&amp;gt; result&amp;lt;list&amp;lt;string&amp;gt;, error-info&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The component would require explicit storage permissions:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Grant read access to workspace directory
wassette grant-storage-permission &amp;lt;component-id&amp;gt; fs://workspace/** read

# Grant write access to output directory
wassette grant-storage-permission &amp;lt;component-id&amp;gt; fs://output/** write
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/wassette-webassembly-mcp-runtime-a-visual-comparison-between-he-1764556914625.jpg&quot; alt=&quot;A visual comparison between heavy container-based isolation and the lightweight, high-performance nature of Wassette&apos;s WebAssembly approach.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Performance Characteristics and Optimization&lt;/h2&gt;
&lt;p&gt;Wassette&apos;s performance comes from modern WebAssembly runtimes plus Rust&apos;s zero-cost abstractions. The main characteristics:&lt;/p&gt;
&lt;h3&gt;Memory Efficiency&lt;/h3&gt;
&lt;p&gt;WebAssembly&apos;s linear memory model provides predictable memory usage patterns. Components operate within isolated memory spaces, preventing memory leaks from affecting other components or the host system. Memory overhead is significantly lower than container-based isolation, with typical components requiring only a few megabytes of memory.&lt;/p&gt;
&lt;h3&gt;Execution Performance&lt;/h3&gt;
&lt;p&gt;Wasmtime&apos;s ahead-of-time compilation and optimization pipeline delivers near-native performance for WebAssembly code. Benchmarks show that well-optimized WebAssembly components can achieve 80-95% of native performance for compute-intensive operations.&lt;/p&gt;
&lt;h3&gt;Startup Latency&lt;/h3&gt;
&lt;p&gt;Component instantiation is optimized for low latency, with typical startup times under 10 milliseconds for simple components. This enables responsive tool execution without the overhead associated with container startup or process spawning.&lt;/p&gt;
&lt;h3&gt;Scalability Characteristics&lt;/h3&gt;
&lt;p&gt;Wassette&apos;s architecture supports horizontal scaling through component isolation. Multiple instances of the same component can run concurrently without interference, and the permission system ensures that resource access remains controlled across all instances.&lt;/p&gt;
&lt;h2&gt;Integration with MCP Clients&lt;/h2&gt;
&lt;p&gt;Wassette works with existing MCP clients through a standards-compliant MCP server implementation. Setup varies by client but follows the same pattern:&lt;/p&gt;
&lt;h3&gt;Visual Studio Code Integration&lt;/h3&gt;
&lt;p&gt;For VS Code with GitHub Copilot:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install Wassette MCP server
code --add-mcp &apos;{&quot;name&quot;:&quot;Wassette&quot;,&quot;command&quot;:&quot;wassette&quot;,&quot;args&quot;:[&quot;serve&quot;,&quot;--stdio&quot;]}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Claude Desktop Integration&lt;/h3&gt;
&lt;p&gt;Add to Claude&apos;s MCP configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;mcpServers&quot;: {
    &quot;wassette&quot;: {
      &quot;command&quot;: &quot;wassette&quot;,
      &quot;args&quot;: [&quot;serve&quot;, &quot;--stdio&quot;]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Cursor Integration&lt;/h3&gt;
&lt;p&gt;Configure in Cursor&apos;s settings:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;mcp.servers&quot;: {
    &quot;wassette&quot;: {
      &quot;command&quot;: &quot;wassette serve --stdio&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Component Distribution and Registry&lt;/h2&gt;
&lt;p&gt;Wassette distributes components through OCI (Open Container Initiative) registries, a mechanism most developers already know. Components are packaged as OCI artifacts with cryptographic signatures for integrity verification.&lt;/p&gt;
&lt;h3&gt;Publishing Components&lt;/h3&gt;
&lt;p&gt;Components can be published to any OCI-compatible registry:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Build and publish a component
wasm-tools component new target/wasm32-wasi/release/weather.wasm -o weather.wasm
oras push ghcr.io/username/weather:latest weather.wasm
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Component Discovery&lt;/h3&gt;
&lt;p&gt;Wassette includes a component registry that catalogs available components:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Search for available components
wassette search-components

# Load a component from the registry
wassette load-component oci://ghcr.io/microsoft/time-server-js:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Development Workflow and Tooling&lt;/h2&gt;
&lt;p&gt;The workflow for building Wassette components is fairly simple:&lt;/p&gt;
&lt;h3&gt;Language Support&lt;/h3&gt;
&lt;p&gt;Wassette supports components written in any language that can compile to WebAssembly Components:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rust&lt;/strong&gt;: First-class support with &lt;code&gt;cargo component&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JavaScript/TypeScript&lt;/strong&gt;: Via &lt;code&gt;jco&lt;/code&gt; (JavaScript Component Tools)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python&lt;/strong&gt;: Via &lt;code&gt;componentize-py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go&lt;/strong&gt;: Via TinyGo with component model support&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C/C++&lt;/strong&gt;: Via Clang with WASI support&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Development Tools&lt;/h3&gt;
&lt;p&gt;Essential tools for component development:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install component development tools
cargo install cargo-component
npm install -g @bytecodealliance/jco
pip install componentize-py

# Create a new Rust component
cargo component new my-tool
cd my-tool
cargo component build
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Testing and Debugging&lt;/h3&gt;
&lt;p&gt;Wassette includes testing and debugging commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Test component locally
wassette test-component ./target/wasm32-wasi/release/my-tool.wasm

# Debug component execution
wassette debug-component &amp;lt;component-id&amp;gt; --verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Current Development Status and Roadmap&lt;/h2&gt;
&lt;p&gt;Wassette is actively developed by Microsoft, with regular releases and community contributions. Where things stand:&lt;/p&gt;
&lt;h3&gt;Current Status (v0.2.0)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Production Ready&lt;/strong&gt;: Stable MCP server implementation with comprehensive permission system&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-Language Support&lt;/strong&gt;: Components can be written in Rust, JavaScript, Python, and Go&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OCI Integration&lt;/strong&gt;: Full support for component distribution via OCI registries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Client Compatibility&lt;/strong&gt;: Works with all major MCP clients including VS Code, Claude, and Cursor&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Roadmap and Future Development&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Enhanced Security Features&lt;/strong&gt;: Advanced sandboxing capabilities, formal verification of permission policies, and integration with hardware security modules.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance Optimizations&lt;/strong&gt;: Improved component caching, lazy loading optimizations, and enhanced memory management.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Developer Experience&lt;/strong&gt;: Integrated development environment support, enhanced debugging tools, and automated component testing frameworks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ecosystem Expansion&lt;/strong&gt;: Broader language support, component composition tools, and marketplace integration for component discovery.&lt;/p&gt;
&lt;h2&gt;Comparative Analysis with Alternative Solutions&lt;/h2&gt;
&lt;p&gt;Wassette&apos;s approach differs significantly from other MCP deployment strategies:&lt;/p&gt;
&lt;h3&gt;Container-Based Isolation&lt;/h3&gt;
&lt;p&gt;Traditional container approaches provide process-level isolation but lack fine-grained permission controls. Containers also incur higher memory overhead and slower startup times compared to WebAssembly components.&lt;/p&gt;
&lt;h3&gt;Direct Binary Execution&lt;/h3&gt;
&lt;p&gt;Running MCP servers as native binaries offers maximum performance but provides no security boundaries. This approach is suitable for trusted environments but inappropriate for executing third-party tools.&lt;/p&gt;
&lt;h3&gt;Centralized WebAssembly Platforms&lt;/h3&gt;
&lt;p&gt;Some platforms run WebAssembly tools centrally but require custom ABIs and lack interoperability. Wassette&apos;s use of the Component Model ensures compatibility across different runtimes and tools.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Wassette&apos;s bet is straightforward: run untrusted AI tools inside a WebAssembly sandbox, expose them through MCP, and hand out system access one capability at a time. You get the isolation without giving up the flexibility that made MCP worth using.&lt;/p&gt;
&lt;p&gt;The language-agnostic Component Model is the part I find most useful. Existing tools can be recompiled to run in the sandbox, and new ones can treat least privilege as the default rather than something bolted on later.&lt;/p&gt;
&lt;p&gt;If you&apos;re running third-party MCP tools with full host access today, Wassette is worth a look. That default is the thing worth fixing, and this is a credible way to fix it.&lt;/p&gt;
</content:encoded><category>webassembly</category><category>mcp</category><category>security</category><category>ai</category><category>rust</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/wassette-webassembly-mcp-runtime-a-visual-metaphor-for-wassette-featured-1764556852449.jpg" length="0" type="image/jpeg"/></item><item><title>ActivityPub MCP Server: Bridging AI and the Fediverse</title><link>https://rye.dev/blog/activitypub-mcp-fediverse-integration/</link><guid isPermaLink="true">https://rye.dev/blog/activitypub-mcp-fediverse-integration/</guid><description>How I taught Claude to explore the Fediverse: an MCP server that discovers actors, fetches timelines, and queries Mastodon-compatible instances.</description><pubDate>Fri, 02 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/activitypub-mcp-fediverse-integration-a-visual-metaphor-illustrating-featured-1764560207218.jpg&quot; alt=&quot;ActivityPub MCP Server: Bridging AI and the Fediverse&quot; /&gt;&lt;/p&gt;&lt;p&gt;I built the &lt;strong&gt;ActivityPub MCP Server&lt;/strong&gt;, a Model Context Protocol implementation that lets LLMs like Claude explore and interact with the Fediverse over ActivityPub.&lt;/p&gt;
&lt;p&gt;Until now there wasn&apos;t a good way for an AI assistant to discover, analyze, and interact with the Fediverse: Mastodon, Pleroma, Misskey, and the other ActivityPub-compatible platforms. This fills that gap.&lt;/p&gt;
&lt;h2&gt;The Challenge: AI Meets Decentralized Social Networks&lt;/h2&gt;
&lt;p&gt;The Fediverse runs on open protocols, primarily ActivityPub, instead of a single company&apos;s servers. People on different servers and platforms can talk to each other while keeping control of their data and identity.&lt;/p&gt;
&lt;p&gt;That decentralization creates a few problems for an AI system:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: finding relevant actors across thousands of independent instances&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Protocol differences&lt;/strong&gt;: implementations vary in subtle ways&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data access&lt;/strong&gt;: retrieving distributed social data without hammering servers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt;: talking to untrusted remote servers safely&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The server hides that plumbing behind a single interface without giving up what the underlying protocols can do.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/activitypub-mcp-fediverse-integration-an-abstract-architectural-diag-1764560225193.jpg&quot; alt=&quot;An abstract architectural diagram showing the flow of data from the AI, through the MCP server, down to the underlying protocol layer.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Architecture and Design Philosophy&lt;/h2&gt;
&lt;h3&gt;Model Context Protocol Integration&lt;/h3&gt;
&lt;p&gt;The server implements the complete MCP specification, providing three primary interaction modes:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Resources&lt;/strong&gt;: Read-only access to Fediverse data with URI-based addressing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;activitypub://remote-actor/{identifier}
activitypub://remote-timeline/{identifier}
activitypub://instance-info/{domain}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Tools&lt;/strong&gt;: Interactive capabilities for discovery and exploration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;discover-actor&lt;/code&gt;: Find and analyze any Fediverse user&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetch-timeline&lt;/code&gt;: Retrieve posts from any public timeline&lt;/li&gt;
&lt;li&gt;&lt;code&gt;get-instance-info&lt;/code&gt;: Analyze server capabilities and statistics&lt;/li&gt;
&lt;li&gt;&lt;code&gt;search-instance&lt;/code&gt;: Query content across instances&lt;/li&gt;
&lt;li&gt;&lt;code&gt;discover-instances&lt;/code&gt;: Find servers by topic or category&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Prompts&lt;/strong&gt;: Template-driven exploration patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;explore-fediverse&lt;/code&gt;: Guided discovery based on interests&lt;/li&gt;
&lt;li&gt;&lt;code&gt;compare-instances&lt;/code&gt;: Analytical comparison of server communities&lt;/li&gt;
&lt;li&gt;&lt;code&gt;discover-content&lt;/code&gt;: Topic-based content exploration&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;WebFinger Discovery Implementation&lt;/h3&gt;
&lt;p&gt;A WebFinger client handles actor discovery. It runs the resolution that turns a human-readable identifier like &lt;code&gt;user@mastodon.social&lt;/code&gt; into an ActivityPub endpoint the code can actually call.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Simplified WebFinger resolution flow
const actorInfo = await webfingerClient.resolve(&apos;user@mastodon.social&apos;);
const actorData = await activityPubClient.fetchActor(actorInfo.actorUrl);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client deals with the cross-domain details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTTPS endpoint resolution with fallback mechanisms&lt;/li&gt;
&lt;li&gt;CORS handling for browser-based implementations&lt;/li&gt;
&lt;li&gt;Rate limiting and respectful server interaction&lt;/li&gt;
&lt;li&gt;Error handling for unreachable or misconfigured instances&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Technical Implementation Highlights&lt;/h2&gt;
&lt;h3&gt;Performance Optimization Strategies&lt;/h3&gt;
&lt;p&gt;A few things keep it responsive across a distributed network:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Caching&lt;/strong&gt;: multiple cache layers that honor ActivityPub cache headers and cut down on repeat network requests:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface CacheStrategy {
  actorCache: LRUCache&amp;lt;string, Actor&amp;gt;;
  timelineCache: LRUCache&amp;lt;string, OrderedCollection&amp;gt;;
  instanceCache: LRUCache&amp;lt;string, InstanceInfo&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Concurrent requests&lt;/strong&gt;: independent requests run in parallel, with related ones batched together:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const [actorInfo, timeline, followers] = await Promise.all([
  fetchActor(identifier),
  fetchTimeline(identifier),
  fetchFollowers(identifier)
]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Resource management&lt;/strong&gt;: connection pooling and careful memory use so high-volume operations don&apos;t fall over.&lt;/p&gt;
&lt;h3&gt;Security and Privacy Considerations&lt;/h3&gt;
&lt;p&gt;Security and privacy come from a few layers:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Input validation&lt;/strong&gt;: every user input and every piece of remote data gets validated before use, which blocks injection and malformed payloads.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt;: configurable limits that back off based on what a server advertises, so I&apos;m not hammering anyone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sanitization&lt;/strong&gt;: content pulled from remote servers is sanitized to prevent XSS and other content-based attacks.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read-only by design&lt;/strong&gt;: the server never stores personal data and never holds a persistent connection to a user account.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/activitypub-mcp-fediverse-integration-a-visualization-of-the-discove-1764560242023.jpg&quot; alt=&quot;A visualization of the discovery process, representing how the server finds specific actors or content within the massive decentralized network.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Practical Applications and Use Cases&lt;/h2&gt;
&lt;h3&gt;Content Discovery and Analysis&lt;/h3&gt;
&lt;p&gt;This enables content discovery patterns you can&apos;t really do on a centralized platform:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Discover technology-focused instances
const techInstances = await mcpClient.callTool(&apos;discover-instances&apos;, {
  topic: &apos;technology&apos;,
  category: &apos;mastodon&apos;,
  size: &apos;medium&apos;
});

// Analyze community engagement patterns
for (const instance of techInstances) {
  const info = await mcpClient.callTool(&apos;get-instance-info&apos;, {
    domain: instance.domain
  });
  console.log(`${instance.domain}: ${info.stats.user_count} users`);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Cross-Platform Social Research&lt;/h3&gt;
&lt;p&gt;Researchers and analysts can use the server to study how communities behave across the decentralized web:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Community analysis&lt;/strong&gt;: compare engagement across instances&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content propagation&lt;/strong&gt;: track how a post spreads through the Fediverse&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Platform diversity&lt;/strong&gt;: compare the technical and social differences between ActivityPub implementations&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;AI-Powered Social Discovery&lt;/h3&gt;
&lt;p&gt;Hooking it up to an LLM means discovery can adapt to what someone&apos;s actually interested in:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// AI-guided instance recommendation
const recommendations = await mcpClient.callTool(&apos;recommend-instances&apos;, {
  interests: [&apos;open source&apos;, &apos;privacy&apos;, &apos;decentralization&apos;]
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Installation and Integration&lt;/h2&gt;
&lt;p&gt;The server can be installed a few different ways:&lt;/p&gt;
&lt;h3&gt;Direct Installation&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Install globally for system-wide access
npm install -g activitypub-mcp

# Or use npx for one-time execution
npx activitypub-mcp install
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Claude Desktop Integration&lt;/h3&gt;
&lt;p&gt;To use it with Claude Desktop, add this configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;mcpServers&quot;: {
    &quot;activitypub&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;activitypub-mcp&quot;]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Development Integration&lt;/h3&gt;
&lt;p&gt;The server can be integrated into custom applications through the MCP SDK:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { MCPClient } from &apos;@modelcontextprotocol/sdk&apos;;

const client = new MCPClient({
  serverPath: &apos;activitypub-mcp&apos;
});

await client.connect();
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Future Developments and Roadmap&lt;/h2&gt;
&lt;p&gt;There&apos;s more I want to do here. Planned work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;More protocols&lt;/strong&gt;: support beyond ActivityPub, like AT Protocol and Nostr.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Analytics&lt;/strong&gt;: better tools for understanding Fediverse dynamics and community structure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Posting&lt;/strong&gt;: secure, user-controlled posting and interaction for AI assistants.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Federation insights&lt;/strong&gt;: tools for gauging the health and connectivity of the wider network.&lt;/p&gt;
&lt;h2&gt;Wrap-Up&lt;/h2&gt;
&lt;p&gt;The server sits between two things I care about: AI assistants and decentralized social networks. Giving an LLM a standard way into the Fediverse opens up social discovery and content analysis without giving up user privacy or platform diversity.&lt;/p&gt;
&lt;p&gt;Open protocols and a shared interface are what make this possible. As the Fediverse keeps growing, I think tools like this will matter more for making sense of it.&lt;/p&gt;
&lt;p&gt;The source is on &lt;a href=&quot;https://github.com/cameronrye/activitypub-mcp&quot;&gt;GitHub&lt;/a&gt;, with docs at &lt;a href=&quot;https://cameronrye.github.io/activitypub-mcp/&quot;&gt;cameronrye.github.io/activitypub-mcp&lt;/a&gt;. Contributions and feedback are welcome.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;The ActivityPub MCP Server is open source under the MIT License. Contributions and feedback are welcome.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>mcp</category><category>ai</category><category>typescript</category><category>decentralization</category><category>protocols</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/activitypub-mcp-fediverse-integration-a-visual-metaphor-illustrating-featured-1764560207218.jpg" length="0" type="image/jpeg"/></item><item><title>Project: ActivityPub MCP Server</title><link>https://rye.dev/projects/activitypub-mcp/</link><guid isPermaLink="true">https://rye.dev/projects/activitypub-mcp/</guid><description>Lets AI assistants explore the Fediverse: WebFinger discovery, actor profiles, and timelines across Mastodon and any ActivityPub-compatible server.</description><pubDate>Fri, 02 May 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/activitypub-mcp-detail-light.webp&quot; alt=&quot;ActivityPub MCP Server screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import MCPToolDemo from &apos;../../components/demos/MCPToolDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;export const activityPubTools = [
{
name: &apos;webfinger&apos;,
description: &apos;Discover a user via WebFinger lookup&apos;,
request: { tool: &apos;webfinger_lookup&apos;, account: &apos;@user@mastodon.social&apos; },
response: { subject: &apos;acct:user@mastodon.social&apos;, links: [{ rel: &apos;self&apos;, type: &apos;application/activity+json&apos;, href: &apos;https://mastodon.social/users/user&apos; }] }
},
{
name: &apos;get_actor&apos;,
description: &apos;Fetch an ActivityPub actor profile&apos;,
request: { tool: &apos;get_actor&apos;, uri: &apos;https://mastodon.social/users/user&apos; },
response: { type: &apos;Person&apos;, name: &apos;Example User&apos;, preferredUsername: &apos;user&apos;, followers: 1250, following: 340 }
},
{
name: &apos;get_outbox&apos;,
description: &apos;Get recent posts from an actor&apos;,
request: { tool: &apos;get_outbox&apos;, actor: &apos;https://mastodon.social/users/user&apos;, limit: 3 },
response: { items: [{ type: &apos;Note&apos;, content: &apos;Hello Fediverse!&apos;, published: &apos;2024-01-20T15:30:00Z&apos; }] }
}
];&lt;/p&gt;
&lt;p&gt;The ActivityPub MCP Server connects AI assistants to the Fediverse, the decentralized social network that includes Mastodon, Pixelfed, and thousands of other instances. It&apos;s built on Fedify, which handles the ActivityPub protocol.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;MCPToolDemo client:visible serverName=&quot;activitypub-mcp&quot; tools={activityPubTools} /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fediverse Access&lt;/strong&gt;: Connect to any ActivityPub-compatible server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WebFinger Support&lt;/strong&gt;: Discover users across federated instances&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP Tools &amp;amp; Prompts&lt;/strong&gt;: Standardized interface for AI interaction&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fedify Integration&lt;/strong&gt;: Built on the Fedify framework for reliability&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The engineering challenge&lt;/h2&gt;
&lt;p&gt;ActivityPub is valuable precisely because it is decentralized, but that decentralization creates the core challenge for AI integration. There is no single canonical API surface, implementations vary across servers, and even simple tasks like resolving an account handle require protocol-aware discovery through WebFinger.&lt;/p&gt;
&lt;p&gt;The goal of this server was to hide the friction without flattening away the protocol. An assistant should be able to explore the Fediverse as a network of actors, inboxes, outboxes, and objects, not as a brittle collection of server-specific REST calls.&lt;/p&gt;
&lt;h2&gt;Why Fedify was the right foundation&lt;/h2&gt;
&lt;p&gt;I built the server on Fedify because correctness mattered more than speed of a one-off integration. Fedify gave me a durable protocol layer for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;WebFinger resolution&lt;/li&gt;
&lt;li&gt;ActivityPub actor and object handling&lt;/li&gt;
&lt;li&gt;cross-instance compatibility&lt;/li&gt;
&lt;li&gt;a cleaner abstraction over the differences between Fediverse implementations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That choice let the MCP layer focus on tool design and response shaping instead of re-implementing protocol details from scratch.&lt;/p&gt;
&lt;h2&gt;Design tradeoffs&lt;/h2&gt;
&lt;p&gt;There is a tension between exposing a rich social graph and keeping tool calls understandable for an LLM. I addressed that by making the tools explicit and composable: discover an account, fetch the actor, inspect the outbox, then traverse outward. That produces a better agent experience than trying to hide everything behind one overloaded endpoint.&lt;/p&gt;
&lt;p&gt;The other tradeoff was resiliency. In a federated network, partial failure is normal. Different instances may be slow, unavailable, or slightly inconsistent. The server therefore had to prefer graceful degradation and clear error reporting over pretending every node behaves the same way.&lt;/p&gt;
&lt;h2&gt;Outcome&lt;/h2&gt;
&lt;p&gt;The hardest constraint turned out to be partial failure. In a federated network some instances are always slow, unreachable, or slightly nonstandard, so the server treats graceful degradation and clear error reporting as core features rather than edge cases. An assistant exploring the Fediverse gets an honest answer about a dead instance instead of a hung request.&lt;/p&gt;
</content:encoded><category>Astro</category><category>MCP</category><category>WebFinger</category><category>ActivityPub</category><category>Fediverse</category><category>Fedify</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/activitypub-mcp-detail-light.webp" length="0" type="image/webp"/></item><item><title>Building an Electromagnetic Spectrum Explorer</title><link>https://rye.dev/blog/electromagnetic-spectrum-explorer/</link><guid isPermaLink="true">https://rye.dev/blog/electromagnetic-spectrum-explorer/</guid><description>Femtometers to kilometers on one screen: how I built an interactive spectrum explorer with React, D3, and NIST-exact constants behind every value.</description><pubDate>Fri, 14 Mar 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/electromagnetic-spectrum-explorer-a-visual-synthesis-of-physics--featured-1764556962967.jpg&quot; alt=&quot;Building an Electromagnetic Spectrum Explorer&quot; /&gt;&lt;/p&gt;&lt;p&gt;I built an interactive explorer for the electromagnetic spectrum. The web stack is ordinary: React and D3. The hard part was fitting a range that runs from femtometers to kilometers onto a single screen without lying about the scale or the physics behind it.&lt;/p&gt;
&lt;h2&gt;The Educational Challenge of Electromagnetic Radiation&lt;/h2&gt;
&lt;p&gt;The spectrum is a first-year physics staple, and it&apos;s still hard to picture. Wavelengths run from femtometers to kilometers; frequencies run from kilohertz to zettahertz. A textbook diagram flattens all of that into a handful of evenly spaced bands, which hides the logarithmic relationships and says nothing about where any of it turns up in daily life.&lt;/p&gt;
&lt;p&gt;So the interface has to stay accurate while still being easy to poke at. Someone using it should walk away with the math linking wavelength, frequency, and energy, plus a feel for where those numbers land in practice, from medical imaging to radio communications.&lt;/p&gt;
&lt;h3&gt;Scientific Accuracy Requirements&lt;/h3&gt;
&lt;p&gt;A teaching tool that gets the numbers wrong is worse than none. I used the exact constants NIST publishes and carried their full precision through every calculation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const PHYSICS_CONSTANTS = {
  SPEED_OF_LIGHT: 299792458, // m/s (exact)
  PLANCK_CONSTANT: 6.62607015e-34, // J⋅s (exact)
  PLANCK_CONSTANT_EV: 4.135667696e-15, // eV⋅s (exact)
  ELECTRON_VOLT: 1.602176634e-19, // J (exact NIST 2018 value)
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every other value in the app is derived from these, so getting them right once keeps the rest of it honest.&lt;/p&gt;
&lt;h2&gt;Architecture Patterns for Scientific Visualization&lt;/h2&gt;
&lt;h3&gt;Physics Calculation Engine&lt;/h3&gt;
&lt;p&gt;Every value on screen is derived from a wavelength, which puts the conversion functions at the center of the whole thing. They have to cover the full range without choking on bad input:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function wavelengthToFrequency(wavelength) {
  if (!isFinite(wavelength) || wavelength &amp;lt;= 0) return NaN;
  return SPEED_OF_LIGHT / wavelength;
}

export function wavelengthToEnergyEV(wavelength) {
  if (!isFinite(wavelength) || wavelength &amp;lt;= 0) return NaN;
  return (PLANCK_CONSTANT_EV * SPEED_OF_LIGHT) / wavelength;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the guard on the first line of each. A zero or negative wavelength is meaningless, and worse, it produces a plausible-looking wrong number once it flows through a chain of conversions. Returning NaN early kills it at the source.&lt;/p&gt;
&lt;h3&gt;Logarithmic Scale Visualization&lt;/h3&gt;
&lt;p&gt;Electromagnetic spectrum visualization requires logarithmic scaling to represent the enormous range of wavelengths and frequencies. The implementation uses D3.js scaling functions combined with custom positioning algorithms:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function getLogPosition(value, min, max) {
  if (value &amp;lt;= 0 || min &amp;lt;= 0 || max &amp;lt;= 0) return 0;
  const logValue = Math.log10(value);
  const logMin = Math.log10(min);
  const logMax = Math.log10(max);
  return (logValue - logMin) / (logMax - logMin);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That normalizes any value to a position between 0 and 1, which is what lets a single bar hold 20+ orders of magnitude, from gamma rays in femtometers to radio waves in kilometers.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/electromagnetic-spectrum-explorer-a-stylized-representation-of-t-1764556980625.jpg&quot; alt=&quot;A stylized representation of the D3.js logarithmic spectrum bar, illustrating the core visualization challenge discussed in the text.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Data Architecture for Spectrum Regions&lt;/h2&gt;
&lt;h3&gt;Structured Spectrum Data&lt;/h3&gt;
&lt;p&gt;Each region of the spectrum is a plain object that carries its own boundaries, a color, and the human-readable content that goes with it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const SPECTRUM_REGIONS = [
  {
    id: &apos;gamma&apos;,
    name: &apos;Gamma Rays&apos;,
    color: &apos;#B19CD9&apos;,
    wavelengthMin: 1e-15, // 1 fm
    wavelengthMax: 10e-12, // 10 pm
    frequencyMin: 3e19, // 30 EHz
    frequencyMax: 3e23, // 300 ZHz
    energyMin: 124000, // eV (124 keV)
    energyMax: 1e12, // eV (1 TeV)
    description: &apos;Gamma rays are the most energetic form of electromagnetic radiation.&apos;,
    applications: [
      &apos;Cancer treatment (radiotherapy)&apos;,
      &apos;Medical imaging (PET scans)&apos;,
      &apos;Nuclear medicine&apos;
    ],
    examples: [
      &apos;Cobalt-60 therapy: 1.17 and 1.33 MeV&apos;,
      &apos;PET scan tracers: 511 keV&apos;
    ]
  }
  // ... additional regions
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keeping the numeric bounds and the descriptive text in one record means a lookup by wavelength hands back the region and everything I need to render its panel. No second join, no separate content file drifting out of sync.&lt;/p&gt;
&lt;h3&gt;Region Detection Algorithms&lt;/h3&gt;
&lt;p&gt;Finding the region for a given wavelength is a linear scan behind the same input guard as before:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function getRegionByWavelength(wavelength) {
  if (!isFinite(wavelength) || wavelength &amp;lt;= 0) {
    return null;
  }

  return SPECTRUM_REGIONS.find(region =&amp;gt;
    wavelength &amp;gt;= region.wavelengthMin &amp;amp;&amp;amp; wavelength &amp;lt;= region.wavelengthMax
  ) || null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There are only a handful of regions, so a linear find is plenty. I&apos;d rather keep it obvious than shave microseconds off a loop that runs once per click.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/electromagnetic-spectrum-explorer-a-conceptual-diagram-of-the-re-1764556996246.jpg&quot; alt=&quot;A conceptual diagram of the real-time unit conversion logic, showing how changing one input updates the others.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Interactive Conversion Interface Design&lt;/h2&gt;
&lt;h3&gt;Real-time Unit Conversion&lt;/h3&gt;
&lt;p&gt;The conversion panel has three inputs (wavelength, frequency, energy) that all describe the same photon. Change one and the other two should follow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function SimpleConversionPanel({ selectedWavelength, onWavelengthChange }) {
  const [wavelengthInput, setWavelengthInput] = useState(&apos;&apos;);
  const [frequencyInput, setFrequencyInput] = useState(&apos;&apos;);
  const [energyInput, setEnergyInput] = useState(&apos;&apos;);

  const handleWavelengthChange = (value) =&amp;gt; {
    const wavelength = parseWavelength(value);
    if (!isNaN(wavelength) &amp;amp;&amp;amp; wavelength &amp;gt; 0) {
      onWavelengthChange(wavelength);
    }
  };

  // Similar handlers for frequency and energy...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trick is keeping the three in sync without setting off an update loop: wavelength updates frequency, which updates energy, which updates wavelength again. Treating the selected wavelength as the single source of truth and deriving the rest from it avoids that.&lt;/p&gt;
&lt;h3&gt;Input Parsing and Validation&lt;/h3&gt;
&lt;p&gt;People type units every way you can imagine, so the parser has to handle unit suffixes and scientific notation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function parseWavelength(input) {
  const value = safeParseFloat(input);
  if (isNaN(value)) return NaN;

  const unit = input.toLowerCase().replace(/[0-9.\-+e\s]/g, &apos;&apos;);

  switch (unit) {
    case &apos;nm&apos;: return value * 1e-9;
    case &apos;μm&apos;: case &apos;um&apos;: return value * 1e-6;
    case &apos;mm&apos;: return value * 1e-3;
    case &apos;cm&apos;: return value * 1e-2;
    case &apos;m&apos;: return value;
    case &apos;km&apos;: return value * 1e3;
    default: return value; // assume meters if no unit
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It accepts the common spellings, including both &lt;code&gt;μm&lt;/code&gt; and &lt;code&gt;um&lt;/code&gt;, and falls back to meters when there&apos;s no unit rather than rejecting the input outright.&lt;/p&gt;
&lt;h2&gt;Educational Interface Patterns&lt;/h2&gt;
&lt;h3&gt;Progressive Disclosure&lt;/h3&gt;
&lt;p&gt;The educational panel implements progressive disclosure patterns that reveal information based on user interaction and current context:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function SimpleEducationalPanel({ selectedWavelength }) {
  const region = getRegionByWavelength(selectedWavelength);

  if (!region) {
    return &amp;lt;div&amp;gt;Select a wavelength to explore its properties&amp;lt;/div&amp;gt;;
  }

  return (
    &amp;lt;div className=&quot;educational-panel&quot;&amp;gt;
      &amp;lt;h3&amp;gt;{region.name}&amp;lt;/h3&amp;gt;
      &amp;lt;p&amp;gt;{region.description}&amp;lt;/p&amp;gt;

      &amp;lt;div className=&quot;applications&quot;&amp;gt;
        &amp;lt;h4&amp;gt;Applications:&amp;lt;/h4&amp;gt;
        &amp;lt;ul&amp;gt;
          {region.applications.map((app, index) =&amp;gt; (
            &amp;lt;li key={index}&amp;gt;{app}&amp;lt;/li&amp;gt;
          ))}
        &amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;examples&quot;&amp;gt;
        &amp;lt;h4&amp;gt;Real-world Examples:&amp;lt;/h4&amp;gt;
        &amp;lt;ul&amp;gt;
          {region.examples.map((example, index) =&amp;gt; (
            &amp;lt;li key={index}&amp;gt;{example}&amp;lt;/li&amp;gt;
          ))}
        &amp;lt;/ul&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The panel only shows what&apos;s relevant to the current selection. Pick a wavelength and you get that region&apos;s description, applications, and examples. Pick nothing and you get a short prompt instead of an empty box.&lt;/p&gt;
&lt;h3&gt;Visual Feedback Systems&lt;/h3&gt;
&lt;p&gt;The spectrum visualization provides immediate visual feedback through color coding, positioning, and scale indicators:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function SimpleSpectrum({ selectedWavelength, onWavelengthChange }) {
  const position = getLogPosition(selectedWavelength, 1e-15, 1e4);
  const region = getRegionByWavelength(selectedWavelength);

  return (
    &amp;lt;div className=&quot;spectrum-container&quot;&amp;gt;
      &amp;lt;svg width=&quot;100%&quot; height=&quot;100&quot;&amp;gt;
        {SPECTRUM_REGIONS.map(region =&amp;gt; (
          &amp;lt;rect
            key={region.id}
            x={getLogPosition(region.wavelengthMin, 1e-15, 1e4) * 100 + &apos;%&apos;}
            width={(getLogPosition(region.wavelengthMax, 1e-15, 1e4) -
                   getLogPosition(region.wavelengthMin, 1e-15, 1e4)) * 100 + &apos;%&apos;}
            height=&quot;100%&quot;
            fill={region.color}
            onClick={() =&amp;gt; onWavelengthChange(
              (region.wavelengthMin + region.wavelengthMax) / 2
            )}
          /&amp;gt;
        ))}

        &amp;lt;line
          x1={position * 100 + &apos;%&apos;}
          x2={position * 100 + &apos;%&apos;}
          y1=&quot;0&quot;
          y2=&quot;100%&quot;
          stroke=&quot;black&quot;
          strokeWidth=&quot;2&quot;
        /&amp;gt;
      &amp;lt;/svg&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each region is a colored rectangle placed by its log position, with a vertical line marking the current selection. Clicking a region jumps to its midpoint, which is enough for browsing; the conversion panel is there when you need an exact value.&lt;/p&gt;
&lt;h2&gt;Testing Strategies for Scientific Applications&lt;/h2&gt;
&lt;h3&gt;Physics Calculation Validation&lt;/h3&gt;
&lt;p&gt;Tests here check two things at once: that the code runs and that the answer matches the physics. I pin a few known conversions and assert against them within a tolerance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function testPhysicsCalculations() {
  const tests = [
    {
      name: &apos;Visible light wavelength to frequency&apos;,
      wavelength: 550e-9, // Green light
      expectedFrequency: 5.45e14, // Hz
      tolerance: 1e12
    },
    {
      name: &apos;X-ray energy calculation&apos;,
      wavelength: 1e-10, // 0.1 nm
      expectedEnergy: 12400, // eV
      tolerance: 100
    }
  ];

  tests.forEach(test =&amp;gt; {
    const frequency = wavelengthToFrequency(test.wavelength);
    const energy = wavelengthToEnergyEV(test.wavelength);

    assert(
      Math.abs(frequency - test.expectedFrequency) &amp;lt; test.tolerance,
      `Frequency calculation failed for ${test.name}`
    );
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Green light at 550 nm should come out near 5.45e14 Hz, and a 0.1 nm X-ray should land around 12400 eV. If either drifts, something in the constants or the math broke, and the test says so before a user ever sees it.&lt;/p&gt;
&lt;h2&gt;Performance Optimization for Large-Scale Data&lt;/h2&gt;
&lt;h3&gt;Efficient Range Calculations&lt;/h3&gt;
&lt;p&gt;Showing a wavelength means picking a unit so the number reads cleanly instead of as a wall of exponent. The formatter switches units by magnitude:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export function formatWavelength(wavelength) {
  if (!isFinite(wavelength) || wavelength &amp;lt;= 0) {
    return &apos;Invalid wavelength&apos;;
  }

  if (wavelength &amp;gt;= 1e-3) {
    return wavelength &amp;gt;= 1 ?
      `${wavelength.toExponential(2)} m` :
      `${(wavelength * 1000).toFixed(2)} mm`;
  } else if (wavelength &amp;gt;= 1e-6) {
    return `${(wavelength * 1e6).toFixed(2)} μm`;
  } else if (wavelength &amp;gt;= 1e-9) {
    return `${(wavelength * 1e9).toFixed(2)} nm`;
  } else {
    return `${wavelength.toExponential(2)} m`;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Millimeter-scale values read in mm, micron-scale in μm, and nanometer-scale in nm, with exponential notation held back for the extremes.&lt;/p&gt;
&lt;h2&gt;Deployment and Distribution Patterns&lt;/h2&gt;
&lt;h3&gt;Automated GitHub Pages Deployment&lt;/h3&gt;
&lt;p&gt;Deployment is a GitHub Actions workflow that builds and pushes to GitHub Pages on every commit to main:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name: Deploy to GitHub Pages
on:
  push:
    branches: [ main ]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: &apos;18&apos;
      - name: Install dependencies
        run: npm install
      - name: Build
        run: npm run build
      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Push to main and the live site updates itself. Nothing to babysit.&lt;/p&gt;
&lt;h2&gt;Future Directions in Scientific Web Applications&lt;/h2&gt;
&lt;p&gt;A few things I&apos;d like to add when I get back to it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Wiring in a physics engine so the visuals can move instead of sitting still.&lt;/li&gt;
&lt;li&gt;Shared sessions, so two people can explore and annotate the same view.&lt;/li&gt;
&lt;li&gt;Content that adapts to what a learner has already looked at.&lt;/li&gt;
&lt;li&gt;Real parity on mobile and VR, not a shrunk-down desktop layout.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of the groundwork here is exotic. The value was in the discipline: exact constants, log scaling done once and reused, tests that assert physics and not just code paths. That combination carries over to any tool where the numbers have to be right.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;Want to explore the electromagnetic spectrum yourself? The application is available at &lt;a href=&quot;https://cameronrye.github.io/electromagnetic-spectrum-explorer/&quot;&gt;cameronrye.github.io/electromagnetic-spectrum-explorer&lt;/a&gt;, and the complete source code can be found at &lt;a href=&quot;https://github.com/cameronrye/electromagnetic-spectrum-explorer&quot;&gt;github.com/cameronrye/electromagnetic-spectrum-explorer&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That&apos;s the whole thing. The math is exact, the layout is honest about scale, and it all runs in the browser.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;The full source, including everything shown here, is on &lt;a href=&quot;https://github.com/cameronrye/electromagnetic-spectrum-explorer&quot;&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>react</category><category>javascript</category><category>visualization</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/electromagnetic-spectrum-explorer-a-visual-synthesis-of-physics--featured-1764556962967.jpg" length="0" type="image/jpeg"/></item><item><title>OpenZIM MCP Server: Offline Knowledge for AI Assistants</title><link>https://rye.dev/blog/openzim-mcp-server/</link><guid isPermaLink="true">https://rye.dev/blog/openzim-mcp-server/</guid><description>I built openzim-mcp so AI assistants can search Wikipedia with zero connectivity. ZIM archives pack an entire encyclopedia into one indexed file.</description><pubDate>Tue, 28 Jan 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/openzim-mcp-server-a-visual-metaphor-for-a-self-s-featured-1764557829239.jpg&quot; alt=&quot;OpenZIM MCP Server: Offline Knowledge for AI Assistants&quot; /&gt;&lt;/p&gt;&lt;p&gt;Most AI assistants stop being useful the moment they lose internet access. That&apos;s a single point of failure, and it shows up in the places where reliability matters most: edge deployments, air-gapped networks, remote sites, anywhere the connection is intermittent or expensive. Offline knowledge access fixes that by letting an assistant keep working regardless of the network.&lt;/p&gt;
&lt;h2&gt;Connectivity Dependency Analysis&lt;/h2&gt;
&lt;p&gt;Assuming the network is always there creates real weak points, especially where reliability can&apos;t be guaranteed. The cases that come up most:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Aircraft and ships, where connectivity is intermittent, expensive, or restricted by regulation&lt;/li&gt;
&lt;li&gt;Remote research stations, field operations, and regions with little infrastructure&lt;/li&gt;
&lt;li&gt;Air-gapped networks that block external connectivity for compliance or security&lt;/li&gt;
&lt;li&gt;Places where data costs put information out of reach, like many developing markets&lt;/li&gt;
&lt;li&gt;Systems that simply have to run without external dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The upside is that a lot of high-value knowledge (encyclopedias, educational material, technical docs) packs down well for offline use, given the right compression and indexing.&lt;/p&gt;
&lt;h2&gt;OpenZIM Architecture and Compression Technology&lt;/h2&gt;
&lt;p&gt;The OpenZIM format came out of the Kiwix project, which needed to get educational content to people on slow or nonexistent connections. ZIM files pair heavy compression with a built-in index, so they stay small without giving up fast lookups.&lt;/p&gt;
&lt;p&gt;That&apos;s how the entire English Wikipedia (articles, metadata, cross-references) fits into a single portable archive you can ship on physical media or pull down over a thin connection.&lt;/p&gt;
&lt;h3&gt;ZIM Format Technical Advantages&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Compression&lt;/strong&gt;: content-aware algorithms tuned for text push ratios past 10:1&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Random access&lt;/strong&gt;: B-tree indexing gives O(log n) article retrieval without decompressing the whole archive&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metadata&lt;/strong&gt;: full-text search indices, category hierarchies, and cross-reference graphs travel with the content&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-platform&lt;/strong&gt;: one standardized binary format behaves the same on any OS or hardware&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Model Context Protocol Integration Strategy&lt;/h2&gt;
&lt;p&gt;The Model Context Protocol gives AI systems one consistent interface for talking to outside resources, which turns out to be handy for offline knowledge. Instead of writing a bespoke integration for every data source, you expose it through MCP once and any compatible client can use it.&lt;/p&gt;
&lt;p&gt;Here that means an assistant can reach a full knowledge base (encyclopedias, courses, technical docs) with no network at all, and keep working wherever it&apos;s deployed.&lt;/p&gt;
&lt;h2&gt;Building the OpenZIM MCP Server&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/openzim-mcp-server-illustrates-the-technical-conc-1764557846591.jpg&quot; alt=&quot;Illustrates the technical concept of efficient indexing and searching within a compressed archive without full decompression.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Performance Engineering Challenges&lt;/h3&gt;
&lt;p&gt;The hard part is searching a compressed archive of millions of documents and still answering in under a second. It&apos;s a classic systems tradeoff: storage efficiency against query speed, inside a memory budget small enough for edge hardware.&lt;/p&gt;
&lt;p&gt;The ZIM format solves the indexing half of this problem itself: archives ship with an embedded Xapian full-text index built at creation time, providing fast content location while preserving the storage benefits of compression. The server&apos;s job is to expose that index safely through libzim&apos;s Python bindings, adding validation, pagination, and caching on top. Condensed from &lt;code&gt;openzim_mcp/zim/search.py&lt;/code&gt;, the core search path looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from libzim.reader import Archive
from libzim.search import Query, Searcher

class _SearchMixin:
    &quot;&quot;&quot;Full-text search over a ZIM archive&apos;s embedded index.&quot;&quot;&quot;

    def _perform_search(
        self, archive: Archive, query: str, offset: int, limit: int
    ) -&amp;gt; list[dict[str, Any]]:
        searcher = Searcher(archive)
        search = searcher.search(Query().set_query(query))
        total_matches = search.getEstimatedMatches()

        results = []
        for path in search.getResults(offset, min(limit, total_matches)):
            entry = archive.get_entry_by_path(path)
            results.append(
                {
                    &quot;path&quot;: path,
                    &quot;title&quot;: entry.title,
                    &quot;snippet&quot;: self._get_entry_snippet(archive, entry),
                }
            )
        return results
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Critical Performance Optimization Strategies&lt;/h3&gt;
&lt;p&gt;A few patterns did most of the work here:&lt;/p&gt;
&lt;h4&gt;1. Demand-Driven Resource Loading&lt;/h4&gt;
&lt;p&gt;Load nothing until someone asks for it. Entry content is never materialized until a client actually requests it: libzim only decompresses the relevant cluster when &lt;code&gt;item.content&lt;/code&gt; is accessed, and the result is cached for next time.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def get_zim_entry_data(self, zim_file_path: str, entry_path: str) -&amp;gt; EntryResponse:
    cache_key = f&quot;entry:{zim_file_path}:{entry_path}&quot;
    cached = self.cache.get(cache_key)
    if cached is not None:
        return cached

    with zim_archive(Path(zim_file_path)) as archive:
        entry = archive.get_entry_by_path(entry_path)
        item = entry.get_item()  # cluster decompressed only on access
        content = self.content_processor.process_mime_content(
            bytes(item.content), item.mimetype or &quot;&quot;
        )

    response = build_entry_response(entry, content)
    self.cache.set(cache_key, response)
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(Simplified. The real method also handles redirect chains, content pagination, and a search-based fallback for mismatched entry paths.)&lt;/p&gt;
&lt;h4&gt;2. Inverted Index Architecture&lt;/h4&gt;
&lt;p&gt;Because the inverted index ships inside the archive (a Xapian index built when the ZIM file is created), the server never has to build one of its own. It validates that the archive carries a full-text index before searching, and archives without one fall back to a bounded namespace scan rather than failing outright. Title lookups get the same embedded-index treatment through libzim&apos;s &lt;code&gt;SuggestionSearcher&lt;/code&gt;; the pattern looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from libzim.suggestion import SuggestionSearcher

def title_suggestions(
    self, archive: Archive, query: str, limit: int
) -&amp;gt; list[str]:
    suggester = SuggestionSearcher(archive)
    suggestions = suggester.suggest(query)
    return list(suggestions.getResults(0, limit))
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. Memory-Mapped I/O Optimization&lt;/h4&gt;
&lt;p&gt;Let the kernel handle page caching instead of building your own. libzim memory-maps each archive and decompresses content clusters on demand, so the kernel&apos;s page cache manages the working set for free. The only knobs the Python layer exposes tune libzim&apos;s two internal caches:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# openzim_mcp/zim/archive.py
def configure_libzim_caches(
    cluster_cache_max_size_bytes: Optional[int] = None,
    dirent_cache_max_count: Optional[int] = None,
) -&amp;gt; None:
    &quot;&quot;&quot;Tune libzim&apos;s internal caches.

    The cluster cache (process-global, default 16 MiB) holds
    decompressed content clusters; the dirent cache (per-archive,
    default 512 entries) holds directory entries.
    &quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Practical Offline Workflows&lt;/h2&gt;
&lt;h3&gt;Research and Development&lt;/h3&gt;
&lt;p&gt;Here&apos;s how I use the OpenZIM MCP server in my daily workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# AI assistant searching offline Wikipedia
&amp;gt; Search the local Wikipedia for &quot;distributed systems consensus algorithms&quot;

# AI assistant accessing educational content
&amp;gt; Find articles about &quot;rust programming language memory safety&quot; in the offline knowledge base

# AI assistant browsing without internet
&amp;gt; Look up &quot;HTTP/3 protocol specifications&quot; in the local technical documentation
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The assistant answers from the local archive, no internet required.&lt;/p&gt;
&lt;h3&gt;Educational Scenarios&lt;/h3&gt;
&lt;p&gt;Offline access is especially useful in education:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Classroom environments&lt;/strong&gt; where internet is restricted or unreliable&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Field research&lt;/strong&gt; where connectivity isn&apos;t available&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developing regions&lt;/strong&gt; where data costs are prohibitive&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security-sensitive environments&lt;/strong&gt; where external connections aren&apos;t allowed&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Development in Low-Connectivity Environments&lt;/h3&gt;
&lt;p&gt;When you&apos;re building on a bad connection, having docs and reference material available offline matters a lot:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Example: AI assistant helping with offline development
def get_documentation(self, topic: str) -&amp;gt; str:
    response = self.zim_ops.search_zim_file_data(self.zim_path, topic, limit=5)

    sections = []
    for result in response[&quot;results&quot;]:
        content = self.zim_ops.get_zim_entry(self.zim_path, result[&quot;path&quot;])
        sections.append(f&quot;## {result[&apos;title&apos;]}\n\n{content}&quot;)

    return &quot;\n\n&quot;.join(sections)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/openzim-mcp-server-a-structural-overview-showing--1764557866316.jpg&quot; alt=&quot;A structural overview showing how the AI communicates through the MCP layer to access the offline storage vault.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Architecture Patterns for Offline Data Access&lt;/h2&gt;
&lt;h3&gt;Resource-Centric Design&lt;/h3&gt;
&lt;p&gt;The key insight for offline MCP servers is separating data access from data processing:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Protocol
from pydantic import BaseModel

class OfflineResource(BaseModel):
    uri: str
    title: str
    description: str | None = None
    content_type: str
    size: int | None = None

class OfflineResourceProvider(Protocol):
    def search_resources(self, query: str) -&amp;gt; list[OfflineResource]: ...
    def get_resource_content(self, uri: str) -&amp;gt; bytes: ...
    def get_resource_metadata(self, uri: str) -&amp;gt; ResourceMetadata: ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This lets you swap offline data sources (ZIM files, local databases, cached web content) without touching the MCP interface.&lt;/p&gt;
&lt;h3&gt;Caching Strategy&lt;/h3&gt;
&lt;p&gt;For offline systems, caching does real work. The cache combines TTL expiry with LRU eviction behind a re-entrant lock, keyed by operation (&lt;code&gt;search_v2b:...&lt;/code&gt;, &lt;code&gt;entry:...&lt;/code&gt;). Condensed from &lt;code&gt;openzim_mcp/cache.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import threading
import time
from typing import Any, Optional

class CacheEntry:
    def __init__(self, value: Any, ttl_seconds: int):
        self.value = value
        self.size_bytes = _approximate_size_bytes(value)
        self.created_at = time.monotonic()
        self.ttl_seconds = ttl_seconds

    def is_expired(self) -&amp;gt; bool:
        return time.monotonic() - self.created_at &amp;gt; self.ttl_seconds

class OpenZimMcpCache:
    def __init__(self, config: CacheConfig):
        self.config = config
        self._cache: dict[str, CacheEntry] = {}
        self._lock = threading.RLock()

    def get(self, key: str) -&amp;gt; Optional[Any]:
        with self._lock:
            entry = self._cache.get(key)
            if entry is None:
                return None
            if entry.is_expired():
                self.delete(key)
                return None
            self._record_access(key)
            return entry.value

    def set(self, key: str, value: Any) -&amp;gt; None:
        with self._lock:
            if len(self._cache) &amp;gt;= self.config.max_size:
                self._evict_lru()
            self._cache[key] = CacheEntry(value, self.config.ttl_seconds)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The full implementation also enforces a byte budget, uses a heap for O(log n) LRU eviction, runs a background cleanup thread for expired entries, and optionally persists the cache to disk across restarts.&lt;/p&gt;
&lt;h2&gt;Best Practices for Offline MCP Servers&lt;/h2&gt;
&lt;h3&gt;Error Handling for Offline Scenarios&lt;/h3&gt;
&lt;p&gt;Offline systems have unique error conditions. Each exception carries a machine-readable error code so MCP clients can handle failures programmatically:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class OpenZimMcpError(Exception):
    &quot;&quot;&quot;Base exception for all OpenZIM MCP-related errors.&quot;&quot;&quot;

    error_code: str = &quot;OPENZIM_ERROR&quot;

class OpenZimMcpFileNotFoundError(OpenZimMcpError):
    &quot;&quot;&quot;Raised when a ZIM file is not found.&quot;&quot;&quot;

    error_code = &quot;OPENZIM_FILE_NOT_FOUND&quot;

class OpenZimMcpArchiveError(OpenZimMcpError):
    &quot;&quot;&quot;Raised when ZIM archive operations fail.&quot;&quot;&quot;

    error_code = &quot;OPENZIM_ARCHIVE_ERROR&quot;

class OpenZimMcpTimeoutError(OpenZimMcpError):
    &quot;&quot;&quot;Base class for timeout-related errors.&quot;&quot;&quot;

    error_code = &quot;OPENZIM_TIMEOUT&quot;

class ArchiveOpenTimeoutError(OpenZimMcpTimeoutError):
    &quot;&quot;&quot;Raised when opening a ZIM archive times out.&quot;&quot;&quot;

    error_code = &quot;OPENZIM_ARCHIVE_OPEN_TIMEOUT&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Configuration for Offline Systems&lt;/h3&gt;
&lt;p&gt;Offline systems need different configuration considerations. The server uses pydantic-settings models with validated bounds on every field:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Literal
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class CacheConfig(BaseModel):
    enabled: bool = True
    max_size: int = Field(default=CACHE.MAX_SIZE, ge=1, le=10000)
    ttl_seconds: int = Field(default=CACHE.TTL_SECONDS, ge=60, le=86400)
    persistence_enabled: bool = Field(default=CACHE.PERSISTENCE_ENABLED)

class OpenZimMcpConfig(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix=&quot;OPENZIM_MCP_&quot;, env_nested_delimiter=&quot;__&quot;
    )

    allowed_directories: list[str] = Field(default_factory=list)
    cache: CacheConfig = Field(default_factory=CacheConfig)
    tool_mode: Literal[&quot;advanced&quot;, &quot;simple&quot;] = &quot;simple&quot;
    transport: Literal[&quot;stdio&quot;, &quot;http&quot;, &quot;sse&quot;] = &quot;stdio&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every field can be overridden through &lt;code&gt;OPENZIM_MCP_&lt;/code&gt;-prefixed environment variables, with &lt;code&gt;__&lt;/code&gt; as the nesting delimiter (for example &lt;code&gt;OPENZIM_MCP_CACHE__TTL_SECONDS=600&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;Testing Offline Systems&lt;/h3&gt;
&lt;p&gt;Testing offline systems requires different strategies. With pytest fixtures providing a small test archive, the pattern looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def test_offline_search(zim_operations, test_zim_path):
    response = zim_operations.search_zim_file_data(
        test_zim_path, &quot;rust programming&quot;, limit=10
    )

    assert response[&quot;results&quot;]
    assert len(response[&quot;results&quot;]) &amp;lt;= 10

def test_cache_behavior(zim_operations, test_zim_path):
    # First access - should hit the ZIM file and populate the cache
    content1 = zim_operations.get_zim_entry(test_zim_path, &quot;A/Rust&quot;)

    # Second access - should be served from the cache
    content2 = zim_operations.get_zim_entry(test_zim_path, &quot;A/Rust&quot;)

    assert content1 == content2

def test_cache_expiry():
    cache = OpenZimMcpCache(CacheConfig(ttl_seconds=60))
    cache.set(&quot;entry:wiki.zim:A/Rust&quot;, &quot;&amp;lt;content&amp;gt;&quot;)

    assert cache.get(&quot;entry:wiki.zim:A/Rust&quot;) == &quot;&amp;lt;content&amp;gt;&quot;
    assert cache.get(&quot;entry:wiki.zim:A/Missing&quot;) is None
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Future of Offline AI&lt;/h2&gt;
&lt;p&gt;Building openzim-mcp got me thinking about how far offline AI can go. An assistant shouldn&apos;t only be smart when it&apos;s connected. It should stay useful when the internet is gone.&lt;/p&gt;
&lt;p&gt;A few directions I want to take this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hybrid online/offline&lt;/strong&gt;: switch between online and offline sources depending on what&apos;s available&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incremental updates&lt;/strong&gt;: refresh an offline knowledge base without rebuilding it from scratch&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Specialized domains&lt;/strong&gt;: build ZIM files for specific technical fields or industries&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Local networks&lt;/strong&gt;: share knowledge bases across a LAN with no internet&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Getting Started with Offline Knowledge&lt;/h2&gt;
&lt;p&gt;Want to try the OpenZIM MCP server yourself? Here&apos;s how to get started:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install the server
uv tool install openzim-mcp
# or: pip install openzim-mcp

# Download a ZIM file (example: Simple English Wikipedia)
wget https://download.kiwix.org/zim/wikipedia/wikipedia_en_simple_all.zim

# Point the server at your ZIM directory
openzim-mcp /path/to/zim/files

# Configure your AI assistant to use the offline knowledge base
# (specific steps depend on your MCP client)

# Start exploring offline knowledge
# Try searching for topics you&apos;re interested in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There&apos;s a lot out there already. You&apos;ll find ZIM files for Wikipedia in dozens of languages, plus educational content, technical documentation, and specialized knowledge bases.&lt;/p&gt;
&lt;h2&gt;Architectural Insights and Design Principles&lt;/h2&gt;
&lt;p&gt;Offline access turned out to beat the network on more than availability. A curated, high-quality knowledge base often gives more focused, relevant answers than a general web search, and there&apos;s no latency or flaky connection to fight.&lt;/p&gt;
&lt;p&gt;The problems I ran into (search optimization, caching, memory management) taught me a lot about designing for data access. Tight constraints tend to produce better solutions than open-ended ones. They force you to decide what actually matters and cut the rest.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Want to dig in? The &lt;a href=&quot;https://cameronrye.github.io/openzim-mcp/&quot;&gt;project documentation&lt;/a&gt; and the &lt;a href=&quot;https://github.com/cameronrye/openzim-mcp&quot;&gt;GitHub repository&lt;/a&gt; have the full implementation and more examples.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>mcp</category><category>ai</category><category>search</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/openzim-mcp-server-a-visual-metaphor-for-a-self-s-featured-1764557829239.jpg" length="0" type="image/jpeg"/></item><item><title>Building a Gopher MCP Server: Bringing 1991&apos;s Internet to Modern AI</title><link>https://rye.dev/blog/gopher-mcp-server/</link><guid isPermaLink="true">https://rye.dev/blog/gopher-mcp-server/</guid><description>Why I wired a 1991 protocol into modern AI: gopher-mcp gives assistants the small internet&apos;s text-first content with almost no protocol overhead.</description><pubDate>Tue, 12 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/gopher-mcp-server-a-visual-metaphor-connecting-t-featured-1764557036867.jpg&quot; alt=&quot;Building a Gopher MCP Server: Bringing 1991&amp;#39;s Internet to Modern AI&quot; /&gt;&lt;/p&gt;&lt;p&gt;I wired Gopher, a protocol from 1991, into an MCP server so AI assistants can read it. Doing that turned out to be a decent lesson in system design. Protocols built on minimalist principles tend to be simpler to run and quicker on the wire than the modern equivalents, and that tradeoff still holds up for distributed systems today.&lt;/p&gt;
&lt;h2&gt;Historical Context and Protocol Evolution&lt;/h2&gt;
&lt;p&gt;Gopher showed up early, before the web had won. Instead of hypertext, it organized information into a strict hierarchy: menus of menus, with files at the leaves. It came out of the University of Minnesota, led by Mark McCahill, as a straightforward client-server design built around structured navigation rather than free-form linking.&lt;/p&gt;
&lt;p&gt;Through the early 1990s it caught on across universities and research institutions, and for a while it beat the early web on both speed and ease of use. The design leaned on predictable navigation and almost no protocol overhead, which is exactly what you wanted on the slow, bandwidth-starved links of the day.&lt;/p&gt;
&lt;h3&gt;Protocol Competition and Market Dynamics&lt;/h3&gt;
&lt;p&gt;HTTP and HTML won anyway, and mostly for reasons that had little to do with technical merit. A few things mattered more:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Licensing&lt;/strong&gt;: The University of Minnesota&apos;s intellectual property stance was ambiguous, and that uncertainty scared off commercial developers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Media&lt;/strong&gt;: Gopher was built for text. The commercial internet wanted images, then everything else, and HTML could carry it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flexibility&lt;/strong&gt;: HTTP is stateless and document-oriented, which made dynamic content easy in a way Gopher&apos;s fixed hierarchy never did.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It&apos;s hard to look at Gopher now without thinking about what the modern web struggles with: page bloat, heavy client-side code, and constant distraction. A lot of what we now call web performance best practice is just Gopher&apos;s minimalism arrived at the long way around.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/gopher-mcp-server-a-comparison-of-gopher-s-struc-1764557057764.jpg&quot; alt=&quot;A comparison of Gopher&apos;s structured, hierarchical nature against the complexity of the modern web.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Contemporary Gopher Protocol Revival&lt;/h2&gt;
&lt;p&gt;Gopher has quietly come back, part of a wider pull toward minimalist computing. The reasons people give tend to be the same handful:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No presentation layer&lt;/strong&gt;: There&apos;s no styling, no layout, no scripts. Just the content.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tiny on the wire&lt;/strong&gt;: With almost no protocol features or metadata to send, there&apos;s very little overhead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trivial to implement&lt;/strong&gt;: The spec is short enough that you can write a client in an afternoon, and a small surface is a small attack surface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easy to navigate&lt;/strong&gt;: The rigid menu structure means fewer decisions and less to get lost in.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&apos;s what makes it a good fit for MCP. An AI assistant wants clean, structured information, not the noise that comes with most of the modern web. Gopher hands it exactly that.&lt;/p&gt;
&lt;h2&gt;Model Context Protocol Integration Architecture&lt;/h2&gt;
&lt;p&gt;MCP sits between an AI model and whatever external resources it needs, and it does so through a capability-based permission model. The model only touches what it&apos;s been granted, which is the whole point: let an assistant reach outside itself without handing it the keys to everything.&lt;/p&gt;
&lt;p&gt;Explicit permissions and resource isolation are a natural match for how small and constrained Gopher already is. Put the two together and an assistant can read curated, high-quality sources without dragging in the security and complexity of a full web browser.&lt;/p&gt;
&lt;h2&gt;Building the Gopher MCP Server&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/gopher-mcp-server-a-diagrammatic-representation--1764557078971.jpg&quot; alt=&quot;A diagrammatic representation of the software architecture, showing how MCP acts as the abstraction layer between AI and resources.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Protocol Abstraction Layer&lt;/h3&gt;
&lt;p&gt;The main design decision in gopher-mcp was to handle a family of related protocols through one abstraction. Gopher and Gemini differ in the details, but they share the same basic interaction model. That overlap is worth factoring out, as long as each protocol keeps room for its own quirks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class GopherClient(TTLCacheMixin[GopherFetchResponse]):
    &quot;&quot;&quot;Async client for the Gopher protocol.&quot;&quot;&quot;

    async def fetch(self, url: str) -&amp;gt; GopherFetchResponse:
        parsed = parse_gopher_url(url)
        ...


class GeminiClient(TTLCacheMixin[GeminiFetchResponse]):
    &quot;&quot;&quot;Async client for the Gemini protocol (TLS, TOFU certificates).&quot;&quot;&quot;

    async def fetch(self, url: str) -&amp;gt; GeminiFetchResponse:
        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the Strategy pattern, and it earns its keep here. Both clients expose the same async &lt;code&gt;fetch&lt;/code&gt; surface and inherit caching from a shared &lt;code&gt;TTLCacheMixin&lt;/code&gt;. Adding a protocol means implementing that interface, not editing the core, so I can grow what the server supports without touching the parts that already work.&lt;/p&gt;
&lt;h3&gt;Gopher Protocol Implementation&lt;/h3&gt;
&lt;p&gt;The Gopher protocol is refreshingly simple. Here&apos;s how a basic client works:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio


async def fetch_gopher(
    host: str, port: int, selector: str, *, max_bytes: int, timeout: float
) -&amp;gt; bytes:
    &quot;&quot;&quot;Send a Gopher request and return the raw response bytes.&quot;&quot;&quot;

    async def _io() -&amp;gt; bytes:
        reader, writer = await asyncio.open_connection(host, port)
        try:
            # Send Gopher request (just the selector + CRLF)
            writer.write(selector.encode(&quot;utf-8&quot;) + b&quot;\r\n&quot;)
            await writer.drain()

            # Read the response in chunks, enforcing the size cap
            chunks: list[bytes] = []
            total = 0
            while chunk := await reader.read(65536):
                total += len(chunk)
                if total &amp;gt; max_bytes:
                    raise GopherProtocolError(
                        f&quot;Response exceeds maximum size of {max_bytes} bytes&quot;
                    )
                chunks.append(chunk)
            return b&quot;&quot;.join(chunks)
        finally:
            writer.close()
            await writer.wait_closed()

    return await asyncio.wait_for(_io(), timeout=timeout)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the whole protocol: send a selector, read bytes back. No status codes, no headers, no content negotiation. Less to implement, less to send, and less that can go wrong on a slow connection.&lt;/p&gt;
&lt;h3&gt;Content Type Detection&lt;/h3&gt;
&lt;p&gt;Gopher uses a simple but effective type system that predates MIME types:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;_GOPHER_TYPE_CATEGORY: dict[str, str] = {
    &quot;0&quot;: &quot;text&quot;,    # Text file
    &quot;1&quot;: &quot;menu&quot;,    # Directory listing
    &quot;7&quot;: &quot;menu&quot;,    # Search server
    &quot;9&quot;: &quot;binary&quot;,  # Binary file
    &quot;g&quot;: &quot;binary&quot;,  # GIF image
    &quot;I&quot;: &quot;binary&quot;,  # Image file
    &quot;h&quot;: &quot;text&quot;,    # HTML document
    # ... more types
}


def gopher_type_category(gopher_type: str) -&amp;gt; str:
    &quot;&quot;&quot;Return the handling category for a Gopher item type.

    One of &quot;menu&quot;, &quot;text&quot;, &quot;binary&quot; or &quot;interactive&quot;. Unknown
    types default to &quot;text&quot;, matching historical behaviour.
    &quot;&quot;&quot;
    return _GOPHER_TYPE_CATEGORY.get(gopher_type, &quot;text&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Practical Applications&lt;/h2&gt;
&lt;h3&gt;Research and Documentation&lt;/h3&gt;
&lt;p&gt;The use case I keep coming back to is research. A lot of Gopher servers host high-quality, curated content:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Academic papers&lt;/strong&gt;: Many universities maintain Gopher archives&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Technical documentation&lt;/strong&gt;: Clean, distraction-free technical docs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Historical archives&lt;/strong&gt;: Digital libraries and historical collections&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When your AI assistant can browse these resources, it&apos;s accessing information that&apos;s often more reliable and better curated than random web pages.&lt;/p&gt;
&lt;h3&gt;Development Workflows&lt;/h3&gt;
&lt;p&gt;Here&apos;s a practical example of how I use the Gopher MCP server in my development workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# AI assistant browsing Gopher for technical documentation
&amp;gt; Browse gopher://gopher.floodgap.com/1/world for information about protocol specifications

# AI assistant accessing university research archives
&amp;gt; Search gopher://gopher.umn.edu/ for papers on distributed systems

# AI assistant exploring historical computing resources
&amp;gt; Navigate to gopher://sdf.org/1/users/cat/gopher-history for protocol history
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The AI gets clean, focused content without the noise of modern web advertising and tracking.&lt;/p&gt;
&lt;h2&gt;Architecture Patterns for Protocol Servers&lt;/h2&gt;
&lt;h3&gt;Resource-Centric Design&lt;/h3&gt;
&lt;p&gt;Building a protocol MCP server taught me the importance of separating concerns:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class MenuResult(BaseModel):
    kind: Literal[&quot;menu&quot;] = &quot;menu&quot;
    items: list[GopherMenuItem]
    truncated: bool = False
    request_info: dict[str, Any]


class BinaryResult(BaseModel):
    kind: Literal[&quot;binary&quot;] = &quot;binary&quot;
    bytes: int
    mime_type: str | None
    note: str  # binary payloads are described, not returned
    request_info: dict[str, Any]


GopherFetchResponse = MenuResult | TextResult | BinaryResult | ErrorResult
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pattern lets you swap protocol implementations without touching the MCP logic. Want to add Finger? Define its result models and a client that returns them. The MCP layer just serializes whatever comes back.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/gopher-mcp-server-visualizing-the-async-first-ar-1764557103342.jpg&quot; alt=&quot;Visualizing the &apos;Async-First Architecture&apos; and caching mechanisms discussed in the server design patterns.&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;Async-First Architecture&lt;/h3&gt;
&lt;p&gt;Protocol servers need to handle multiple concurrent requests efficiently:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class GopherClient(TTLCacheMixin[GopherFetchResponse]):
    async def fetch(self, url: str) -&amp;gt; GopherFetchResponse:
        parsed = parse_gopher_url(url)
        self._validate_security(parsed)

        # Check cache first (TTL expiry, LRU touch)
        if self.cache_enabled:
            cached = self._get_cached_response(url)
            if cached is not None:
                return cached

        # Fetch, bounded by an asyncio.Semaphore when a
        # concurrency cap is configured
        response = await self._bounded_fetch(parsed)

        # Cache non-error responses; the mixin evicts the least
        # recently used entry once max_cache_entries is reached
        if self.cache_enabled and not isinstance(response, ErrorResult):
            self._cache_response(url, response)

        return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because everything runs on a single asyncio event loop, the cache is just an &lt;code&gt;OrderedDict&lt;/code&gt; with TTL timestamps. No locks required. Concurrency is bounded where it actually matters, at the sockets.&lt;/p&gt;
&lt;h2&gt;Best Practices for Protocol MCP Servers&lt;/h2&gt;
&lt;h3&gt;Error Handling&lt;/h3&gt;
&lt;p&gt;Handle errors with context:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class GopherProtocolError(Exception):
    &quot;&quot;&quot;Raised when a Gopher request cannot be completed.&quot;&quot;&quot;


class SSRFError(ValueError):
    &quot;&quot;&quot;Raised when a target host/address is blocked by the SSRF policy.&quot;&quot;&quot;


class ErrorResult(BaseModel):
    kind: Literal[&quot;error&quot;] = &quot;error&quot;
    error: dict[str, str]
    request_info: dict[str, Any]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client catches &lt;code&gt;SSRFError&lt;/code&gt;, &lt;code&gt;GopherProtocolError&lt;/code&gt;, and unexpected exceptions at the fetch boundary and converts them into an &lt;code&gt;ErrorResult&lt;/code&gt;, so failures surface as structured data the model can reason about rather than an unhandled exception.&lt;/p&gt;
&lt;h3&gt;Configuration Management&lt;/h3&gt;
&lt;p&gt;Keep configuration simple but flexible:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic_settings import BaseSettings, SettingsConfigDict


class GopherConfig(BaseSettings):
    &quot;&quot;&quot;Gopher client settings, read from GOPHER_* environment variables.&quot;&quot;&quot;

    model_config = SettingsConfigDict(env_prefix=&quot;GOPHER_&quot;)

    max_response_size: int = 1024 * 1024  # 1MB
    timeout_seconds: float = 30.0
    cache_ttl_seconds: int = 300  # 5 minutes
    max_cache_entries: int = 1000
    allow_local_hosts: bool = False  # SSRF guard stays on by default
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Future of Alternative Protocols in AI&lt;/h2&gt;
&lt;p&gt;Building the Gopher MCP server opened my eyes to something interesting: there&apos;s a whole ecosystem of alternative protocols that could benefit AI assistants:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gemini&lt;/strong&gt;: Gopher&apos;s modern successor with TLS and markdown support&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finger&lt;/strong&gt;: Simple user information protocol&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NNTP&lt;/strong&gt;: Network News Transfer Protocol for accessing Usenet&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IRC&lt;/strong&gt;: Real-time chat protocol integration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each takes a different angle on sharing information, and each could be worth exposing to an assistant.&lt;/p&gt;
&lt;h2&gt;Architectural Insights and Design Principles&lt;/h2&gt;
&lt;p&gt;Building gopher-mcp made the link between protocol complexity and reliability concrete for me. Gopher puts content ahead of presentation, and that happens to be what an AI wants too: give it structured data, skip the multimedia.&lt;/p&gt;
&lt;p&gt;Its simplicity also makes it a good place to learn MCP server patterns. With so little protocol to fight, you can concentrate on the parts that actually recur: resource management, caching, and error handling, without drowning in protocol-specific edge cases.&lt;/p&gt;
&lt;h2&gt;Getting Started&lt;/h2&gt;
&lt;p&gt;Want to try the Gopher MCP server yourself? Here&apos;s how to get started:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Run the server directly with uv
uvx gopher-mcp

# Or install it with pip
pip install gopher-mcp

# Configure your AI assistant to use it
# (specific steps depend on your MCP client)

# Start exploring Gopher space
# Try gopher://gopher.floodgap.com/ for a good starting point
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Gopher internet is small but surprisingly rich. There&apos;s technical documentation, there&apos;s poetry, and it all shows up in the same clean, text-first format. After a while, reading it feels like a relief.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Interested in exploring more? Visit the &lt;a href=&quot;https://cameronrye.github.io/gopher-mcp/&quot;&gt;project documentation&lt;/a&gt; or check out the &lt;a href=&quot;https://github.com/cameronrye/gopher-mcp&quot;&gt;GitHub repository&lt;/a&gt; for complete implementation details and examples.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>mcp</category><category>ai</category><category>protocols</category><category>retro-computing</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/gopher-mcp-server-a-visual-metaphor-connecting-t-featured-1764557036867.jpg" length="0" type="image/jpeg"/></item><item><title>Building Model Context Protocol Servers: A Deep Dive</title><link>https://rye.dev/blog/building-mcp-servers/</link><guid isPermaLink="true">https://rye.dev/blog/building-mcp-servers/</guid><description>What I learned building production MCP servers in Python: the architecture, caching, and security patterns shared by gopher-mcp and openzim-mcp.</description><pubDate>Thu, 05 Sep 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-mcp-servers-a-conceptual-visualization-of--featured-1764559800190.jpg&quot; alt=&quot;Building Model Context Protocol Servers: A Deep Dive&quot; /&gt;&lt;/p&gt;&lt;p&gt;I&apos;ve spent more than a decade building distributed systems, and the Model Context Protocol is the first thing in a while that&apos;s changed how I think about connecting AI models to the outside world. I&apos;ve built production MCP servers including gopher-mcp and openzim-mcp, and along the way I landed on a handful of patterns worth writing down.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update (June 2025):&lt;/strong&gt; I&apos;ve split this guide into two shorter articles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://rye.dev/blog/gopher-mcp-server/&quot;&gt;Gopher MCP Server: Bringing 1991&apos;s Internet to Modern AI&lt;/a&gt;&lt;/strong&gt; - Focuses on the Gopher protocol, its history, and practical applications&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://rye.dev/blog/openzim-mcp-server/&quot;&gt;OpenZIM MCP Server: Offline Knowledge for AI Assistants&lt;/a&gt;&lt;/strong&gt; - Covers offline Wikipedia access and ZIM format optimization&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Understanding the Model Context Protocol Architecture&lt;/h2&gt;
&lt;p&gt;Before MCP, wiring an AI model up to an external data source meant a one-off integration every time. MCP replaces that with a formal contract between the model and the resource: a standard way to expose data and tools that doesn&apos;t hand the model unchecked access to your system.&lt;/p&gt;
&lt;p&gt;It sits between the model and whatever you&apos;re integrating, so old protocols and modern APIs look the same from the model&apos;s side. None of this is new. It&apos;s the same separation-of-concerns thinking distributed systems have relied on for years, pointed at AI tooling.&lt;/p&gt;
&lt;h3&gt;Strategic Advantages of MCP Implementation&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt;: capability-based, with explicit permission boundaries. The model gets exactly the access you grant it, which beats the plugin free-for-all it replaces.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One integration pattern&lt;/strong&gt;: every resource type is reached the same way, so you&apos;re not relearning a new integration shape for each backend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Room to grow&lt;/strong&gt;: adding a capability means implementing an interface, not redesigning the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance hooks built in&lt;/strong&gt;: caching, connection pooling, and resource lifecycle management are part of the model rather than bolted on later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-mcp-servers-visualizing-the-protocol-abstr-1764559819283.jpg&quot; alt=&quot;Visualizing the &apos;Protocol Abstraction Layer&apos; pattern, showing how the system separates high-level requests from low-level resource handling.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Architectural Patterns for Production MCP Systems&lt;/h2&gt;
&lt;p&gt;Building a couple of MCP servers surfaced the same handful of patterns each time. None of them are specific to MCP; they&apos;re standard distributed-systems practice, applied to the job of exposing resources to a model:&lt;/p&gt;
&lt;h3&gt;1. Resource-Centric Design&lt;/h3&gt;
&lt;p&gt;The pattern looks like this: Pydantic models for the data contract, a &lt;code&gt;Protocol&lt;/code&gt; for the behavior.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from typing import Protocol

from pydantic import BaseModel


class Resource(BaseModel):
    uri: str
    name: str
    description: str | None = None
    mime_type: str | None = None


class ResourceProvider(Protocol):
    async def list_resources(self) -&amp;gt; list[Resource]: ...
    async def read_resource(self, uri: str) -&amp;gt; bytes: ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the Strategy pattern: you can swap the backend at runtime without touching the code that uses it. Keeping discovery (&lt;code&gt;list_resources&lt;/code&gt;) separate from access (&lt;code&gt;read_resource&lt;/code&gt;) is also what makes caching, load balancing, and failover tractable later, since each concern has one place to live.&lt;/p&gt;
&lt;h3&gt;2. Protocol Abstraction Layer&lt;/h3&gt;
&lt;p&gt;gopher-mcp has to speak two protocols, Gopher and Gemini, which made it a good place to work out how to abstract over them instead of writing two disconnected handlers. Both clients expose the same async surface and inherit the same caching behavior, and each is registered as a FastMCP tool (condensed from &lt;code&gt;server.py&lt;/code&gt; and the client modules):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class GopherClient(TTLCacheMixin[GopherFetchResponse]):
    &quot;&quot;&quot;Async Gopher protocol client with caching and safety features.&quot;&quot;&quot;

    async def fetch(self, url: str) -&amp;gt; GopherFetchResponse: ...


class GeminiClient(TTLCacheMixin[GeminiFetchResponse]):
    &quot;&quot;&quot;Async Gemini protocol client with TLS, caching and safety features.&quot;&quot;&quot;

    async def fetch(self, url: str) -&amp;gt; GeminiFetchResponse: ...


@mcp.tool(title=&quot;Fetch Gopher resource&quot;)
async def gopher_fetch(url: str) -&amp;gt; dict[str, Any]:
    client = await client_manager.get_gopher_client()
    response = await client.fetch(url)
    return response.model_dump()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the Open/Closed Principle in practice: open for extension, closed for modification. Adding a protocol means implementing the same client interface, not editing the core, so the parts that already work keep working.&lt;/p&gt;
&lt;h3&gt;3. Async-First Architecture&lt;/h3&gt;
&lt;p&gt;A server handling concurrent requests wants sub-millisecond responses for cached resources, and a single blocking I/O call can stall everything queued behind it. So every I/O path in gopher-mcp is async end to end, on Python&apos;s asyncio event loop. There&apos;s a nice side effect: because the event loop serializes access to shared state, the cache layer needs no locking at all. Both protocol clients inherit the same TTL + LRU cache behavior from a shared mixin (condensed from &lt;code&gt;cache.py&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import time
from collections import OrderedDict
from typing import Generic, TypeVar

V = TypeVar(&quot;V&quot;)


class TTLCacheMixin(Generic[V]):
    &quot;&quot;&quot;Shared TTL + LRU cache behavior for the Gopher and Gemini clients.&quot;&quot;&quot;

    _cache: &quot;OrderedDict[str, _BaseCacheEntry[V]]&quot;

    def _get_cached_response(self, url: str) -&amp;gt; V | None:
        entry = self._cache.get(url)
        if entry is None:
            return None
        if entry.is_expired(time.time()):
            del self._cache[url]
            return None
        self._cache.move_to_end(url)  # mark as most recently used
        return entry.value

    def _cache_response(self, url: str, response: V) -&amp;gt; None:
        if len(self._cache) &amp;gt;= self.max_cache_entries:
            self._cache.popitem(last=False)  # evict the LRU entry
        self._cache[url] = self._cache_entry_cls(
            key=url,
            value=response,
            timestamp=time.time(),
            ttl=self.cache_ttl_seconds,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-mcp-servers-abstract-representation-of-sea-1764559837806.jpg&quot; alt=&quot;Abstract representation of searching within compressed data structures, relevant to the OpenZIM case study.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Case Study: OpenZIM MCP Server Architecture&lt;/h2&gt;
&lt;p&gt;openzim-mcp searches compressed knowledge bases with millions of articles and needs to return results in under a second. That&apos;s the usual storage-versus-speed tradeoff, with a memory budget tight enough to run on modest hardware.&lt;/p&gt;
&lt;h3&gt;ZIM File Handling&lt;/h3&gt;
&lt;p&gt;The hard part sounds like it would be searching compressed data without decompressing the whole thing first. It turns out the ZIM format already solves that: each archive ships with an embedded Xapian full-text index, and libzim exposes it directly. So the server doesn&apos;t build an index at all; it just drives the one that&apos;s already there. The core search path (condensed from openzim-mcp&apos;s &lt;code&gt;zim/search.py&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from libzim.search import Query, Searcher


class _SearchMixin:
    def _perform_search(
        self, archive: Archive, query: str, limit: int, offset: int
    ) -&amp;gt; dict[str, Any]:
        searcher = Searcher(archive)
        search = searcher.search(Query().set_query(query))
        total_results = search.getEstimatedMatches()

        results = []
        for entry_id in search.getResults(offset, limit):
            entry = archive.get_entry_by_path(entry_id)
            results.append({&quot;path&quot;: entry_id, &quot;title&quot;: entry.title})

        return {&quot;query&quot;: query, &quot;total&quot;: total_results, &quot;results&quot;: results}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Performance Tricks I Discovered&lt;/h3&gt;
&lt;p&gt;Most of the speed comes from four things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Lazy loading&lt;/strong&gt;: articles and indexes are read on demand, so startup stays cheap and memory tracks what you actually touch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The embedded Xapian index&lt;/strong&gt;: search runs against the full-text index already inside the ZIM archive, reached through libzim, so there&apos;s no separate index to build or keep in sync.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory-mapped I/O&lt;/strong&gt;: the kernel&apos;s page cache decides what stays in memory, which means no cache layer to write here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Result caching&lt;/strong&gt;: paginated search results are cached so a repeated query doesn&apos;t pay the full cost twice.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/building-mcp-servers-a-visual-metaphor-for-the-goph-1764559853771.jpg&quot; alt=&quot;A visual metaphor for the Gopher MCP server: wrapping 1990s internet protocols in modern server architecture.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Case Study: Gopher MCP Server Implementation&lt;/h2&gt;
&lt;p&gt;There&apos;s something to learn from old protocols about doing less. Gopher predates most of the complexity we take for granted on the modern web, and that simplicity is exactly what makes it reliable and easy to reason about.&lt;/p&gt;
&lt;h3&gt;Protocol Implementation&lt;/h3&gt;
&lt;p&gt;The transport layer (condensed from &lt;code&gt;gopher_transport.py&lt;/code&gt;) is about as simple as the protocol. It&apos;s a TCP connection, a selector line, and a bounded read:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import asyncio


async def fetch_gopher(
    host: str,
    port: int,
    selector: str,
    *,
    search: str | None = None,
    max_bytes: int = DEFAULT_MAX_RESPONSE_SIZE,
    timeout: float = DEFAULT_TIMEOUT_SECONDS,
) -&amp;gt; bytes:
    async def _io() -&amp;gt; bytes:
        reader, writer = await asyncio.open_connection(host, port)
        try:
            # &quot;selector[&amp;lt;TAB&amp;gt;search]&amp;lt;CR&amp;gt;&amp;lt;LF&amp;gt;&quot;, UTF-8 encoded
            writer.write(build_request(selector, search))
            await writer.drain()

            chunks: list[bytes] = []
            total = 0
            while True:
                chunk = await reader.read(min(READ_CHUNK, max_bytes - total + 1))
                if not chunk:
                    break
                total += len(chunk)
                if total &amp;gt; max_bytes:
                    raise GopherProtocolError(&quot;Response exceeded size limit&quot;)
                chunks.append(chunk)
            return b&quot;&quot;.join(chunks)
        finally:
            writer.close()
            await writer.wait_closed()

    # One deadline covers connect, send, and read
    return await asyncio.wait_for(_io(), timeout=timeout)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Content Type Detection&lt;/h3&gt;
&lt;p&gt;Gopher uses a simple but effective type system. In gopher-mcp, the mapping lives in &lt;code&gt;mime.py&lt;/code&gt; as plain data:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;type_mappings = {
    &quot;0&quot;: &quot;text/plain&quot;,                # text file
    &quot;1&quot;: &quot;text/gopher-menu&quot;,          # menu / directory
    &quot;4&quot;: &quot;application/mac-binhex40&quot;,  # BinHex file
    &quot;7&quot;: &quot;text/gopher-menu&quot;,          # search server
    &quot;9&quot;: &quot;application/octet-stream&quot;,  # binary file
    &quot;g&quot;: &quot;image/gif&quot;,
    &quot;I&quot;: &quot;image/jpeg&quot;,
    # ... more mappings
}


def guess_mime_type(gopher_type: str, selector: str = &quot;&quot;) -&amp;gt; str:
    &quot;&quot;&quot;Map a Gopher item type to a MIME type.

    The full implementation also refines ambiguous types using the
    selector&apos;s file extension (.html, .png, .pdf, ...).
    &quot;&quot;&quot;
    return type_mappings.get(gopher_type, &quot;application/octet-stream&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Best Practices for MCP Server Development&lt;/h2&gt;
&lt;h3&gt;1. Error Handling&lt;/h3&gt;
&lt;p&gt;Handle errors with context. gopher-mcp draws a line between internal exceptions and what crosses the tool boundary:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;class GopherProtocolError(Exception):
    &quot;&quot;&quot;Raised internally when a Gopher request cannot be completed.&quot;&quot;&quot;


class ErrorResult(BaseModel):
    &quot;&quot;&quot;Structured error payload returned to the model.&quot;&quot;&quot;

    kind: Literal[&quot;error&quot;] = &quot;error&quot;
    error: dict[str, str]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The split matters: transport failures raise &lt;code&gt;GopherProtocolError&lt;/code&gt; inside the client, but the tool boundary converts everything into a structured &lt;code&gt;ErrorResult&lt;/code&gt;. An exception that escapes an MCP tool is an opaque failure to the calling model; a structured error is something it can reason about and recover from.&lt;/p&gt;
&lt;h3&gt;2. Configuration Management&lt;/h3&gt;
&lt;p&gt;Use structured configuration with validation. gopher-mcp builds its config from pydantic-settings models keyed by environment prefix:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from pydantic_settings import BaseSettings, SettingsConfigDict


class GopherConfig(BaseSettings):
    &quot;&quot;&quot;Gopher client settings, read from GOPHER_* environment variables.&quot;&quot;&quot;

    model_config = SettingsConfigDict(env_prefix=&quot;GOPHER_&quot;, env_file=&quot;.env&quot;)

    max_response_size: int = 1024 * 1024  # 1MB
    timeout_seconds: float = 30.0
    cache_enabled: bool = True
    cache_ttl_seconds: int = 300
    max_cache_entries: int = 1000
    allowed_hosts: list[str] | None = None  # None = allow all
    allow_local_hosts: bool = False  # SSRF protection
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each surface gets its own settings class (&lt;code&gt;GOPHER_*&lt;/code&gt;, &lt;code&gt;GEMINI_*&lt;/code&gt;, and &lt;code&gt;GOPHER_MCP_*&lt;/code&gt; for the server itself), with field validators that parse comma-separated environment values into typed lists.&lt;/p&gt;
&lt;h3&gt;3. Testing Strategy&lt;/h3&gt;
&lt;p&gt;Cover the real paths, integration tests included. With pytest and pytest-asyncio, the shape looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import pytest


def test_gopher_url_parsing() -&amp;gt; None:
    parsed = parse_gopher_url(&quot;gopher://example.com/0/about.txt&quot;)
    assert parsed.host == &quot;example.com&quot;
    assert parsed.port == 70
    assert parsed.gopher_type == &quot;0&quot;


@pytest.mark.asyncio
async def test_fetch_returns_menu_result() -&amp;gt; None:
    client = GopherClient(cache_enabled=False)
    result = await client.fetch(&quot;gopher://gopher.example/&quot;)
    assert result.kind == &quot;menu&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Performance Considerations&lt;/h2&gt;
&lt;h3&gt;Memory Management&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Use streaming for large resources&lt;/li&gt;
&lt;li&gt;Implement proper caching strategies&lt;/li&gt;
&lt;li&gt;Monitor memory usage in production&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Concurrency&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Design for high concurrency from the start&lt;/li&gt;
&lt;li&gt;Use appropriate synchronization primitives&lt;/li&gt;
&lt;li&gt;Consider backpressure mechanisms&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Network Efficiency&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Implement connection pooling&lt;/li&gt;
&lt;li&gt;Use compression when appropriate&lt;/li&gt;
&lt;li&gt;Handle network timeouts gracefully&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Deployment and Monitoring&lt;/h2&gt;
&lt;h3&gt;Docker Deployment&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Build stage: produce a wheel from the source tree
FROM python:3.14-slim AS build
RUN pip install --no-cache-dir uv
WORKDIR /src
COPY . .
RUN uv build --wheel --out-dir /dist

# Runtime stage: install just the wheel, run as a non-root user
FROM python:3.14-slim
RUN useradd --create-home --uid 10001 app
COPY --from=build /dist/*.whl /tmp/
RUN pip install --no-cache-dir /tmp/*.whl &amp;amp;&amp;amp; rm -f /tmp/*.whl
USER app

EXPOSE 8000
ENTRYPOINT [&quot;gopher-mcp&quot;]
CMD [&quot;--transport&quot;, &quot;streamable-http&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;8000&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Health Checks&lt;/h3&gt;
&lt;p&gt;Health checks look different for MCP servers than for web services. Over the stdio transport there&apos;s no HTTP surface to probe, so it pays to expose health as a first-class tool the client can call like any other. openzim-mcp ships one as &lt;code&gt;zim_health&lt;/code&gt;. The pattern looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@mcp.tool()
async def health_check() -&amp;gt; dict[str, str]:
    &quot;&quot;&quot;Lightweight liveness probe callable by any MCP client.&quot;&quot;&quot;
    return {&quot;status&quot;: &quot;ok&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Future Directions in MCP Architecture&lt;/h2&gt;
&lt;p&gt;MCP is still young, and there&apos;s a lot of room to build on top of it. A few directions I&apos;m watching:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Streaming&lt;/strong&gt;: backpressure-aware streaming so a large dataset doesn&apos;t have to fit in memory to be processed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authentication&lt;/strong&gt;: capability-based security that holds up across multiple MCP servers, not just one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Federation&lt;/strong&gt;: service-mesh patterns for running and load-balancing a fleet of MCP servers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability&lt;/strong&gt;: distributed tracing and metrics for interactions that span several tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Strategic Implications and Future Outlook&lt;/h2&gt;
&lt;p&gt;Two servers in, what strikes me is how ordinary the good decisions are. Start with the smallest version that works. Add observability early, so you can see what production is actually doing. Then iterate on what it shows you.&lt;/p&gt;
&lt;p&gt;MCP is on track to become plumbing that a lot of AI systems depend on. That&apos;s reason enough to build these servers with the same care you&apos;d give any other piece of infrastructure you expect to keep running.&lt;/p&gt;
&lt;h2&gt;Dive Deeper&lt;/h2&gt;
&lt;p&gt;Two more focused guides on building specific kinds of MCP server:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://rye.dev/blog/gopher-mcp-server/&quot;&gt;Gopher MCP Server: Bringing 1991&apos;s Internet to Modern AI&lt;/a&gt;&lt;/strong&gt; - protocol handlers, Gopher&apos;s history, and where alternative internet protocols still make sense&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://rye.dev/blog/openzim-mcp-server/&quot;&gt;OpenZIM MCP Server: Offline Knowledge for AI Assistants&lt;/a&gt;&lt;/strong&gt; - building offline knowledge systems, ZIM file handling, and AI assistants that work with no network&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;The complete implementations are on GitHub: &lt;a href=&quot;https://github.com/cameronrye/gopher-mcp&quot;&gt;gopher-mcp&lt;/a&gt; and &lt;a href=&quot;https://github.com/cameronrye/openzim-mcp&quot;&gt;openzim-mcp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>mcp</category><category>ai</category><category>protocols</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/building-mcp-servers-a-conceptual-visualization-of--featured-1764559800190.jpg" length="0" type="image/jpeg"/></item><item><title>Well-known URIs: Standardizing Web Metadata Discovery</title><link>https://rye.dev/blog/well-known-uris-standardizing-web-metadata/</link><guid isPermaLink="true">https://rye.dev/blog/well-known-uris-standardizing-web-metadata/</guid><description>I walk through RFC 8615&apos;s /.well-known/ namespace, where security contacts, OAuth endpoints, and password-change URLs finally got a standard home.</description><pubDate>Thu, 18 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/well-known-uris-standardizing-web-metadata-a-digital-beacon-illustrating--featured-1764560055263.jpg&quot; alt=&quot;Well-known URIs: Standardizing Web Metadata Discovery&quot; /&gt;&lt;/p&gt;&lt;p&gt;Every web developer has run into this: the metadata a site exposes lives in a different place on every site. Where do you find a site&apos;s security contact? How do you discover its OAuth endpoints? What about the password-change URL a password manager needs? The web is decentralized by design, and for most of its history that meant each site published this kind of service metadata however it happened to feel like.&lt;/p&gt;
&lt;p&gt;RFC 8615, published by the Internet Engineering Task Force (IETF), fixes that. It reserves a single path prefix, &lt;code&gt;/.well-known/&lt;/code&gt;, as the place a site publishes machine-readable information about its services and policies. If you know the suffix, you know the URL.&lt;/p&gt;
&lt;h2&gt;Understanding Well-known URIs&lt;/h2&gt;
&lt;p&gt;Well-known URIs are defined in &lt;a href=&quot;https://datatracker.ietf.org/doc/html/rfc8615&quot;&gt;RFC 8615&lt;/a&gt;. They carve out a namespace under the &lt;code&gt;/.well-known/&lt;/code&gt; path prefix where a site can expose machine-readable information about its services, policies, and capabilities.&lt;/p&gt;
&lt;p&gt;The problem behind the spec is a real one. More and more protocols need a piece of information to sit at a predictable location, no matter how a given host organizes the rest of its URLs. Pin that location down once and automated discovery stops being a per-site integration project.&lt;/p&gt;
&lt;h3&gt;The Technical Foundation&lt;/h3&gt;
&lt;p&gt;Well-known URIs follow a simple pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://example.com/.well-known/{service-name}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This structure provides several key advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Predictability&lt;/strong&gt;: Clients know exactly where to look for specific metadata&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Namespace Isolation&lt;/strong&gt;: The &lt;code&gt;.well-known&lt;/code&gt; prefix prevents conflicts with existing site structure&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extensibility&lt;/strong&gt;: New services can be added without affecting existing implementations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Origin Compatibility&lt;/strong&gt;: Standard HTTP mechanisms apply for access control&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/well-known-uris-standardizing-web-metadata-a-visual-comparison-between-th-1764560072585.jpg&quot; alt=&quot;A visual comparison between the chaotic, non-standardized approach and the single, standardized path offered by Well-known URIs.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;The Problem Well-known URIs Solve&lt;/h2&gt;
&lt;p&gt;Before standardization, discovering service metadata required ad-hoc approaches that varied significantly across implementations. Consider these common scenarios:&lt;/p&gt;
&lt;h3&gt;Security Contact Discovery&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Before Well-known URIs:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Multiple possible locations, no standard format
https://example.com/security
https://example.com/contact/security
https://example.com/about/security-team
https://example.com/responsible-disclosure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;With Well-known URIs:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Single, predictable location with standardized format
https://example.com/.well-known/security.txt
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;OAuth/OpenID Connect Discovery&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Before Well-known URIs:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Provider-specific discovery mechanisms
https://accounts.google.com/.well-known/openid_configuration
https://login.microsoftonline.com/common/.well-known/openid_configuration
# But many providers used different paths entirely
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;With Well-known URIs:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Standardized discovery endpoint
https://any-provider.com/.well-known/openid-configuration
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That predictability cuts integration work and lets tooling behave the same way against any provider.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/well-known-uris-standardizing-web-metadata-a-technical-illustration-showi-1764560089889.jpg&quot; alt=&quot;A technical illustration showing a server structure with a specific, highlighted location for metadata storage.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Implementation Architecture&lt;/h2&gt;
&lt;h3&gt;Server Configuration&lt;/h3&gt;
&lt;p&gt;Implementing well-known URIs requires configuring your web server to serve content from the &lt;code&gt;/.well-known/&lt;/code&gt; directory. Here are examples for common server configurations:&lt;/p&gt;
&lt;h4&gt;Apache Configuration&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# Enable .well-known directory
&amp;lt;Directory &quot;/var/www/html/.well-known&quot;&amp;gt;
    Options -Indexes
    AllowOverride None
    Require all granted

    # Set appropriate content types
    &amp;lt;Files &quot;security.txt&quot;&amp;gt;
        Header set Content-Type &quot;text/plain; charset=utf-8&quot;
    &amp;lt;/Files&amp;gt;

    &amp;lt;Files &quot;openid-configuration&quot;&amp;gt;
        Header set Content-Type &quot;application/json; charset=utf-8&quot;
    &amp;lt;/Files&amp;gt;
&amp;lt;/Directory&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Nginx Configuration&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;location /.well-known/ {
    root /var/www/html;

    # Security headers
    add_header X-Content-Type-Options nosniff;
    add_header Cache-Control &quot;public, max-age=3600&quot;;

    # Content type mapping
    location ~ \.txt$ {
        add_header Content-Type &quot;text/plain; charset=utf-8&quot;;
    }

    location ~ /openid-configuration$ {
        add_header Content-Type &quot;application/json; charset=utf-8&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Dynamic Implementation&lt;/h3&gt;
&lt;p&gt;For applications requiring dynamic well-known URI generation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Express.js implementation
const express = require(&apos;express&apos;);
const app = express();

// Well-known URI middleware
app.use(&apos;/.well-known&apos;, (req, res, next) =&amp;gt; {
    // Set security headers
    res.set({
        &apos;X-Content-Type-Options&apos;: &apos;nosniff&apos;,
        &apos;Cache-Control&apos;: &apos;public, max-age=3600&apos;
    });
    next();
});

// Security.txt endpoint
app.get(&apos;/.well-known/security.txt&apos;, (req, res) =&amp;gt; {
    res.type(&apos;text/plain&apos;);
    res.send(`Contact: security@example.com
Expires: 2025-12-31T23:59:59.000Z
Encryption: https://example.com/pgp-key.txt
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt`);
});

// OpenID Connect discovery
app.get(&apos;/.well-known/openid-configuration&apos;, (req, res) =&amp;gt; {
    res.json({
        issuer: &apos;https://example.com&apos;,
        authorization_endpoint: &apos;https://example.com/auth&apos;,
        token_endpoint: &apos;https://example.com/token&apos;,
        userinfo_endpoint: &apos;https://example.com/userinfo&apos;,
        jwks_uri: &apos;https://example.com/.well-known/jwks.json&apos;,
        response_types_supported: [&apos;code&apos;, &apos;token&apos;, &apos;id_token&apos;],
        subject_types_supported: [&apos;public&apos;],
        id_token_signing_alg_values_supported: [&apos;RS256&apos;]
    });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Essential Well-known URIs&lt;/h2&gt;
&lt;p&gt;IANA keeps the registry of standardized well-known URIs. A few that come up most often:&lt;/p&gt;
&lt;h3&gt;Security and Policy&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;security.txt&lt;/strong&gt; - Security contact information and vulnerability disclosure policies&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Contact: security@example.com
Expires: 2025-12-31T23:59:59.000Z
Encryption: https://example.com/pgp-key.txt
Policy: https://example.com/security-policy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;change-password&lt;/strong&gt; - Direct link to password change functionality for password managers&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://example.com/.well-known/change-password
# Redirects to: https://example.com/account/password
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Authentication and Identity&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;openid-configuration&lt;/strong&gt; - OAuth 2.0/OpenID Connect provider metadata
&lt;strong&gt;webfinger&lt;/strong&gt; - Identity discovery for federated protocols
&lt;strong&gt;host-meta&lt;/strong&gt; - General host metadata in XML format&lt;/p&gt;
&lt;h3&gt;Application Integration&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;apple-app-site-association&lt;/strong&gt; - iOS Universal Links configuration
&lt;strong&gt;assetlinks.json&lt;/strong&gt; - Android App Links verification
&lt;strong&gt;matrix&lt;/strong&gt; - Matrix protocol server discovery&lt;/p&gt;
&lt;h3&gt;Development and Automation&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;robots.txt&lt;/strong&gt; equivalent URIs for specialized crawlers
&lt;strong&gt;nodeinfo&lt;/strong&gt; - Federated social network metadata
&lt;strong&gt;timezone&lt;/strong&gt; - Time zone data distribution&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://rye.dev/images/blog/generated/well-known-uris-standardizing-web-metadata-an-abstract-representation-of--1764560106990.jpg&quot; alt=&quot;An abstract representation of security policy discovery and automated verification.&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Security Considerations&lt;/h2&gt;
&lt;p&gt;Well-known URIs come with security benefits and a few risks worth thinking about:&lt;/p&gt;
&lt;h3&gt;Security Benefits&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Standardized Security Contact&lt;/strong&gt;: The &lt;code&gt;security.txt&lt;/code&gt; standard provides a reliable way for security researchers to report vulnerabilities&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reduced Attack Surface&lt;/strong&gt;: Centralized metadata reduces the need for custom discovery mechanisms&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Improved Transparency&lt;/strong&gt;: Standardized policy disclosure enhances security posture visibility&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Potential Risks&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Information Disclosure&lt;/strong&gt;: Well-known URIs may reveal sensitive information about system architecture&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attack Vector Expansion&lt;/strong&gt;: Improperly configured endpoints could expose internal services&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache Poisoning&lt;/strong&gt;: Incorrect caching headers could lead to stale or malicious metadata&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Best Practices&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Security-focused well-known configuration
location /.well-known/ {
    # Prevent directory traversal
    location ~ \.\. {
        deny all;
    }

    # Rate limiting
    limit_req zone=wellknown burst=10 nodelay;

    # Security headers
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options DENY;
    add_header Referrer-Policy strict-origin-when-cross-origin;

    # Appropriate caching
    expires 1h;
    add_header Cache-Control &quot;public, immutable&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Practical Implementation Guide&lt;/h2&gt;
&lt;h3&gt;Step 1: Create the Well-known Directory Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Create the directory structure
mkdir -p /var/www/html/.well-known

# Set appropriate permissions
chmod 755 /var/www/html/.well-known
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 2: Implement Security.txt&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Create security.txt file
cat &amp;gt; /var/www/html/.well-known/security.txt &amp;lt;&amp;lt; EOF
Contact: mailto:security@example.com
Contact: https://example.com/security-contact
Expires: 2025-12-31T23:59:59.000Z
Encryption: https://example.com/pgp-key.txt
Acknowledgments: https://example.com/security-acknowledgments
Preferred-Languages: en, es
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/vulnerability-disclosure-policy
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3: Validation and Testing&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;
Well-known URI validator script
&quot;&quot;&quot;
import requests
import json
from urllib.parse import urljoin

def validate_security_txt(base_url):
    &quot;&quot;&quot;Validate security.txt implementation&quot;&quot;&quot;
    url = urljoin(base_url, &apos;/.well-known/security.txt&apos;)

    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()

        # Check content type
        content_type = response.headers.get(&apos;content-type&apos;, &apos;&apos;)
        if &apos;text/plain&apos; not in content_type:
            print(f&quot;Warning: Unexpected content-type: {content_type}&quot;)

        # Parse and validate required fields
        content = response.text
        required_fields = [&apos;Contact&apos;, &apos;Expires&apos;]

        for field in required_fields:
            if field not in content:
                print(f&quot;Error: Missing required field: {field}&quot;)
                return False

        print(&quot;✓ security.txt validation passed&quot;)
        return True

    except requests.RequestException as e:
        print(f&quot;Error accessing security.txt: {e}&quot;)
        return False

def validate_openid_configuration(base_url):
    &quot;&quot;&quot;Validate OpenID Connect configuration&quot;&quot;&quot;
    url = urljoin(base_url, &apos;/.well-known/openid-configuration&apos;)

    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()

        # Check content type
        content_type = response.headers.get(&apos;content-type&apos;, &apos;&apos;)
        if &apos;application/json&apos; not in content_type:
            print(f&quot;Warning: Unexpected content-type: {content_type}&quot;)

        # Parse JSON and validate required fields
        config = response.json()
        required_fields = [
            &apos;issuer&apos;, &apos;authorization_endpoint&apos;,
            &apos;token_endpoint&apos;, &apos;jwks_uri&apos;
        ]

        for field in required_fields:
            if field not in config:
                print(f&quot;Error: Missing required field: {field}&quot;)
                return False

        print(&quot;✓ OpenID Connect configuration validation passed&quot;)
        return True

    except (requests.RequestException, json.JSONDecodeError) as e:
        print(f&quot;Error accessing OpenID configuration: {e}&quot;)
        return False

if __name__ == &quot;__main__&quot;:
    base_url = &quot;https://example.com&quot;
    validate_security_txt(base_url)
    validate_openid_configuration(base_url)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Advanced Use Cases&lt;/h2&gt;
&lt;h3&gt;Content Delivery Network Integration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// Cloudflare Workers implementation
addEventListener(&apos;fetch&apos;, event =&amp;gt; {
    event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
    const url = new URL(request.url)

    // Handle well-known URIs
    if (url.pathname.startsWith(&apos;/.well-known/&apos;)) {
        return handleWellKnownRequest(url.pathname)
    }

    // Forward other requests
    return fetch(request)
}

async function handleWellKnownRequest(pathname) {
    const wellKnownRoutes = {
        &apos;/.well-known/security.txt&apos;: () =&amp;gt; new Response(
            generateSecurityTxt(),
            {
                headers: {
                    &apos;Content-Type&apos;: &apos;text/plain; charset=utf-8&apos;,
                    &apos;Cache-Control&apos;: &apos;public, max-age=3600&apos;
                }
            }
        ),

        &apos;/.well-known/openid-configuration&apos;: () =&amp;gt; new Response(
            JSON.stringify(generateOpenIDConfig()),
            {
                headers: {
                    &apos;Content-Type&apos;: &apos;application/json; charset=utf-8&apos;,
                    &apos;Cache-Control&apos;: &apos;public, max-age=3600&apos;
                }
            }
        )
    }

    const handler = wellKnownRoutes[pathname]
    if (handler) {
        return handler()
    }

    return new Response(&apos;Not Found&apos;, { status: 404 })
}

function generateSecurityTxt() {
    return `Contact: security@example.com
Expires: ${new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()}
Encryption: https://example.com/pgp-key.txt
Canonical: https://example.com/.well-known/security.txt`
}

function generateOpenIDConfig() {
    return {
        issuer: &apos;https://example.com&apos;,
        authorization_endpoint: &apos;https://example.com/oauth/authorize&apos;,
        token_endpoint: &apos;https://example.com/oauth/token&apos;,
        userinfo_endpoint: &apos;https://example.com/oauth/userinfo&apos;,
        jwks_uri: &apos;https://example.com/.well-known/jwks.json&apos;,
        response_types_supported: [&apos;code&apos;],
        subject_types_supported: [&apos;public&apos;],
        id_token_signing_alg_values_supported: [&apos;RS256&apos;]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Monitoring and Analytics&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Well-known URI monitoring script
import requests
import time
import logging
from datetime import datetime, timedelta

class WellKnownMonitor:
    def __init__(self, base_url):
        self.base_url = base_url
        self.logger = logging.getLogger(__name__)

    def check_endpoint(self, path, expected_content_type):
        &quot;&quot;&quot;Monitor a specific well-known endpoint&quot;&quot;&quot;
        url = f&quot;{self.base_url}/.well-known/{path}&quot;

        try:
            start_time = time.time()
            response = requests.get(url, timeout=10)
            response_time = time.time() - start_time

            # Log metrics
            self.logger.info(f&quot;Endpoint: {path}&quot;)
            self.logger.info(f&quot;Status: {response.status_code}&quot;)
            self.logger.info(f&quot;Response Time: {response_time:.3f}s&quot;)
            self.logger.info(f&quot;Content-Type: {response.headers.get(&apos;content-type&apos;)}&quot;)

            # Validate content type
            if expected_content_type not in response.headers.get(&apos;content-type&apos;, &apos;&apos;):
                self.logger.warning(f&quot;Unexpected content-type for {path}&quot;)

            # Check for security headers
            security_headers = [
                &apos;X-Content-Type-Options&apos;,
                &apos;Cache-Control&apos;
            ]

            for header in security_headers:
                if header not in response.headers:
                    self.logger.warning(f&quot;Missing security header: {header}&quot;)

            return response.status_code == 200

        except requests.RequestException as e:
            self.logger.error(f&quot;Error checking {path}: {e}&quot;)
            return False

    def run_checks(self):
        &quot;&quot;&quot;Run all well-known URI checks&quot;&quot;&quot;
        endpoints = [
            (&apos;security.txt&apos;, &apos;text/plain&apos;),
            (&apos;openid-configuration&apos;, &apos;application/json&apos;),
            (&apos;change-password&apos;, &apos;text/html&apos;)
        ]

        results = {}
        for path, content_type in endpoints:
            results[path] = self.check_endpoint(path, content_type)

        return results

# Usage
if __name__ == &quot;__main__&quot;:
    logging.basicConfig(level=logging.INFO)
    monitor = WellKnownMonitor(&quot;https://example.com&quot;)
    results = monitor.run_checks()
    print(f&quot;Check results: {results}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Registry and Standardization Process&lt;/h2&gt;
&lt;p&gt;The Internet Assigned Numbers Authority (IANA) maintains the official &lt;a href=&quot;https://www.iana.org/assignments/well-known-uris/&quot;&gt;Well-Known URIs registry&lt;/a&gt;, which serves as the authoritative source for standardized well-known URI suffixes. This registry ensures global coordination and prevents conflicts between different specifications.&lt;/p&gt;
&lt;h3&gt;Proposing New Well-known URIs&lt;/h3&gt;
&lt;p&gt;To propose a new well-known URI, you must follow the IETF specification process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Draft Specification&lt;/strong&gt;: Create an Internet-Draft describing the proposed URI and its purpose&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Community Review&lt;/strong&gt;: Submit to the &lt;code&gt;wellknown-uri-review@ietf.org&lt;/code&gt; mailing list&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IANA Registration&lt;/strong&gt;: Complete the registration template with required fields&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expert Review&lt;/strong&gt;: IANA designated experts review the proposal&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publication&lt;/strong&gt;: Upon approval, the URI is added to the official registry&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Registration Template&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;URI suffix: example-service
Change controller: IETF
Specification document: RFC 8615, Section 3.1
Status: permanent
Related information: Optional additional context
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Current Adoption and Future Trends&lt;/h2&gt;
&lt;p&gt;Adoption is broad at this point:&lt;/p&gt;
&lt;h3&gt;Industry Adoption&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Major Platforms&lt;/strong&gt;: Google, Microsoft, Apple, and other tech giants extensively use well-known URIs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security Tools&lt;/strong&gt;: Security scanners and vulnerability management platforms rely on &lt;code&gt;security.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identity Providers&lt;/strong&gt;: OAuth and OpenID Connect providers universally implement discovery endpoints&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Password Managers&lt;/strong&gt;: Modern password managers use &lt;code&gt;change-password&lt;/code&gt; to send people straight to the right page&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Emerging Trends&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Federated Protocols&lt;/strong&gt;: Matrix, Mastodon, and other federated platforms use well-known URIs for server discovery&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privacy Standards&lt;/strong&gt;: Global Privacy Control (GPC) and similar privacy frameworks adopt well-known URIs&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI and Automation&lt;/strong&gt;: Machine learning platforms use well-known URIs for model and API discovery&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IoT Integration&lt;/strong&gt;: Internet of Things devices increasingly expose metadata via well-known URIs&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Performance and Caching Considerations&lt;/h2&gt;
&lt;p&gt;Caching strategy matters here:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Optimal caching headers for well-known URIs
Cache-Control: public, max-age=3600, immutable
ETag: &quot;v1.2.3-20250114&quot;
Last-Modified: Tue, 14 Jan 2025 10:00:00 GMT
Vary: Accept-Encoding
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;CDN Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# CloudFront distribution configuration
wellknown_cache_behavior:
  path_pattern: &quot;/.well-known/*&quot;
  target_origin_id: &quot;primary-origin&quot;
  viewer_protocol_policy: &quot;redirect-to-https&quot;
  cache_policy:
    default_ttl: 3600
    max_ttl: 86400
    min_ttl: 0
  compress: true
  headers:
    - &quot;Content-Type&quot;
    - &quot;Cache-Control&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Troubleshooting Common Issues&lt;/h2&gt;
&lt;h3&gt;CORS Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// Express.js CORS configuration for well-known URIs
app.use(&apos;/.well-known&apos;, cors({
    origin: true,
    methods: [&apos;GET&apos;, &apos;HEAD&apos;],
    allowedHeaders: [&apos;Content-Type&apos;],
    maxAge: 3600
}));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Content-Type Issues&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Apache MIME type configuration
&amp;lt;Files &quot;security.txt&quot;&amp;gt;
    ForceType text/plain
&amp;lt;/Files&amp;gt;

&amp;lt;Files &quot;openid-configuration&quot;&amp;gt;
    ForceType application/json
&amp;lt;/Files&amp;gt;

&amp;lt;Files &quot;jwks.json&quot;&amp;gt;
    ForceType application/json
&amp;lt;/Files&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;SSL/TLS Considerations&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Nginx SSL configuration for well-known URIs
location /.well-known/ {
    # Allow HTTP for ACME challenges
    if ($request_uri ~ &quot;^/.well-known/acme-challenge/&quot;) {
        # ACME challenge can use HTTP
    }

    # Force HTTPS for other well-known URIs
    if ($scheme = http) {
        return 301 https://$server_name$request_uri;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Integration with Modern Development Workflows&lt;/h2&gt;
&lt;h3&gt;Docker Implementation&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Dockerfile for well-known URI server
FROM nginx:alpine

# Copy well-known files
COPY .well-known/ /usr/share/nginx/html/.well-known/

# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf

# Set appropriate permissions
RUN chmod -R 644 /usr/share/nginx/html/.well-known/

EXPOSE 80 443

CMD [&quot;nginx&quot;, &quot;-g&quot;, &quot;daemon off;&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Kubernetes Deployment&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: wellknown-config
data:
  security.txt: |
    Contact: security@example.com
    Expires: 2025-12-31T23:59:59.000Z
    Canonical: https://example.com/.well-known/security.txt

  openid-configuration: |
    {
      &quot;issuer&quot;: &quot;https://example.com&quot;,
      &quot;authorization_endpoint&quot;: &quot;https://example.com/oauth/authorize&quot;,
      &quot;token_endpoint&quot;: &quot;https://example.com/oauth/token&quot;
    }

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: wellknown-server
spec:
  replicas: 2
  selector:
    matchLabels:
      app: wellknown-server
  template:
    metadata:
      labels:
        app: wellknown-server
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
        ports:
        - containerPort: 80
        volumeMounts:
        - name: wellknown-volume
          mountPath: /usr/share/nginx/html/.well-known
      volumes:
      - name: wellknown-volume
        configMap:
          name: wellknown-config
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Conclusion and Strategic Recommendations&lt;/h2&gt;
&lt;p&gt;The payoff of well-known URIs is boring in the best way: metadata discovery stops being guesswork. Less integration glue, a clearer security posture, tooling that behaves the same everywhere.&lt;/p&gt;
&lt;p&gt;If you&apos;re rolling these out, here&apos;s what I&apos;d do:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Start with Security&lt;/strong&gt;: Implement &lt;code&gt;security.txt&lt;/code&gt; as your first well-known URI to improve security posture&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plan for Scale&lt;/strong&gt;: Design your implementation to handle high traffic and provide appropriate caching&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitor Continuously&lt;/strong&gt;: Implement monitoring to ensure well-known URIs remain accessible and current&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Follow Standards&lt;/strong&gt;: Adhere to IANA registry specifications and IETF best practices&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consider Privacy&lt;/strong&gt;: Evaluate what information you expose through well-known URIs&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The point isn&apos;t the individual files. It&apos;s that discovery stops depending on convention and starts depending on a spec. Implement these and you&apos;re part of a web that machines can navigate without a scraper and a prayer.&lt;/p&gt;
&lt;p&gt;As more traffic comes from automated clients rather than people, that predictability is worth a little more every year.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Want to explore more web standards and protocols? Check out my post on &lt;a href=&quot;https://rye.dev/blog/building-mcp-servers/&quot;&gt;building MCP servers&lt;/a&gt; for more on connecting open protocols to modern AI.&lt;/em&gt;&lt;/p&gt;
</content:encoded><category>web-standards</category><category>protocols</category><category>security</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/images/blog/generated/well-known-uris-standardizing-web-metadata-a-digital-beacon-illustrating--featured-1764560055263.jpg" length="0" type="image/jpeg"/></item><item><title>Project: Aranet</title><link>https://rye.dev/projects/aranet/</link><guid isPermaLink="true">https://rye.dev/projects/aranet/</guid><description>Rust libraries and tools for Aranet environmental sensors (CO2, temperature, humidity, radon, radiation) via Bluetooth Low Energy.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/aranet-detail-light.webp&quot; alt=&quot;Aranet screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;Aranet is a Rust implementation for connecting to Aranet environmental sensors via Bluetooth Low Energy. The workspace covers the full stack: low-level BLE communication, the user interfaces, and the production monitoring integrations.&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multi-sensor support&lt;/strong&gt;: CO2, temperature, pressure, humidity, radon, and radiation readings from four Aranet device families&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Historical data download&lt;/strong&gt;: Resumable checkpoints, date filters, and incremental sync with local SQLite storage&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Passive monitoring&lt;/strong&gt;: Read BLE advertisements without establishing a device connection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terminal dashboard&lt;/strong&gt;: Sparkline charts, CO2/radon threshold alerts, vim keybindings, and CSV export via ratatui&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Desktop GUI&lt;/strong&gt;: Multi-panel egui interface with alert system and system tray&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background service&lt;/strong&gt;: REST API with WebSocket streaming for real-time data&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Observability&lt;/strong&gt;: Prometheus metrics endpoint with pre-built Grafana dashboard template&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IoT integration&lt;/strong&gt;: MQTT publisher with Home Assistant auto-discovery, InfluxDB export, webhook notifications&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why Rust&lt;/h2&gt;
&lt;p&gt;Environmental monitoring runs continuously on modest hardware. Rust&apos;s zero-cost abstractions and memory safety make it the right choice for a long-running BLE daemon that needs to be reliable without consuming resources. The workspace structure keeps each concern in its own crate while sharing the core protocol implementation across every interface.&lt;/p&gt;
&lt;h2&gt;Architecture decisions&lt;/h2&gt;
&lt;p&gt;The seven-crate workspace follows a clear layering:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;aranet-types&lt;/strong&gt; provides platform-agnostic shared types&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;aranet-core&lt;/strong&gt; handles all BLE communication and protocol parsing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;aranet-store&lt;/strong&gt; manages local SQLite persistence with deduplication and incremental sync&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;aranet-service&lt;/strong&gt; adds the daemon layer with REST, WebSocket, MQTT, Prometheus, and webhook integrations&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;aranet-cli&lt;/strong&gt;, &lt;strong&gt;aranet-tui&lt;/strong&gt;, and &lt;strong&gt;aranet-gui&lt;/strong&gt; are thin interfaces over the core and store crates&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This separation means adding a new integration or interface requires no changes to the sensor communication layer.&lt;/p&gt;
&lt;h2&gt;Distribution&lt;/h2&gt;
&lt;p&gt;The project ships through several channels:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Homebrew&lt;/strong&gt;: &lt;code&gt;brew tap cameronrye/aranet &amp;amp;&amp;amp; brew install aranet&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;crates.io&lt;/strong&gt;: &lt;code&gt;cargo install aranet-cli&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Releases&lt;/strong&gt;: Shell and PowerShell installers, macOS DMG bundles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker Compose&lt;/strong&gt;: Pre-configured stack with Prometheus and Grafana&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Outcome&lt;/h2&gt;
&lt;p&gt;Embedded sensor tooling does not need to be locked behind mobile apps or cloud services. Building up from the BLE packets through storage, APIs, and dashboards in a single Rust workspace gives users full control of their environmental data, and it runs reliably enough to sit under real monitoring infrastructure.&lt;/p&gt;
</content:encoded><category>Rust</category><category>BLE</category><category>SQLite</category><category>Tokio</category><category>egui</category><category>ratatui</category><category>Prometheus</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/aranet-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Circle of Fifths</title><link>https://rye.dev/projects/circle-of-fifths/</link><guid isPermaLink="true">https://rye.dev/projects/circle-of-fifths/</guid><description>Learning music theory through an interactive Circle of Fifths visualization. This educational tool combines visual design with audio feedback to help users understand key relationships, scales, and chord progressions.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/circle-of-fifths-detail-light.webp&quot; alt=&quot;Circle of Fifths screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import CircleOfFifthsDemo from &apos;../../components/demos/CircleOfFifthsDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;A static diagram of the Circle of Fifths can show you that adjacent keys sit a perfect fifth apart, but it cannot let you hear it. This project closes that gap: click any of the twelve keys and the Web Audio API synthesizes the pitch in real time, connecting the circle&apos;s geometry to the harmonic relationships it encodes.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;CircleOfFifthsDemo client:visible /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Interactive Visualization&lt;/strong&gt;: Click and explore the circle of fifths&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audio Feedback&lt;/strong&gt;: Hear scales and chords using Web Audio API&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Key Relationships&lt;/strong&gt;: Understand relative majors/minors and chord progressions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Educational Design&lt;/strong&gt;: Clear visual representation of music theory concepts&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;p&gt;The Web Audio API provides real-time audio synthesis:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const A4_FREQUENCY = 440; // Hz
const SEMITONE_RATIO = Math.pow(2, 1/12);

function noteToFrequency(note, octave) {
  const noteIndex = [&apos;C&apos;, &apos;C#&apos;, &apos;D&apos;, &apos;D#&apos;, &apos;E&apos;, &apos;F&apos;, 
                     &apos;F#&apos;, &apos;G&apos;, &apos;G#&apos;, &apos;A&apos;, &apos;A#&apos;, &apos;B&apos;].indexOf(note);
  const semitonesFromA4 = (octave - 4) * 12 + (noteIndex - 9);
  return A4_FREQUENCY * Math.pow(SEMITONE_RATIO, semitonesFromA4);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each key in the circle is positioned 30 degrees apart, representing the perfect fifth interval that defines the circle&apos;s structure.&lt;/p&gt;
&lt;h2&gt;What was hard&lt;/h2&gt;
&lt;p&gt;The audio side held the real surprises. Modern browsers start every &lt;code&gt;AudioContext&lt;/code&gt; in a suspended state to block autoplay, so the app resumes the context on the first user interaction. Without that pre-warming, the first click on a key would produce silence. Raw oscillator starts and stops also click audibly; shaping each note with a gain envelope that ramps down exponentially makes the tones sound like an instrument instead of a test signal. On the visual side, the circular layout is pure trigonometry: twelve keys at 30-degree intervals with a -90-degree offset to put C at the top. But every key still needed a 44px minimum touch target so the circle stays usable on phones.&lt;/p&gt;
</content:encoded><category>JavaScript</category><category>Web Audio API</category><category>CSS</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/circle-of-fifths-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Clarissa</title><link>https://rye.dev/projects/clarissa/</link><guid isPermaLink="true">https://rye.dev/projects/clarissa/</guid><description>An AI-powered terminal assistant that executes tools, built with Bun and Ink, featuring streaming responses, MCP integration, and session persistence.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/clarissa-detail-light.webp&quot; alt=&quot;Clarissa screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import MCPToolDemo from &apos;../../components/demos/MCPToolDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;export const clarissaTools = [
{
name: &apos;read_file&apos;,
description: &apos;Read the contents of a file&apos;,
request: { tool: &apos;read_file&apos;, path: &apos;./src/agent.ts&apos; },
response: { content: &apos;import { llmClient } from &quot;./llm/client.ts&quot;;\nimport { toolRegistry } from &quot;./tools/index.ts&quot;;\n...&apos;, lines: 203 }
},
{
name: &apos;bash&apos;,
description: &apos;Execute shell commands&apos;,
request: { tool: &apos;bash&apos;, command: &apos;git status --short&apos; },
response: { stdout: &apos; M src/agent.ts\n?? src/tools/new-tool.ts&apos;, exitCode: 0 }
},
{
name: &apos;git_diff&apos;,
description: &apos;Show changes in the repository&apos;,
request: { tool: &apos;git_diff&apos;, staged: false },
response: { diff: &apos;diff --git a/src/agent.ts b/src/agent.ts\n@@ -1,5 +1,6 @@\n+import { memoryManager } from &quot;./memory&quot;;&apos;, files: 1 }
},
{
name: &apos;web_fetch&apos;,
description: &apos;Fetch and parse web pages&apos;,
request: { tool: &apos;web_fetch&apos;, url: &apos;https://example.com&apos; },
response: { title: &apos;Example Domain&apos;, content: &apos;This domain is for use in illustrative examples...&apos;, status: 200 }
}
];&lt;/p&gt;
&lt;p&gt;Clarissa is a command-line AI agent built with &lt;a href=&quot;https://bun.sh&quot;&gt;Bun&lt;/a&gt; and &lt;a href=&quot;https://github.com/vadimdemedes/ink&quot;&gt;Ink&lt;/a&gt;. It provides a conversational interface powered by &lt;a href=&quot;https://openrouter.ai&quot;&gt;OpenRouter&lt;/a&gt;, enabling access to various LLMs like Claude, GPT-4, Gemini, and more. The agent can execute tools, manage files, run shell commands, and integrate with external services via the Model Context Protocol (MCP).&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;MCPToolDemo client:visible serverName=&quot;clarissa&quot; tools={clarissaTools} /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ReAct Agent Pattern&lt;/strong&gt;: Implements the Reasoning + Acting loop for multi-step tasks&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-model Support&lt;/strong&gt;: Switch between Claude, GPT-4, Gemini, Llama, and more via OpenRouter&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool Execution&lt;/strong&gt;: Built-in tools for files, Git, shell commands, and web fetching&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP Integration&lt;/strong&gt;: Extend with external tools through the Model Context Protocol&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Session Persistence&lt;/strong&gt;: Save and restore conversation history across sessions&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory System&lt;/strong&gt;: Remember facts across sessions with &lt;code&gt;/remember&lt;/code&gt; and &lt;code&gt;/memories&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Management&lt;/strong&gt;: Automatic token tracking and context truncation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool Confirmation&lt;/strong&gt;: Approve or reject potentially dangerous operations&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The ReAct Loop&lt;/h2&gt;
&lt;p&gt;The agent implements an iterative loop where it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Receives user input and sends it to the LLM with available tool definitions&lt;/li&gt;
&lt;li&gt;If the LLM responds with tool calls, executes them and feeds results back&lt;/li&gt;
&lt;li&gt;Repeats until the LLM provides a final answer without requesting tools&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This pattern enables complex multi-step tasks while maintaining safety through tool confirmation.&lt;/p&gt;
&lt;h2&gt;Usage Modes&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# Interactive mode
clarissa

# One-shot mode
clarissa &quot;What files are in this directory?&quot;

# Piped input
git diff | clarissa &quot;Write a commit message for these changes&quot;
&lt;/code&gt;&lt;/pre&gt;
</content:encoded><category>TypeScript</category><category>Bun</category><category>Ink</category><category>React</category><category>MCP</category><category>OpenRouter</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/clarissa-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: ClarissaBot</title><link>https://rye.dev/projects/clarissabot/</link><guid isPermaLink="true">https://rye.dev/projects/clarissabot/</guid><description>AI-powered vehicle safety assistant that queries NHTSA data in real-time. Check recalls, safety ratings, and consumer complaints through natural conversation.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/clarissabot-detail-light.webp&quot; alt=&quot;ClarissaBot screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;ClarissaBot is a conversational AI assistant that helps users understand vehicle safety information. Rather than training a model on static data, it uses Azure OpenAI&apos;s function calling to query live NHTSA (National Highway Traffic Safety Administration) data in real-time.&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Natural Language Queries&lt;/strong&gt;: Ask about recalls, safety ratings, or complaints in plain English&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-time Data&lt;/strong&gt;: Queries live NHTSA APIs for current information&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VIN Decoding&lt;/strong&gt;: Automatically identifies vehicles from VIN numbers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Streaming Responses&lt;/strong&gt;: Token-by-token delivery via Server-Sent Events&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Awareness&lt;/strong&gt;: Remembers which vehicles you&apos;re discussing&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;NHTSA Tools&lt;/h2&gt;
&lt;p&gt;The agent has access to five specialized tools:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check_recalls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Find recall campaigns affecting a vehicle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_complaints&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;View consumer-reported problems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_safety_rating&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NCAP crash test ratings (1-5 stars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;decode_vin&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extract year/make/model from VIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check_investigations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active NHTSA defect investigations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Technical Architecture&lt;/h2&gt;
&lt;p&gt;The backend is built with .NET 10 and uses Azure AI Foundry (Azure OpenAI) for the AI capabilities:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var completion = await _chatClient.CompleteChatStreamingAsync(
    messages, 
    options, 
    cancellationToken
);

await foreach (var update in completion)
{
    foreach (var contentPart in update.ContentUpdate)
    {
        yield return new ContentChunkEvent(contentPart.Text);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The React frontend connects via SSE for real-time streaming, showing tool calls and responses as they happen.&lt;/p&gt;
&lt;h2&gt;Reinforcement Fine-Tuning&lt;/h2&gt;
&lt;p&gt;The project includes a complete RFT (Reinforcement Fine-Tuning) pipeline with 502 training examples and a Python grader that validates responses against live NHTSA data. This enables training specialized models that stay accurate as vehicle safety data evolves.&lt;/p&gt;
&lt;h2&gt;Infrastructure&lt;/h2&gt;
&lt;p&gt;Deployed on Azure Container Apps with infrastructure defined in Bicep:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Managed Identity&lt;/strong&gt;: RBAC-based authentication to Azure OpenAI, no API keys&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto-scaling&lt;/strong&gt;: Scale to zero when idle, burst for traffic&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;: Application Insights for observability&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Container Registry&lt;/strong&gt;: ACR for image management&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>.NET</category><category>React</category><category>TypeScript</category><category>Azure OpenAI</category><category>Azure Container Apps</category><category>Bicep</category><category>SSE</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/clarissabot-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: ClaytonRye.com</title><link>https://rye.dev/projects/claytonrye-com/</link><guid isPermaLink="true">https://rye.dev/projects/claytonrye-com/</guid><description>A digital archive of my father&apos;s five decades of documentary filmmaking, built for his 77th birthday and designed for long-term preservation.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/claytonrye-com-detail-light.webp&quot; alt=&quot;ClaytonRye.com screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import AccessibilityDemo from &apos;../../components/demos/AccessibilityDemo.astro&apos;;&lt;/p&gt;
&lt;p&gt;ClaytonRye.com is a digital archive of Clayton Rye&apos;s work as an award-winning documentary filmmaker, Vietnam War veteran, and Professor Emeritus at Ferris State University. I built it as a birthday gift. It gathers five decades of documentary work focused on civil rights and social justice.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;AccessibilityDemo /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Filmography&lt;/strong&gt;: Documentation of his award-winning documentaries, including the Detroit Civil Rights Trilogy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Archival Design&lt;/strong&gt;: Built for long-term preservation and accessibility&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance Optimized&lt;/strong&gt;: Static site generation with minimal JavaScript&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WCAG AA Compliant&lt;/strong&gt;: Full accessibility with semantic HTML and keyboard navigation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured Data&lt;/strong&gt;: Schema.org markup for discoverability by researchers and educators&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Documentary Legacy&lt;/h2&gt;
&lt;p&gt;Clayton Rye&apos;s films preserve historical records:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ten Vietnam Vets&lt;/strong&gt;: First-hand accounts from fellow veterans, now in university archives&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Jim Crow&apos;s Museum&lt;/strong&gt;: PBS documentary exploring racist memorabilia as educational tools&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detroit Civil Rights Trilogy&lt;/strong&gt;: Three pivotal stories from Michigan&apos;s civil rights history&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;p&gt;The site demonstrates several patterns for archival web design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Astro Static Generation&lt;/strong&gt;: Pre-rendered HTML for instant loading&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Responsive Images&lt;/strong&gt;: Modern formats (WebP, AVIF) with optimization&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Theme Switching&lt;/strong&gt;: Light/dark/system mode with localStorage persistence&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Video Integration&lt;/strong&gt;: Lightweight lite-youtube component for embedded content&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Read the full story: &lt;a href=&quot;https://rye.dev/blog/building-claytonrye-com-for-my-fathers-77th-birthday/&quot;&gt;Building ClaytonRye.com for My Father&apos;s 77th Birthday&lt;/a&gt;&lt;/p&gt;
</content:encoded><category>Astro</category><category>TypeScript</category><category>Tailwind CSS</category><category>Schema.org</category><category>Accessibility</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/claytonrye-com-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: DosKit</title><link>https://rye.dev/projects/doskit/</link><guid isPermaLink="true">https://rye.dev/projects/doskit/</guid><description>Runs classic DOS software and demos directly in modern browsers using WebAssembly. No setup, no configuration.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/doskit-detail-light.webp&quot; alt=&quot;DosKit screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import DOSKitDemo from &apos;../../components/demos/DOSKitDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;DosKit brings classic DOS software to modern browsers through WebAssembly emulation. Nothing to install and nothing to configure: you click, and it runs.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;DOSKitDemo client:visible /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Instant Access&lt;/strong&gt;: One-click access to DOS software&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Browser-Based&lt;/strong&gt;: Runs entirely in the browser via WebAssembly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Curated Library&lt;/strong&gt;: Classic demos, games, and applications&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mobile Friendly&lt;/strong&gt;: Touch controls for mobile devices&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;p&gt;DosKit uses js-dos, a WebAssembly port of DOSBox, to run x86 DOS binaries directly in the browser:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const dos = await Dos(canvas, {
  wdosboxUrl: &apos;/wdosbox.js&apos;,
  autoStart: true
});

await dos.fs.extract(&apos;/software.zip&apos;);
await dos.main([&apos;-c&apos;, &apos;SOFTWARE.EXE&apos;]);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The emulator handles CPU emulation, memory management, and audio/video output, providing an authentic DOS experience without any native installation.&lt;/p&gt;
&lt;h2&gt;Why this was worth building&lt;/h2&gt;
&lt;p&gt;A lot of classic software preservation efforts are technically impressive but still inaccessible to most people. If someone has to learn emulator configuration before they can try a demo, the preservation effort has not fully crossed into public accessibility. DosKit was built to reduce that gap to a single click.&lt;/p&gt;
&lt;h2&gt;Product and engineering tradeoffs&lt;/h2&gt;
&lt;p&gt;Running old software in the browser sounds simple until you account for startup time, asset packaging, keyboard handling, audio behavior, and mobile input. The experience had to feel immediate enough for casual exploration while still preserving the character of the original software.&lt;/p&gt;
&lt;p&gt;That led to a few practical decisions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;keep the launch path short so users reach the software quickly&lt;/li&gt;
&lt;li&gt;package software and emulator configuration together instead of expecting manual setup&lt;/li&gt;
&lt;li&gt;support touch controls for devices that do not have a physical keyboard&lt;/li&gt;
&lt;li&gt;curate the library so the first-run experience highlights software that is historically interesting and technically representative&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Product framing&lt;/h2&gt;
&lt;p&gt;The hard part of DosKit was product framing, not emulation. WebAssembly turns a fiddly setup process into a plain web page, and a curated library keeps a niche corner of computing approachable.&lt;/p&gt;
&lt;h2&gt;Outcome&lt;/h2&gt;
&lt;p&gt;What you get is a preservation product that still feels current: fast launch, zero installation, cross-device support, and a clear reason the software matters. The emulator core, the software, and its configuration all ship together as one web page that loads in under three seconds.&lt;/p&gt;
</content:encoded><category>WebAssembly</category><category>JavaScript</category><category>DOS</category><category>Emulation</category><category>js-dos</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/doskit-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Electromagnetic Spectrum Explorer</title><link>https://rye.dev/projects/electromagnetic-spectrum-explorer/</link><guid isPermaLink="true">https://rye.dev/projects/electromagnetic-spectrum-explorer/</guid><description>An interactive web application for exploring the electromagnetic spectrum from radio waves to gamma rays, with real-time visualization, unit conversion, and educational content for each region.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/electromagnetic-spectrum-explorer-detail-light.webp&quot; alt=&quot;Electromagnetic Spectrum Explorer screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import SpectrumMiniDemo from &apos;../../components/demos/SpectrumMiniDemo.astro&apos;;&lt;/p&gt;
&lt;p&gt;Electromagnetic wavelengths run from femtometers to kilometers, more than twenty orders of magnitude. The core design problem in this explorer was making that entire range navigable on a single screen, which is why everything in the interface, from the spectrum bar to the position marker, is built on logarithmic scaling.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;SpectrumMiniDemo /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Full Spectrum Coverage&lt;/strong&gt;: From radio waves to gamma rays&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-Time Visualization&lt;/strong&gt;: Interactive spectrum display with logarithmic scaling&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unit Conversion&lt;/strong&gt;: Convert between wavelength, frequency, and energy&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Educational Content&lt;/strong&gt;: Details for each band&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scientific Accuracy&lt;/strong&gt;: NIST-certified physical constants&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;p&gt;The physics calculations build on a small set of fixed constants:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export const PHYSICS_CONSTANTS = {
  SPEED_OF_LIGHT: 299792458, // m/s (exact)
  PLANCK_CONSTANT: 6.62607015e-34, // J*s (exact)
  PLANCK_CONSTANT_EV: 4.135667696e-15, // eV*s
};

export function wavelengthToFrequency(wavelength) {
  return SPEED_OF_LIGHT / wavelength;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log scaling is what keeps the whole range usable in one view, from femtometers up to kilometers.&lt;/p&gt;
&lt;h2&gt;What was hard&lt;/h2&gt;
&lt;p&gt;Scientific tools punish sloppy numerics. Values near the edges of the spectrum push against JavaScript&apos;s floating-point limits, so every calculation validates its inputs and returns &lt;code&gt;NaN&lt;/code&gt; early rather than letting a bad value propagate through a chain of conversions into a plausible-looking wrong answer. The conversion panel was the other challenge: wavelength, frequency, and energy are three views of one quantity, so editing any field has to update the other two without triggering an infinite update loop. Input parsing also has to meet users where they are, accepting &lt;code&gt;550 nm&lt;/code&gt;, &lt;code&gt;5.45e14&lt;/code&gt;, or bare meters and normalizing the units before any math runs.&lt;/p&gt;
</content:encoded><category>React</category><category>JavaScript</category><category>Vite</category><category>D3.js</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/electromagnetic-spectrum-explorer-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Frostpane</title><link>https://rye.dev/projects/frostpane/</link><guid isPermaLink="true">https://rye.dev/projects/frostpane/</guid><description>A customizable CSS/SCSS library for frosted glass effects with backdrop blur, highlights, and smooth animations.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/frostpane-detail-light.webp&quot; alt=&quot;Frostpane screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import FrostpaneDemo from &apos;../../components/demos/FrostpaneDemo.astro&apos;;&lt;/p&gt;
&lt;p&gt;A convincing frosted glass effect is never one &lt;code&gt;backdrop-filter&lt;/code&gt; line. It needs light and dark variants with different opacity and border treatments, a reduced-motion fallback, and a plan for browsers that cannot blur at all. Frostpane packages those decisions into a drop-in stylesheet that weighs under 5KB.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;FrostpaneDemo /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Frosted Glass Effects&lt;/strong&gt;: Backdrop blur and translucent glass surfaces&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSS Custom Properties&lt;/strong&gt;: 30+ variables for complete customization&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SCSS Variables&lt;/strong&gt;: Full Sass/SCSS support with configurable variables&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Smooth Animations&lt;/strong&gt;: Built-in transitions and animation effects&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero Dependencies&lt;/strong&gt;: Lightweight, no JavaScript required&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Browser Support&lt;/strong&gt;: Graceful fallbacks for older browsers&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What was hard&lt;/h2&gt;
&lt;p&gt;The difficult part of shipping glass effects as a library is everything around the blur. &lt;code&gt;backdrop-filter&lt;/code&gt; is expensive to render on lower-powered devices, so Frostpane includes a performance-conscious variant that swaps the blur for a more opaque solid background when &lt;code&gt;prefers-reduced-motion&lt;/code&gt; is set. Browsers without &lt;code&gt;backdrop-filter&lt;/code&gt; support get a usable solid panel through &lt;code&gt;@supports&lt;/code&gt; feature queries rather than broken transparency. And a single recipe cannot serve both color schemes: light surfaces need a brighter, more opaque fill while dark surfaces need a near-black fill with a much fainter border, so the library ships separate light and dark mixins instead of one compromise value.&lt;/p&gt;
&lt;h2&gt;Customization&lt;/h2&gt;
&lt;p&gt;Frostpane uses CSS custom properties for easy customization. Override these variables to match your design:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.custom-glass {
  --fp-backdrop-blur: 12px;
  --fp-bg-color: rgba(255, 255, 255, 0.2);
  --fp-border-radius: 16px;
  --fp-filter-saturate: 180%;
  --fp-border-color: rgba(255, 255, 255, 0.3);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Quick Start&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install frostpane
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;link rel=&quot;stylesheet&quot; href=&quot;path/to/frostpane.css&quot;&amp;gt;

&amp;lt;div class=&quot;glass-container&quot;&amp;gt;
  &amp;lt;div class=&quot;glass-content&quot;&amp;gt;Your content here&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
</content:encoded><category>CSS</category><category>SCSS</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/frostpane-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Gopher MCP Server</title><link>https://rye.dev/projects/gopher-mcp/</link><guid isPermaLink="true">https://rye.dev/projects/gopher-mcp/</guid><description>Gives AI assistants a window into the small internet: browse menus, fetch documents, and navigate both Gopher and Gemini protocols safely.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/gopher-mcp-detail-light.webp&quot; alt=&quot;Gopher MCP Server screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import GopherDemo from &apos;../../components/demos/GopherDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;The small internet never went away. Enthusiasts still run thousands of Gopher and Gemini servers full of curated, text-first content (university archives, technical documentation, historical collections) that the modern web does not index. This MCP server hands that world to an AI assistant directly: request a menu, follow a selector, fetch a document, no HTML parsing required.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;GopherDemo client:visible /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dual Protocol Support&lt;/strong&gt;: Access both Gopher and Gemini resources&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Platform&lt;/strong&gt;: Works on Windows, macOS, and Linux&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safe Browsing&lt;/strong&gt;: Secure interaction with protocol resources&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP Integration&lt;/strong&gt;: Standard Model Context Protocol interface&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Small Internet&lt;/h2&gt;
&lt;p&gt;Gopher and Gemini represent alternatives to the modern web:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gopher&lt;/strong&gt; (1991): Hierarchical, menu-driven protocol predating HTTP&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gemini&lt;/strong&gt; (2019): Modern minimalist protocol with TLS encryption&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both protocols focus on text content and simple navigation, offering a distraction-free reading experience that many enthusiasts prefer to the modern web.&lt;/p&gt;
&lt;h2&gt;What was hard&lt;/h2&gt;
&lt;p&gt;The key design decision was treating Gopher and Gemini as one protocol family behind a shared handler abstraction: each protocol implements the same fetch-and-describe interface, so supporting a new protocol means writing one handler rather than touching the MCP layer. Gopher&apos;s type system needed translation too. It predates MIME, marking each item with a single character (&lt;code&gt;0&lt;/code&gt; for text, &lt;code&gt;1&lt;/code&gt; for menus, &lt;code&gt;I&lt;/code&gt; for images), so the server maps those onto MIME types before content reaches the model. Safety came down to refusing to trust remote servers: response size caps, connection timeouts, and content type validation keep a misbehaving host on a 35-year-old protocol from breaking the assistant&apos;s session.&lt;/p&gt;
</content:encoded><category>Python</category><category>MCP</category><category>Gopher</category><category>Gemini</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/gopher-mcp-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: OpenZIM MCP Server</title><link>https://rye.dev/projects/openzim-mcp/</link><guid isPermaLink="true">https://rye.dev/projects/openzim-mcp/</guid><description>Search Wikipedia and other ZIM knowledge bases from Claude, fully offline. Sub-second retrieval with zero network dependency.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/openzim-mcp-detail-light.webp&quot; alt=&quot;OpenZIM MCP Server screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import MCPToolDemo from &apos;../../components/demos/MCPToolDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;export const openzimTools = [
{
name: &apos;search&apos;,
description: &apos;Search for articles in the ZIM knowledge base&apos;,
request: { tool: &apos;zim_search&apos;, query: &apos;quantum computing&apos;, limit: 5 },
response: { results: [{ title: &apos;Quantum computing&apos;, snippet: &apos;Quantum computing is a type of computation...&apos; }, { title: &apos;Qubit&apos;, snippet: &apos;A qubit is a quantum bit...&apos; }] }
},
{
name: &apos;get_article&apos;,
description: &apos;Retrieve the full content of a specific article&apos;,
request: { tool: &apos;zim_get_article&apos;, path: &apos;/A/Quantum_computing&apos; },
response: { title: &apos;Quantum computing&apos;, content: &apos;Quantum computing is a type of computation that harnesses quantum mechanical phenomena...&apos;, word_count: 8420 }
},
{
name: &apos;list_zims&apos;,
description: &apos;List all available ZIM files&apos;,
request: { tool: &apos;zim_list&apos; },
response: { files: [{ name: &apos;wikipedia_en_all&apos;, articles: 6500000, size: &apos;90GB&apos; }] }
}
];&lt;/p&gt;
&lt;p&gt;Put a Wikipedia ZIM archive on disk, point the server at it, and Claude can search and read those articles with the network cable unplugged. That is the whole pitch: retrieval that keeps working in air-gapped labs, classrooms with unreliable connectivity, and anywhere a live API is not an option.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;MCPToolDemo client:visible serverName=&quot;openzim-mcp&quot; tools={openzimTools} /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Offline Knowledge Access&lt;/strong&gt;: Full Wikipedia and Kiwix content access without internet&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;High Performance&lt;/strong&gt;: Fast search across millions of articles&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Python-Based&lt;/strong&gt;: Built with Python for easy deployment and extensibility&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MCP Integration&lt;/strong&gt;: Standard Model Context Protocol interface&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why offline access mattered&lt;/h2&gt;
&lt;p&gt;Most AI tooling assumes an always-on network connection and a live API behind every retrieval request. That assumption breaks down in classrooms, field work, privacy-sensitive environments, and any air-gapped deployment. OpenZIM MCP was built to prove that high-quality retrieval can still feel immediate when the knowledge base lives on disk instead of behind a network hop.&lt;/p&gt;
&lt;h2&gt;Performance strategy&lt;/h2&gt;
&lt;p&gt;The project focused on a few pragmatic constraints:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;search should feel interactive even against multi-million-article archives&lt;/li&gt;
&lt;li&gt;article retrieval should return clean, model-friendly content instead of raw archival formats&lt;/li&gt;
&lt;li&gt;memory usage should stay low enough for modest developer machines and offline appliances&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That drove the overall architecture: query the ZIM index efficiently, extract only the article payload that is needed, and normalize the result into an MCP response that is easy for an assistant to consume.&lt;/p&gt;
&lt;h2&gt;Product decisions&lt;/h2&gt;
&lt;p&gt;The strongest product decision was to make the server useful without requiring users to think about the details of the ZIM format. Developers care that the knowledge is offline and searchable; they do not want to learn an archive format first. MCP is a good fit here because it lets the complexity live at the boundary while the user gets a stable set of retrieval tools.&lt;/p&gt;
&lt;h2&gt;Outcome&lt;/h2&gt;
&lt;p&gt;I care about software that does not collapse the moment it loses the network. Pairing offline archives with an MCP interface makes local knowledge bases feel like first-class infrastructure for AI systems instead of second-best fallbacks.&lt;/p&gt;
</content:encoded><category>Python</category><category>MCP</category><category>Kiwix</category><category>ZIM</category><category>OpenZIM</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/openzim-mcp-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Retro Floppy</title><link>https://rye.dev/projects/retro-floppy/</link><guid isPermaLink="true">https://rye.dev/projects/retro-floppy/</guid><description>An interactive 3.5&quot; floppy disk React component for retro-themed UIs</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/retro-floppy-detail-light.webp&quot; alt=&quot;Retro Floppy screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import RetroFloppyDemo from &apos;../../components/demos/RetroFloppyDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;A 3.5&quot; floppy disk held just 1.44 megabytes, yet it remains one of the most recognizable artifacts in computing. Retro Floppy recreates it as a React component, down to the details people actually remember: the spring-loaded metal shutter, the write-protect tab, the hub ring, and the hand-labeled sticker with its ruled lines.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;RetroFloppyDemo client:only=&quot;preact&quot; /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;What was hard&lt;/h2&gt;
&lt;p&gt;Getting the disk to feel physical came down to a handful of CSS decisions. The metal shutter slides open on hover with a &lt;code&gt;cubic-bezier&lt;/code&gt; timing curve tuned to mimic the spring-loaded action of the real mechanism, rather than a stock easing that would read as a generic UI transition. Depth comes from layered gradients and paired inset shadows that suggest molded plastic rather than a flat illustration. And because the disk is interactive, it had to work beyond the mouse: the component exposes a button role, keyboard activation, and ARIA labels so the novelty never costs accessibility.&lt;/p&gt;
&lt;h2&gt;Theme Customization&lt;/h2&gt;
&lt;p&gt;Choose from built-in themes or create your own:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import { FloppyDisk, NEON_THEME, RETRO_THEME } from &apos;retro-floppy&apos;;

// Use a built-in theme
&amp;lt;FloppyDisk theme={NEON_THEME} /&amp;gt;

// Or create a custom theme
&amp;lt;FloppyDisk theme={{
  diskColor: &apos;#1a1a2e&apos;,
  slideColor: &apos;#c0c0c0&apos;,
  labelColor: &apos;#ffffff&apos;,
  labelBg: &apos;#2d2d44&apos;,
}} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Event Handling&lt;/h2&gt;
&lt;p&gt;The component supports click and hover events:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;FloppyDisk
  onClick={() =&amp;gt; console.log(&apos;Disk clicked&apos;)}
  onDoubleClick={() =&amp;gt; console.log(&apos;Disk opened&apos;)}
  onHover={(isHovering) =&amp;gt; console.log(&apos;Hover:&apos;, isHovering)}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Interactive Design&lt;/strong&gt;: Realistic floppy disk with sliding metal shutter animation on hover&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Built-in Themes&lt;/strong&gt;: Light, Dark, Neon, Retro, and Pastel themes included&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript Support&lt;/strong&gt;: Full type definitions with generics for type-safe props&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Customizable&lt;/strong&gt;: CSS custom properties and theme objects for complete control&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessible&lt;/strong&gt;: ARIA labels and keyboard navigation support&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multiple Sizes&lt;/strong&gt;: Tiny, small, medium, and large size variants&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Quick Start&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install retro-floppy
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;import { FloppyDisk } from &apos;retro-floppy&apos;;
import &apos;retro-floppy/dist/retro-floppy.css&apos;;

function App() {
  return (
    &amp;lt;FloppyDisk
      label={{ name: &apos;My App&apos;, author: &apos;v1.0&apos; }}
      size=&quot;medium&quot;
    /&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded><category>React</category><category>TypeScript</category><category>CSS</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/retro-floppy-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Uzumaki</title><link>https://rye.dev/projects/uzumaki/</link><guid isPermaLink="true">https://rye.dev/projects/uzumaki/</guid><description>Cross-platform spiral visualization app for Web, iOS, iPadOS, macOS, and watchOS. Generate animated spirals from ten mathematical algorithms with real-time customization.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/uzumaki-detail-light.webp&quot; alt=&quot;Uzumaki screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import UzumakiDemo from &apos;../../components/demos/UzumakiDemo.astro&apos;;&lt;/p&gt;
&lt;p&gt;Uzumaki is an interactive spiral visualization app that renders ten mathematical spiral algorithms across web and Apple platforms. Each algorithm produces animated artwork, from the Fibonacci golden spiral to the chaotic Uzumaki pattern.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;UzumakiDemo /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Spiral Algorithms&lt;/h2&gt;
&lt;p&gt;Each spiral follows a specific mathematical formula in polar coordinates:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spiral&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;Natural Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fibonacci&lt;/td&gt;
&lt;td&gt;r = a * phi^(2*theta/PI)&lt;/td&gt;
&lt;td&gt;Nautilus shells, galaxies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vogel&lt;/td&gt;
&lt;td&gt;theta = n * 137.5 deg&lt;/td&gt;
&lt;td&gt;Sunflower seeds, pinecones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Archimedean&lt;/td&gt;
&lt;td&gt;r = a + b * theta&lt;/td&gt;
&lt;td&gt;Watch springs, coiled rope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fermat&lt;/td&gt;
&lt;td&gt;r = a * sqrt(theta)&lt;/td&gt;
&lt;td&gt;Optical lenses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logarithmic&lt;/td&gt;
&lt;td&gt;r = a * e^(b*theta)&lt;/td&gt;
&lt;td&gt;Hurricane formations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Curlicue&lt;/td&gt;
&lt;td&gt;phi = 2&lt;em&gt;PI&lt;/em&gt;phi*n^2&lt;/td&gt;
&lt;td&gt;Fractal art&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;p&gt;The web app uses Web Workers with TypedArrays for parallel spiral generation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function generateSpiralTyped(params: SpiralParams): TypedSpiralPoints {
  const points = createTypedPoints(numSteps);
  const rotation = time * spinRate;

  for (let i = 0; i &amp;lt; numSteps; i++) {
    const theta = i * stepSize + rotation;
    const r = calculateRadius(i * stepSize, params);
    setPoint(points, i, r * Math.cos(theta), r * Math.sin(theta));
  }
  return points;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Swift implementation uses SIMD for vectorized math operations, achieving the same 60fps performance on Apple devices.&lt;/p&gt;
&lt;h2&gt;Platform Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Web/PWA&lt;/strong&gt;: Shareable URLs, keyboard shortcuts, PNG export&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iOS/iPadOS&lt;/strong&gt;: Pinch-to-zoom, pan gestures, full-screen mode&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS&lt;/strong&gt;: Menu bar integration, keyboard shortcuts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;watchOS&lt;/strong&gt;: Digital Crown zoom, swipe navigation, complications&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>React</category><category>TypeScript</category><category>Swift</category><category>SwiftUI</category><category>Canvas API</category><category>Web Workers</category><category>PWA</category><category>SIMD</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/uzumaki-detail-light.webp" length="0" type="image/webp"/></item><item><title>Project: Zero Crust POS Simulator</title><link>https://rye.dev/projects/zero-crust/</link><guid isPermaLink="true">https://rye.dev/projects/zero-crust/</guid><description>A virtualized dual-head point-of-sale system built with Electron, demonstrating architecture patterns for distributed state management, secure IPC, and offline-first retail operations.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://rye.dev/screenshots/zero-crust-detail-light.webp&quot; alt=&quot;Zero Crust POS Simulator screenshot&quot; /&gt;&lt;/p&gt;&lt;p&gt;import ZeroCrustArchDemo from &apos;../../components/demos/ZeroCrustArchDemo.tsx&apos;;&lt;/p&gt;
&lt;p&gt;Zero Crust is a POS simulator that explores architectural patterns for quick-service restaurant operations. It features dual synchronized windows, modeling the hardware segregation found in production deployments where cashier and customer displays run on separate hardware.&lt;/p&gt;
&lt;p&gt;&amp;lt;div class=&quot;my-8 p-6 bg-white/60 dark:bg-gray-800/60 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50&quot;&amp;gt;
&amp;lt;ZeroCrustArchDemo client:visible /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;h2&gt;Key Features&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dual-Head Display Simulation&lt;/strong&gt;: Separate cashier and customer windows with real-time synchronization&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Command Pattern IPC&lt;/strong&gt;: Type-safe, auditable command system with Zod runtime validation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Integer-Only Currency&lt;/strong&gt;: All monetary values stored in cents to prevent floating-point errors&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture Debug Window&lt;/strong&gt;: Real-time visualization of IPC flow and state changes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Demo Loop&lt;/strong&gt;: Auto-generates realistic order patterns for continuous operation&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Security Model&lt;/h2&gt;
&lt;p&gt;Zero Crust implements six layers of Electron security:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Electron Fuses&lt;/strong&gt;: Compile-time security flags that cannot be changed at runtime&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Isolation&lt;/strong&gt;: Renderer processes cannot access Node.js APIs directly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zod Validation&lt;/strong&gt;: All IPC commands validated with schemas before processing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sender Verification&lt;/strong&gt;: IPC handlers validate message origin against allowed sources&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Navigation Control&lt;/strong&gt;: Blocks unauthorized navigation and window.open calls&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Permission Denial&lt;/strong&gt;: Blocks all permission requests by default&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;The Broadcast Pattern&lt;/h2&gt;
&lt;p&gt;Instead of delta updates or complex synchronization logic, Zero Crust broadcasts the entire application state on every change:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// BroadcastService.ts - Subscribe and broadcast on change
this.mainStore.subscribe((state) =&amp;gt; {
  this.windowManager.broadcastState(state);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This &quot;full-state sync&quot; pattern eliminates an entire category of bugs. Renderers always have the complete, consistent picture, and the performance cost is negligible for typical POS cart sizes.&lt;/p&gt;
&lt;h2&gt;Screenshots&lt;/h2&gt;
&lt;p&gt;&amp;lt;div class=&quot;grid grid-cols-2 gap-4 my-8&quot;&amp;gt;
&amp;lt;img src=&quot;https://rye.dev/screenshots/cashier.png&quot; alt=&quot;Cashier window with product grid and cart&quot; class=&quot;rounded-lg shadow-lg&quot; loading=&quot;lazy&quot; width=&quot;2050&quot; height=&quot;1538&quot; /&amp;gt;
&amp;lt;img src=&quot;https://rye.dev/screenshots/customer.png&quot; alt=&quot;Customer display showing synchronized cart&quot; class=&quot;rounded-lg shadow-lg&quot; loading=&quot;lazy&quot; width=&quot;948&quot; height=&quot;1537&quot; /&amp;gt;
&amp;lt;img src=&quot;https://rye.dev/screenshots/debugger.png&quot; alt=&quot;Architecture Debug Window with event timeline&quot; class=&quot;rounded-lg shadow-lg&quot; loading=&quot;lazy&quot; width=&quot;3600&quot; height=&quot;2260&quot; /&amp;gt;
&amp;lt;img src=&quot;https://rye.dev/screenshots/transactions.png&quot; alt=&quot;Transaction history view&quot; class=&quot;rounded-lg shadow-lg&quot; loading=&quot;lazy&quot; width=&quot;3056&quot; height=&quot;1208&quot; /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
</content:encoded><category>Electron</category><category>React</category><category>TypeScript</category><category>Tailwind CSS</category><category>Vite</category><category>Immer</category><category>Zod</category><author>c@meron.io (Cameron Rye)</author><enclosure url="https://rye.dev/screenshots/zero-crust-detail-light.webp" length="0" type="image/webp"/></item></channel></rss>