# Cameron Rye - Complete Content Archive > This file contains the complete text of all blog posts and projects from rye.dev. > Generated for LLMs with large context windows per the llms.txt specification. > See also: https://rye.dev/llms.txt for a concise overview with links. Total blog posts: 27 Total projects: 15 --- # Blog Posts ## The Test Wasn't Flaky. The Server Was Quitting. > A prod-smoke suite failed for weeks and the error I spent that time chasing was never the cause. The real one was an upgrade that made a dropped connection fatal. Date: 2026-08-06 Tags: cloudflare, wrangler, testing, debugging, ci URL: https://rye.dev/blog/the-test-wasnt-flaky-the-server-was-quitting/ For most of July, the last gate before production on this site failed about four times in ten. It is a Playwright suite I call the prod smoke: it builds the real Worker bundle, serves it with `wrangler dev --local`, and drives a browser at it. When it goes red, the deploy job is skipped and nothing ships. The failures looked like this. Four specs would go down together — CSP headers, front-door cache, agent terminal, search — all with `ECONNREFUSED`. Four unrelated parts of the site, broken at once, in a suite that had passed an hour earlier on the same commit. That shape is worth learning to recognize, because it is not four defects. It is one. When everything downstream of a server fails at the same instant, you do not have four bugs; you have a server that stopped answering. Every minute I spent looking at the CSP test was a minute spent on a symptom. What follows is the part I got wrong for three weeks, and the thing that turned out to be true. ## The wrong answer, written down and committed I had a diagnosis early. Buried in the run logs was this: ``` kj/async-io-unix.c++:263: disconnected: ::write(...): Broken pipe ``` That is workerd, the runtime under `wrangler dev`, complaining about a write to a socket the client had already closed. It appeared in failing runs. It looked exactly like a crash. I believed it, and on 19 July I committed a supervisor script to restart the server around it, in `c376ab9`, with a message that stated the cause plainly: *"workerd intermittently dies mid-run on a client disconnect."* The supervisor worked. The failures continued. The problem with a wrong diagnosis that ships with a partial fix is that it stops looking like a hypothesis. It becomes the thing everybody knows. It went into a commit message, then into comments in three files, and every time I came back to the flake I started from it. Here is what finally killed it. I stopped reading the logs for the error I expected and started counting: | Run | Broken pipe lines | Server exits | Result | |---|---|---|---| | CI, 3 Aug | 2 | 0 | **passed** | | CI, 4 Aug | 1 | 8 | failed | | 46 local runs | **0** | 12 | 4 failed | A passing run had two Broken pipe lines. A failing run had one. Forty-six instrumented runs on my own machine produced twelve failures and not a single Broken pipe line. In one run, the first two exits both happened *before* the first Broken pipe line appeared. The two events were unrelated. Broken pipe is noise that a dev server emits when a browser closes a tab, and it appears in healthy runs. I had spent three weeks on a coincidence. The number that did correlate was the one I had not been counting: **every run where the server process exited failed, and every run where it did not, passed.** Restart count was not predictive either — one CI run restarted the server eight times and still went green. What mattered was whether the process was gone at the moment a test needed it. ![A monitor showing lines of code, where one line is a blank glowing white bar with no text, examined under a magnifying glass.](/images/blog/generated/the-test-wasnt-flaky-the-server-was-quitting-illustrates-the-concept-of-an--1786026627037.jpg) ## An error with no message So why had I not noticed the server exiting? Because of how it announced itself: ``` ✘ [ERROR] ``` That is the whole line. No message, no stack, no code. `wrangler dev` prints an empty error and the Node process exits 1. In a CI log with thousands of lines of build output, a bare glyph is invisible, and it is not something you would ever think to grep for. The stack existed, but only in wrangler's own debug log, which nothing was capturing. The fix was two lines in the workflow: set `WRANGLER_LOG: debug` on the prod-smoke job, and upload the log directory as an artifact whether the job passes or fails. Do that before you theorize. I had been reasoning about a process whose last words I had never read. With the log in hand the sequence was legible, and it was not a crash at all. ![Two isometric server towers labelled ProxyWorker and UserWorker, joined by a pipe that has snapped in the middle, marked with a red lightning bolt and a broken-link icon.](/images/blog/generated/the-test-wasnt-flaky-the-server-was-quitting-depicts-the-architectural-setu-1786026647497.jpg) ## What was happening `wrangler dev` runs your code in workerd, but it puts a small Worker in front of it called the ProxyWorker, which forwards each request to the UserWorker — your actual code — and pipes the response back. When Playwright tears down a browser context, it closes sockets. If a response is still being written when that happens, the ProxyWorker's `fetch` to the UserWorker rejects with a disconnect: `Network connection lost.` The ProxyWorker reports that to the controller, where it arrives as an error named **`Error inside ProxyWorker`**. Wrangler keeps an allowlist of error strings that are known to be harmless during development. There are two on it: `Failed to send message to…` and `Could not connect to InspectorProxyWorker`. `Error inside ProxyWorker` is not one of them. So it falls through to `emit('error')`, and with no handler attached to that event, Node does what Node does. Note what is *not* happening here. workerd does not die. It survives the whole thing, which is why it goes on holding port 4321 after its parent is gone — and why the naive cleanup step I will get to below made everything worse. The thing that quits is wrangler's own Node process. There is no crash to catch and no flag that makes this non-fatal. ## The one-line change that caused it None of this was new code on my side. The suite broke because I upgraded wrangler from 4.110 to 4.114 in `d63dea6`, in a routine batch of dependency updates, and deploys started failing three days later. The relevant code is in the ProxyWorker's error handler. It has to decide whether an error it just caught is still worth reporting, because by the time a network error surfaces, the UserWorker may already have been replaced by a hot reload — in which case the error is stale and should be dropped. In 4.113, that check is one line: ```ts // only report errors if the downstream proxy has NOT changed if (userWorkerUrl.href === newUserWorkerUrl?.href) { ``` Now look at what those two URLs are. `userWorkerUrl` is built from the incoming request, so it carries that request's path: ```ts const userWorkerUrl = new URL(request.url); Object.assign(userWorkerUrl, proxyData.userWorkerUrl); ``` `newUserWorkerUrl` is rebuilt from the connection parts alone, so its path is `/`. For any request to a real page, those two hrefs are never equal. `http://localhost:4321/blog/some-post` does not equal `http://localhost:4321/`. The comparison is false, the error is judged stale, and it is silently dropped. **On every path except the root, this check swallowed the error by accident.** In 4.114 the comparison became origin-based, and wrangler's own source says why: > `isSameUserWorkerOrigin` compares origin (not href) so a genuine error on a non-root path isn't misread as a reload That is a correct fix for a real bug. Origins now match on every path, so the error is reported instead of dropped — and because `Error inside ProxyWorker` was never on the non-fatal allowlist, "reported" means the dev server exits. My suite had been depending on a bug. When upstream fixed it, a dropped connection on any page went from silently ignored to fatal. Both of those behaviors are wrong; I had just been on the comfortable side of the trade. ## It was never one bad test While the diagnosis was wrong I kept trying to find the test responsible. There wasn't one. I suspected my heaviest spec, which loads about 1.3 MB of Mermaid chunks and has one case that aborts requests mid-flight. Running it alone: zero failures in eight attempts. The agent-terminal spec alone: zero in five. The full suite: about half. Then I stopped bisecting and measured the actual trigger — a page torn down while responses were in flight: - A page with no subresources survived **80** teardowns. - A normal page died within **6 to 22**. - Letting the page settle before teardown cut the rate by roughly **6×**. It is dose-response. Every teardown with something in flight is a roll of the dice, and a browser test suite does that hundreds of times. That also explains why concurrency looked like a lever and wasn't: running with `--workers=1` failed at the same rate over eight runs. Serializing the tests does not reduce the number of teardowns, only their overlap. If you are hunting a flake and single-spec runs come back clean, consider that you may be looking for a rate rather than a culprit. ## A real bug that fixed nothing One detour is worth publishing because the code looked obviously correct. The supervisor needed to free the port before restarting, since workerd outlives its parent. It did this: ```bash lsof -ti:4321 | xargs kill -9 ``` `lsof -i:PORT` matches a socket if **either** endpoint uses that port — local or foreign. A browser connected to the dev server has 4321 as its *remote* port, so it matches. That command was killing Chromium's connections during every restart. The correct filter is `-sTCP:LISTEN`, which returns only the listener; I confirmed it with a listener and one client, where the bare form returns both PIDs and the filtered form returns one. The same mistake was in the CI cleanup step. It was a genuine bug, I was right to fix it, and it changed nothing: four of six runs still failed afterward. Fixing a real bug you found while looking for a different one is not evidence you found the right one — which is the same error as the Broken pipe, wearing better clothes. ![Three stacked slabs labelled Version Z, Version Y and Version X. The top two are red and breaking apart; the bottom one is intact and held in place by a pin.](/images/blog/generated/the-test-wasnt-flaky-the-server-was-quitting-illustrates-the-concept-of-pin-1786026667492.jpg) ## The fix, and why it is three things The fix is a version pin: ```json "wrangler": "4.113.0" ``` Exact, not a caret. A caret floats straight back to 4.114 on the next install and undoes everything. The pin alone is not enough, and this is the part that would have cost someone else an afternoon. Two more entries are required: - **`@cloudflare/vite-plugin` held at `~1.46.0`.** The plugin pins `miniflare` and `workerd` exactly in its own dependencies, and 1.46 is the line whose pins match wrangler 4.113. Let it float to 1.47 and the tree resolves two copies of miniflare and two of workerd. The tell is a pnpm warning naming two workerd versions at install time. - **`sharp` overridden to `^0.35.3`.** Plugin 1.46.0 carries sharp 0.34.5, which has a high-severity advisory. I had raised the audit gate from `critical` to `high` a few days earlier, which is the only reason this surfaced before it shipped rather than after. Measured across full runs of the suite: | wrangler | server exits | outcome | |---|---|---| | 4.114.0 | 7 in 6 runs | 4 red | | 4.113.0 | **0 in 14 runs** | 12 green | The one non-green run under 4.113 was an unrelated timeout, with zero exits. ## A pin is a debt Pinning to an older version to dodge an upstream fix is not a good place to end up. It is a deliberate decision to stay on the far side of a change that was, on its own terms, correct. So the pin carries its reasoning where the next person will hit it — in the Dependabot ignore entry, alongside the measurements and an explicit condition for removing it: not "when a newer version exists," but when upstream restores non-fatal handling for a client disconnect. A newer version number is not evidence. 4.118 was still reported failing, and it also pins a miniflare 5 alpha. The whole-suite retry stays in CI as a backstop, because a pin is a bet and I would rather not re-learn this from a red deploy. On the first green run after the pin landed, the retry never fired — and the run before it had restarted the server four times and still passed. Three things I would keep from this: **When everything fails at once, suspect the thing underneath all of it.** Four specs failing together was never four problems. **An error you can explain is not the same as the error that is happening.** Broken pipe had a plausible story, appeared in the right logs, and was a coincidence. The number that mattered was one I was not counting until I stopped assuming. **Capture the logs before you theorize.** The actual cause printed a bare `✘ [ERROR]` with no message, and its stack existed the whole time in a debug log nothing was uploading. Three weeks of guessing, and the evidence was one workflow line away. --- ## Making My Portfolio Agent-Readable: From Files to an Interface Agents Can Act On > 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. Date: 2026-06-15 Tags: ai, agents, web-standards, mcp, protocols URL: https://rye.dev/blog/making-my-portfolio-agent-readable/ A few months ago I published [an essay](/blog/llms-txt-standard-elegant-solution-nobody-using/) arguing that `llms.txt` 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, "sits unused, waiting for a problem that the powerful have chosen not to solve." Then I went back to my portfolio and built more of it. 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 *verify*, not just trust, before acting on them. If you only read the first essay, this looks like a man losing an argument with himself. It is not. There is one distinction that makes building all of this the opposite of insane. ## Discovery is dead; usability is the live question The thing I declared dead was **discovery**. 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 [my follow-up on AI slop](/blog/ai-slop-is-a-search-problem-now/) I went further and reframed the entire category: these standards are valuable "not as a way to be discovered, but as a way to be verified once a user has found you." Hold onto that sentence. It is the hinge of this whole post. 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: *go look at Cameron's site and tell me what he's done with MCP.* The agent is already here. Nobody had to discover me. The question is no longer "will it find me." The question is: When it gets here, can it actually do anything? That is a usability question, not an SEO question. And usability for an agent has a spectrum: - **Can it read me?** Most sites hand an agent a tag-soup DOM full of nav chrome, cookie banners, and analytics noise, then make it guess which `
` is the article. - **Can it understand me?** Even with clean text, can it tell a blog post from a project, find the publish date, know which pages are drafts? - **Can it act on me?** If the human says *subscribe me to his newsletter* or *send him a message*, is there a door the agent can open, or does it have to scrape a form and forge a POST? My RAG chatbot, [Ask](/blog/building-ask-rag-portfolio-chatbot/), 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. 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. ## Every page has a markdown twin ![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.](/images/blog/generated/making-my-portfolio-agent-readable-markdown-twin-one-document-tw-1781571015356.png) Start with reading, because if an agent cannot cleanly read the page, nothing above it matters. Every blog post on rye.dev is served at `/blog/.md`, and every project at `/projects/.md`. These are real routes: Astro file routes at `src/pages/blog/[...slug].md.ts` and `src/pages/projects/[...slug].md.ts`, with `prerender = true` so they bake to static files at build time. Here is the part I care about: **these do not convert HTML to markdown.** They reconstruct it. The route takes the source content and emits a fresh YAML frontmatter block (title, description, date, updated, tags, author, plus a `canonical_url` I inject) followed by the raw markdown body, served as `text/markdown; charset=utf-8`. No nav, no footer, no cookie banner. Just the document, the way I actually wrote it. Each response also carries a non-standard header I made up because I wanted it: `x-markdown-tokens`, set to `ceil(markdown.length / 4)`. That four-chars-per-token estimate lets an agent budget its context window *before* it spends a fetch. A small courtesy, but the whole exercise is courtesies. There is a second way in, for agents that don't know about the `.md` convention: **HTTP content negotiation.** Request the canonical HTML URL with `Accept: text/markdown` and middleware serves you markdown instead. For the eight static pages (`/`, `/about`, `/now`, `/uses`, `/colophon`, `/reading`, `/resume`, `/blogroll`) it serves hand-authored markdown twins embedded in the Worker bundle: `src/data/static-page-md/*.md`, imported via Vite's `?raw`. One resource, two representations, picked by a header. Content negotiation working exactly as specified. There is a boundary on that, and it's worth naming because it caught me. Negotiation only works on pages the Worker actually sees. Blog posts and project pages are prerendered, and Workers Static Assets answers a prerendered path straight from disk without ever invoking the Worker — so middleware never gets a say. `run_worker_first` doesn't rescue it (`serve_directly` overrides it for asset-matched paths) and `_routes.json` is a Pages mechanism, not a Workers one. For those pages the `.md` sibling URL is the way in, which is exactly what `llms.txt` and the `` tags point at. Negotiation is for the SSR front door; the sibling URLs are for everything else. ### The part that took the longest was the part you can't see I want to be honest about how much fighting the platform this took, because the clean result hides it. **Runtime HTML-to-markdown is impossible here.** The obvious move (let a request come in, grab the rendered HTML, run it through `turndown` or `cheerio`) does not work on Cloudflare Workers. Those libraries pull in `parse5`, which is Node-only, and they crash the Workers runtime at *module load*. 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. **`run_worker_first` is a trap.** Cloudflare lets you ask the Worker to run before static assets are served. Except for routes that match a prerendered asset, `serve_directly` silently overrides it and the asset is handed back before your code runs. **`_routes.json` does nothing**: that is a Pages mechanism, and this is a Workers deploy. And Cloudflare's own zone-level "Markdown for Agents" feature, which would replace this entire layer, requires a paid plan. rye.dev is on the **Free plan**. So I built the free version by hand. There's one subtlety that cost me an afternoon. The static `.md` routes must be `prerender = false` *and* there must be a registered SSR route at `src/pages/[slug].md.ts`, 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. One more, because it bit me in production: `Vary: Accept` is the correct header to set, and I set it. But Cloudflare's edge cache does not vary on `Accept`. So without intervention, the edge could cache an HTML body and then hand it to an `Accept: text/markdown` request. The fix is to force `Cache-Control: private, no-store` on the negotiable static-page paths. You give up edge caching on a handful of routes to guarantee correctness. Worth it. ## The index nobody reads, built anyway, honestly On top of the twins sits [the file I already eulogized](/blog/llms-txt-standard-elegant-solution-nobody-using/). `public/llms.txt` is hand-authored. I'll say that plainly, because the next file isn't, and the distinction matters. It follows the [llmstxt.org](https://llmstxt.org/) structure: an H1, a blockquote summary, then `## About`, `Featured Projects`, `All Projects`, a reverse-chronological `Technical Blog` with dates, `Technical Expertise`, `Connect`, and `Optional`. It documents the `.md`-appended convention right at the top so an agent reading it knows the twins exist. Being hand-authored, it can drift. So next to it is `public/llms-full.txt`, which is **generated** by `scripts/generate-llms-full.ts` as the *first* step of every build. It inlines the full body of every non-draft post and project (currently around **25 posts and 15 projects**) into one grep-able file. If an agent has no fancier tool, the instruction is blunt: fetch `llms-full.txt` and grep it. 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 *my own* 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 `llms.txt` anyway, and it is the entire argument here. ![An isometric glowing 'Agent Card' panel listing supportedInterfaces, a capabilities block with streaming and pushNotifications set to false, and a skills list.](/images/blog/generated/making-my-portfolio-agent-readable-a-visual-representation-of-the-1781570086957.jpg) ## An agent card that tells the truth about itself Now we move from "read me" to "here is my interface." At `/.well-known/agent-card.json` lives a static, hand-authored [A2A Protocol](https://a2a-protocol.org/) AgentCard, version `1.0.0`. It names the provider (`https://rye.dev`, "Cameron Rye"), points `documentationUrl` at the agent-skills index, and uses the site favicon as its icon. Its `supportedInterfaces` is a single entry: `{ url: https://rye.dev/a2a, protocolBinding: JSONRPC, protocolVersion: 1.0 }`. That `/a2a` URL is the door. We'll walk through it in the last layer. The honest part is `capabilities`: ```json "capabilities": { "streaming": false, "pushNotifications": false, "extendedAgentCard": false } ``` 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 *are* false, and an agent that reads `streaming: false` and then doesn't wait around for an event stream is an agent I've saved a timeout. The card advertises **five skills: `search-blog`, `get-post`, `subscribe-newsletter`, `submit-contact`, and `mcp`.** Four of those are callable over the live endpoint. The fifth, `mcp`, is **not an executable skill**. It is a pointer that says "there is an MCP server at `https://rye.dev/mcp`, go talk to it." 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'd rather hand off cleanly than leave the MCP server undiscovered. But I'm telling you here what the card can't: four do something, one points elsewhere. ### The dotfile problem, which I will mention exactly once per file Cloudflare Workers Static Assets **will not serve any file under a literal `.well-known/` directory.** The dotfile prefix is filtered out of the asset bundle. So nothing I just described could physically live where its URL says it lives. The workaround, which recurs across this entire surface: every well-known file lives under `public/wellknown/` (no dot) and is exposed at `/.well-known/` via a status-`200` internal rewrite in `public/_redirects`. The URL bar stays `/.well-known/agent-card.json`; the bytes come from `wellknown/`. `public/_headers` then layers on the CORS, `Content-Type`, and cache headers. (For the namespace background on why `.well-known/` is the right place for any of this, I wrote a whole post on [well-known URIs](/blog/well-known-uris-standardizing-web-metadata/).) And because a card that lies about its skills is worse than no card, there's a drift-guard test (`tests/a2a-agent-card.test.ts`) that asserts the card's skill descriptions match the agent-skills index *verbatim*. The two cannot silently diverge. ![Two humanoid AI agents shaking hands beside a SHA256 padlock badge with a green checkmark, over a circuit-board background.](/images/blog/generated/making-my-portfolio-agent-readable-visually-depicts-the-process-o-1781570108492.png) ## Skills an agent is told to verify before trusting `public/wellknown/agent-skills/index.json` is the verification manifest: a pointer set for an agent already on the site, declaring `$schema https://schemas.agentskills.io/discovery/0.2.0/schema.json`. It's a flat `skills` array. Each entry has a `name`, a `type` of `"skill-md"`, a `description`, an absolute `url` to a `SKILL.md`, and the interesting one: a `digest` of the form `sha256:` computed over that `SKILL.md`'s bytes. Each skill is a `SKILL.md` file: YAML frontmatter (`name`, `description`) plus a freeform markdown body of instructions written for an agent to read and follow. The digest is the whole point, and it's the theme of this post wearing a hat. The discovery schema (**v0.2.0**) carries a per-skill sha256 digest precisely so a client can verify the artifact bytes before trusting a skill. `scripts/agent-skills-digest.mjs` (`pnpm agent-skills:digest`) recomputes them; edit a `SKILL.md` and forget to re-run it, and you ship a stale digest that a compliant agent will *reject*. That is not a bug. That is the verification handshake doing its job, the same thing I said standards are actually good for. Not "trust me because I'm in the index," but "here's the hash, check it yourself." The five skills split into three kinds. **Read skills.** `search-blog` tells the agent to prefer the MCP `search_posts` tool, fall back to fetching `llms-full.txt` and grepping, then read a hit via MCP `get_post` or `GET /blog/.md`. `get-post` describes `get_post { slug } → { slug, frontmatter, body, url }`, or the plain `GET /blog/.md` with `Accept: text/markdown`, drafts excluded, response carrying that `x-markdown-tokens` header. Reads are cheap and safe. **Write skills, where it gets interesting.** `subscribe-newsletter` is `POST /api/newsletter` with `{ email, source }`, double opt-in, returning `200 / 400 / 429`. `submit-contact` is `POST /api/contact` with `{ name, email, subject, message }` under field-length constraints. Both are rate limited and both are **same-origin enforced**: the `Origin` must be `https://rye.dev`. That same-origin rule creates a fork in the road, and the skills document both branches honestly: - An **in-browser** agent (something running as a page-side tool) inherits the page's Origin, so it uses the WebMCP `subscribe_newsletter` / `submit_contact` tools and sails through the check. - An **out-of-browser** agent can't forge a same-origin request and shouldn't try, so the skill tells it to do the polite thing: send the human to `https://rye.dev/#newsletter` or the contact form. The agent doesn't pretend to be the user. It hands the user back the wheel. **The mcp pointer.** The fifth `skill-md` entry, `mcp`, has its own `SKILL.md` and its own digest, but it isn't callable over `/a2a`. It describes connecting to the MCP server at `https://rye.dev/mcp`, Streamable HTTP, stateless (no `Mcp-Session-Id`), `protocolVersion 2025-06-18`, six read-only tools: `list_posts`, `search_posts`, `get_post`, `list_projects`, `get_project`, `get_about`. Which brings us to the layer where things actually execute. ## The endpoints that do something Everything above is description. `/a2a` is action. It is **live, not a placeholder**: a server-rendered Astro route at `src/pages/a2a.ts`, `prerender = false`, speaking JSON-RPC 2.0, stateless and synchronous. It supports exactly **one** method, `message/send`; anything else gets a clean JSON-RPC `-32601`. It never returns an A2A `Task`: no streaming, no async job queue, which is precisely what the card's all-false capabilities promised. Dispatch works like this. The client sends a message whose `parts` include a `DataPart`: ```json { "kind": "data", "data": { "skill_id": "get-post", "args": { "slug": "ai-slop-is-a-search-problem-now" } } } ``` Four skills are callable through it: `search-blog`, `get-post`, `subscribe-newsletter`, `submit-contact`. Send a text-only message with no `DataPart` and you get a help reply listing exactly those four. No guessing. **Code reuse is the entire architecture, not a detail.** The read skills call the same `searchPosts` / `getPostBySlug` functions the MCP server uses. The write skills call the *same* action handlers as the `/api/newsletter` and `/api/contact` REST routes, which means they inherit the existing validation and rate limiting for free: **newsletter 3/hour, contact 5/hour, both fail-closed, keyed by client IP.** 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). ### Being candid about the security posture `/a2a` is deliberately **unauthenticated, with open CORS**: `Access-Control-Allow-Origin: *`. I want to be straight about that rather than let you discover it. The reason is structural. My same-origin CSRF check is scoped to the `/api/` prefix, and `/a2a` lives outside it so that a third-party agent (which by definition can't present my Origin) isn't blocked at the door. Put it under `/api/` and the CSRF guard would reject every legitimate external agent, which defeats the entire point of being reachable by agents a human sent. The honest tradeoff: **anyone can POST to `/a2a`.** It is an open endpoint. What protects me is not the transport but the fact that the only *write* 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 *write* endpoint would be, which is why the writes keep their limits no matter which door they came through. That's hardening by design, not an oversight. I'm telling you it's open because the design *expects* it to be open. The sibling is `/mcp` (`src/pages/mcp.ts`), with its own server card at `/.well-known/mcp/server-card.json`: `serverInfo.name "rye.dev"`, title "Cameron Rye Portfolio MCP Server", `streamable-http` transport, capabilities `["tools"]`, 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. ## The table of contents that holds it together Five layers is a lot of surface. So there's a top-level map. `/.well-known/api-catalog` is an **RFC 9727** linkset, the table of contents for an agent that's already here. It links `service-desc` → `/openapi.json`, `service-doc` → `/llms-full.txt`, `status` → `/api/health`, the MCP server → `/mcp`, and the agent-skills index. It **does not** 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.) Reinforcing the catalog, an **RFC 8288** `Link` header rides on responses (emitted statically in `_headers` 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. And for the humans: the homepage has a "Built for humans and AI agents" section (driven by `src/data/agent-endpoints.ts`) that just lists these endpoints in plain sight. I'm not hiding the machine surface in the metadata. I'm proud of it. The whole thing is kept honest by drift guards I've already mentioned in passing: the card-versus-skills verbatim test, the digest recompute script, and build-time hash checks. The failure mode I'm most afraid of is not "no agent uses this." It's "an agent uses this and I've quietly lied to it." The guards exist so I can't. ## So does anything actually consume this yet? Honest answer: mostly nothing. No major platform is fetching my `llms.txt` or my agent card on its own. I said that months ago and nothing has changed. The thing that *does* 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's it. That's the honest scorecard. And it reconciles perfectly with what I argued before, because **I never promised this would bring traffic.** It won't. This is not a discovery play; I retired that idea in print. These are the exact benefits I pre-committed to in [the llms.txt piece](/blog/llms-txt-standard-elegant-solution-nobody-using/), and they still hold: it'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 *if* 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. The reframe from the slop essay holds all the way down. These standards are not how an agent *finds* me. They're how an agent that a human already pointed here can *read me cleanly, understand my structure, verify what it's about to trust, and act through a door I deliberately left open.* Usability and verification. Not SEO. Which leaves exactly one question unanswered, and it's the good one. Everything here answers *what can an agent read and do on this site.* It says nothing about *who is this agent, and should I trust it.* My `/a2a` 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 `/.well-known/` for the answer: an `http-message-signatures-directory` publishing an Ed25519 public key for Web Bot Auth, and OAuth discovery metadata under RFC 8414 / RFC 9728. I'll be blunt about their status, because it's the same honesty the digests demand: **the key is published but no request signing is implemented, and there is no OAuth server behind that metadata.** The site cannot authenticate a bot or run an OAuth flow today. The doors are framed; the locks aren't installed. Installing the locks (agent identity and verification) is the next post. For now I'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. To be usable once you arrive. --- **Companion essays:** - [The /llms.txt Standard: An Elegant Solution Nobody's Using](/blog/llms-txt-standard-elegant-solution-nobody-using/). The prequel: why no major platform reads these files, and why I built them anyway. - [AI Slop Is a Search Problem Now](/blog/ai-slop-is-a-search-problem-now/). The reframe this whole post stands on: standards as a way to be *verified*, not discovered. - [Standardizing Web Metadata with Well-Known URIs](/blog/well-known-uris-standardizing-web-metadata/). The `.well-known/` namespace background behind the agent card and api-catalog. - [Building Ask: A RAG-Powered Portfolio Chatbot](/blog/building-ask-rag-portfolio-chatbot/). The conversational counterpart to this static, declarative surface. --- ## AI Slop Is a Search Problem Now > 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. Date: 2026-05-24 Tags: ai, search, web-standards, open-web URL: https://rye.dev/blog/ai-slop-is-a-search-problem-now/ Ask Google "what temperature should I roast a chicken at," and Google answers you. There's a confident paragraph at the top of the page. There are no clicks. The recipe site that taught Google the answer doesn't know you exist. Its ad inventory served nothing. Its newsletter signup didn't fire. The cooking writer who tested four roasting temperatures and wrote about it three years ago isn't compensated, credited, or even visible above the fold. This is the new search bargain, and we're naming the wrong thing when we call its byproduct "AI slop." "AI slop" (Merriam-Webster's, the American Dialect Society's, and Macquarie Dictionary's word of the year for 2025) gets used to mean *low-effort, AI-generated content polluting the internet*. 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. It won'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's not a content problem you can solve by writing better content. It's a search problem. ## What the Numbers Actually Say Let's get the data straight, because the figure that's been floating around, 34.5%, is now twelve months out of date. 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, [Ahrefs' follow-up study](https://ahrefs.com/blog/ai-overviews-reduce-clicks-update/) using December 2025 data put the click reduction at **58%**. Almost double, in eight months. [Pew Research](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/), 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. - Users who saw an AI summary clicked a traditional search result in **8% of visits**. - Users who didn't see one clicked in **15% of visits**. - Clicks on links *inside* the AI summary itself: **1% of visits**. When an AI summary appears, the chance any single visitor clicks anything at all is around 9%. Without one, it's 15%. Almost half the would-be clicks evaporate. 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 **58% of Google searches end in zero clicks**. 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's search monopoly, Penske argues, forces a "forced choice": 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. Google's response was to ship "Further Exploration," a small section of curated links at the bottom of AI Overview answers, designed to send some traffic back. It's a thermostat on a burning house. ![A severed glowing cable representing the broken traffic link and lost clicks between search engines and web publishers.](/images/blog/generated/ai-slop-is-a-search-problem-now-a-severed-glowing-cable-repres-1779668022694.png) ## The Supply-Side Reading Misses the Diagnosis The dominant framing of "AI slop" 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, "AI-free" certifications. This framing isn't wrong, but it's incomplete in a way that matters. It treats the AI-generated content surge as an exogenous shock, something that happened *to* the web from outside. The supply surge isn't autonomous. It's a rational response to a price signal. Until roughly 2023, the implicit deal was: write content, get ranked, get clicks, monetize via ads or conversions, fund more writing. That'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. 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't fall with them, and human writers didn't suddenly become cheaper. So what happens to a business whose revenue per article is dropping but whose cost per article is constant? It writes fewer articles, or it writes cheaper articles. Most chose cheaper. 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 "slop" aren't the economics of malice or laziness. They're the economics of a publisher trying to survive a 33% collapse in search referrals (per Chartbeat) without going out of business. The supply of AI content isn't a content problem. It's a *response* to the search problem. ## The Spiral Once you see the loop, it's hard to unsee: 1. 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. 2. Publishers' click-driven revenue collapses. Pew'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. 3. 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. 4. The corpus answer engines train on and summarize from increasingly consists of AI-assisted content optimized to be ranked and summarized. 5. The summaries get blander, more derivative, more confidently wrong. Users notice. Trust erodes. 6. Search platforms respond by amplifying the AI surface further, since their own AI answer feels more authoritative than the slop substrate beneath it. 7. Loop. Each cycle of this loop makes the next cycle cheaper for the platform, more expensive for the publisher, and worse for the user. It's not a stable equilibrium. It's a tightening spiral. The Cloudflare data point that drives this home: per Cloudflare's own analysis, **Anthropic's ClaudeBot crawls 20,583 pages for every single referral it returns to a publisher**. That is the ratio of extraction to acknowledgement in the current AI/web relationship. It's not a small asymmetry. It is, functionally, a one-way valve. The Reuters Institute's *Journalism, Media, and Technology Trends and Predictions 2026* puts it tactfully: publishers plan to focus on "investigative journalism, analysis, and distinctive reporting" while "reducing investment in more routine content." 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. That's the slop, and it isn't a moral failing of content producers. It's the equilibrium of a market whose price signal got rewired. ![An infinite downward spiral showing crisp data degrading into bland spheres, representing the AI training feedback loop.](/images/blog/generated/ai-slop-is-a-search-problem-now-an-infinite-downward-spiral-sh-1779668040141.jpg) ## Why Standards Can't Fix This I wrote about [`/llms.txt`](/blog/llms-txt-standard-elegant-solution-nobody-using/) last September and concluded it was an elegant solution nobody is using. I want to be specific about *why* it isn't used, because the same reasoning will apply to every voluntary standard proposed to "fix" AI search. `/llms.txt` 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. That assumption is dead. Platforms have no reason to honor a publisher's curation, because the platform's incentive is not to send users to publishers. The platform's incentive is to keep users inside the platform. Reading `/llms.txt` to surface a publisher's preferred summary, then linking out to that publisher, would directly reduce the platform's most important metric (time-on-Google, in Google's case). Why would they? The same argument disqualifies most variants. AI-bot-only robots.txt directives? Honored selectively: most major bots respect them, some don't, and the ones that don't are unaccountable. Schema.org annotations specific to AI consumption? Same incentive problem. A new HTTP header that signals "compensate me to summarize me"? The platform would need a reason to read it. There isn't one. The [Well-Known URIs standard](/blog/well-known-uris-standardizing-web-metadata/) (`/.well-known/security.txt`, `/.well-known/openid-configuration`, the IETF's tidy little namespace) works because the parties on both sides *want* the discovery to succeed. A security researcher and a website owner both benefit from a working `security.txt`. An OAuth client and an identity provider both benefit from `openid-configuration`. Coordination problems get solved when incentives align. Publisher and AI platform incentives don't align. There is no standard you can write that fixes a structural conflict between two parties who would prefer the other to disappear. ## So the AI Is Poisoning the Well It Drinks From 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's a name for that (model collapse, technically), but you don't need a paper to see it. Search results have gotten genuinely worse over the last two years, and the worsening isn't subtle. Cloudflare's Q1 2026 robots.txt analysis found that **89.4% of AI crawler traffic serves training or mixed purposes, not search**. That asymmetry matters. The web isn't being read to be indexed and referred to. It's being read to be ingested, distilled, and returned without attribution. The Reuters Institute's tracking of AI-generated content in fact-checked claims rose from 7% of cases in 2024 to 16% in 2025, and that's just the cases where someone bothered to file a fact-check. The actual prevalence is higher, because most slop doesn't trigger a check; it just sits in the substrate, doing search-engine-optimization work, training the next generation of summaries. The slop isn't sitting in a separate quarantine the AI can ignore. It *is* the AI's input. ## The Exits If standards can't fix it and the spiral is self-reinforcing, what's actually left? A few things, and none of them are search. **Direct subscription.** Newsletters. [RSS](/blog/rss-miniflux-2026/). Bookmarks. The relationships where the reader decides what they read, and the publisher knows their reader exists. These don't scale the way search did, and that's precisely what makes them defensible. Algorithms can't disintermediate a relationship the user formed directly. **Paid relationships.** 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's deliberate decision, not from incidental ad impressions during a search journey. **Infrastructure pushback.** Cloudflare flipped its default in mid-2025: new customers get AI crawlers blocked unless they opt in, and "pay-per-crawl" 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. **Trust signals where discovery used to be.** This is where standards still matter, not as a way to be discovered but as a way to be *verified* once a user has found you. `/.well-known/security.txt`, `/.well-known/openid-configuration`, signed RSS feeds, verified author identity, ATProto handles, Keyoxide profiles. The post-search internet still needs trust infrastructure. It just doesn't need it to function as a search funnel. ![A network diagram showing bright direct connections bypassing a dark central hub, representing direct publisher-to-reader relationships.](/images/blog/generated/ai-slop-is-a-search-problem-now-a-network-diagram-showing-brig-1779668058404.jpg) ## What This Means If You're Building Something on the Web Three implications I'd take seriously if I were starting a project right now: **Design for zero referral.** Assume the search referral to your project is going to keep dropping. Build something that survives at 10% of today's discovery traffic. If your business model only works at 100%, your business model isn't a business model. It's an artifact of a market structure that's being dismantled in real time. **Treat the front page as the relationship.** Newsletter signups, RSS subscribe buttons, and follow-on-Mastodon/Bluesky links aren't 2010 decorations. They'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. **Build for the AI surfaces, but don't depend on them.** Yes, your content will be ingested. Yes, summaries will appear without your link. You can't opt out and expect to remain visible, and you can't opt in and expect to be compensated. Optimize for being *recognizable inside* 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's a tiny fraction of readers. It's the fraction you can actually keep. ## A Note on Optimism There'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'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. But "different" is the operative word. The web didn'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. 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 "market" by 2015 standards. And if you're a person who writes things you'd rather not see compressed, paraphrased, and served at the top of someone else's search results, it might also be the only web worth being on. ## Coda The "AI slop" framing is a comfortable diagnosis. It locates the problem in bad actors making bad content, which means there'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 *router* to *answerer*, and everything downstream of that change, including the slop, is a market responding rationally. You can't fix that with standards. You can't fix it with quality controls on content production. You can only route around it. So route. --- **Sources and further reading:** - [Pew Research Center: Google users are less likely to click on links when an AI summary appears](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/) (July 2025, n=900, 68,879 searches) - [Ahrefs: AI Overviews reduce clicks by 58%](https://ahrefs.com/blog/ai-overviews-reduce-clicks-update/) (February 2026 update) - [Search Engine Journal: Antitrust filing says Google cannibalizes publisher traffic](https://www.searchenginejournal.com/antitrust-filing-says-google-cannibalizes-publisher-traffic/567535/) (Penske Media v. Google) - [Reuters Institute: Journalism, Media, and Technology Trends and Predictions 2026](https://reutersinstitute.politics.ox.ac.uk/journalism-media-and-technology-trends-and-predictions-2026) - [Transparency Coalition: Cloudflare blocks AI crawlers by default](https://www.transparencycoalition.ai/news/cloudflare-becomes-first-infrastructure-provider-to-block-ai-crawlers-by-default) - [The Next Web: Google updates AI Overviews with "Further Exploration" as 58% click decline triggers antitrust suits](https://thenextweb.com/news/google-ai-overviews-publisher-links-search-traffic) **Companion essays in this trilogy:** - [The /llms.txt Standard: An Elegant Solution Nobody's Using](/blog/llms-txt-standard-elegant-solution-nobody-using/) - [RSS Is Still Great (and Miniflux Is the Tool You Need)](/blog/rss-miniflux-2026/) - [Well-known URIs: Standardizing Web Metadata Discovery](/blog/well-known-uris-standardizing-web-metadata/) --- ## Building Aranet: A Rust Toolkit for Liberating Environmental Sensor Data > 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. Date: 2026-04-04 Tags: rust, protocols, open-source URL: https://rye.dev/blog/building-aranet-rust-ble-environmental-monitoring/ My Aranet4 has been sitting on my desk for over a year. It'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't export anything useful. The data was mine. I just couldn't get to it. 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. ## Why Environmental Monitoring Matters 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. Radon is even more consequential. It's the second leading cause of lung cancer and you can't detect it without a sensor. The EPA estimates that radon causes about 21,000 lung cancer deaths per year in the US alone. 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. ## Starting at the Bottom: BLE Protocol Reverse Engineering The first challenge was understanding how Aranet devices communicate over Bluetooth Low Energy. There's no official protocol documentation. The [Aranet4-Python](https://github.com/Anrijs/Aranet4-Python) project provided a starting point, but I needed to support four device families (Aranet4, Aranet2, AranetRn+, and Aranet Radiation), each with different data formats. 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. Here's where the protocol gets interesting. Aranet4 was the first device and its advertisement format doesn't include a device-type prefix. Later devices (Aranet2, AranetRn+, Aranet Radiation) prepend a type byte. The parser has to handle this inconsistency: ```rust pub fn parse_advertisement_with_name( data: &[u8], name: Option<&str>, ) -> Result { let is_aranet4_by_name = name .map(|n| n.starts_with("Aranet4")) .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 => DeviceType::Aranet2, 0x02 => DeviceType::AranetRadiation, 0x03 => DeviceType::AranetRadon, other => return Err(Error::InvalidData( format!("Unknown device type byte: 0x{:02X}", other), )), }; (device_type, &data[1..]) }; // ... } ``` 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. ![Scanning for nearby Aranet devices over Bluetooth LE with the aranet CLI.](/images/blog/aranet/cli-scan.gif) ## Dealing with BLE's Unreliability Anyone who's worked with Bluetooth knows it's flaky. Devices disappear, connections drop, scans return empty. A monitoring tool that crashes or hangs when BLE misbehaves is useless. The scanner uses exponential backoff with a cap, retrying both failed scans and empty results: ```rust pub async fn scan_with_retry( options: ScanOptions, max_retries: u32, retry_on_empty: bool, ) -> Result> { 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() && retry_on_empty && attempt < max_retries => { attempt += 1; warn!("No devices found, retrying ({}/{})...", attempt, max_retries); sleep(delay).await; delay = delay.saturating_mul(2).min(Duration::from_secs(5)); } Ok(devices) => return Ok(devices), Err(e) if attempt < max_retries => { attempt += 1; warn!("Scan failed ({}), retrying ({}/{})...", e, attempt, max_retries); sleep(delay).await; delay = delay.saturating_mul(2).min(Duration::from_secs(5)); } Err(e) => return Err(e), } } } ``` The `saturating_mul` prevents overflow on the delay, and the 5-second cap keeps retries responsive. The `retry_on_empty` flag matters: sometimes you want to distinguish "no devices nearby" from "BLE stack isn't ready yet." This is a small function, but it's the difference between a tool that works on a bench and one that works in production. On Linux, there'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. ## The Seven-Crate Architecture 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. ``` 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) ``` The dependency graph flows strictly downward. `aranet-types` has no dependencies on other workspace crates. `aranet-core` depends only on `aranet-types`. `aranet-store` depends on `aranet-types` and `aranet-core`. The three interface crates (`cli`, `tui`, `gui`) and `aranet-service` sit at the top, consuming the lower layers. 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. ## Local-First Data: SQLite and Incremental Sync 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. The `aranet-store` 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: ```rust /// Incremental Sync Algorithm: /// /// 1. Read device'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("Aranet4 17C3C", 500)?; /// assert_eq!(start, 1); /// /// Next sync — device now has 510 records: /// let start = store.calculate_sync_start("Aranet4 17C3C", 510)?; /// assert_eq!(start, 501); // Only download 10 new records ``` Records are deduplicated at the SQLite level by `(device_id, timestamp)` 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. 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't notice. ![Reading current measurements from an Aranet4 sensor.](/images/blog/aranet/cli-read.gif) ## The Terminal Dashboard 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 [ratatui](https://ratatui.rs/), provides real-time multi-device monitoring with sparkline charts and threshold alerts. 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'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. The sparkline charts show min/max labels and adapt to terminal width. It's the kind of information density that a mobile app can't match. ## Desktop GUI For less terminal-inclined users (or when I want a persistent window rather than a terminal pane), `aranet-gui` provides a desktop application built with [egui](https://egui.rs/): ![The aranet-gui desktop application showing multi-panel device monitoring with alerts and history views.](/images/blog/aranet/gui-main.png) 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 `aranet-service` directly from the GUI. ## From Sensors to Grafana: The Service Layer The `aranet-service` 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. The Prometheus integration filters metrics by device capability. Aranet2 sensors don't have CO2, so they shouldn't emit `aranet_co2_ppm` metrics: ```rust for (device, reading) in &device_readings { let device_type = resolve_device_type(device); if device_type.is_none_or(|dt| dt.has_co2()) && reading.co2 > 0 { co2_lines.push(format!( "aranet_co2_ppm{{{}}} {}\n", labels, reading.co2 )); } if device_type.is_none_or(|dt| dt.has_temperature()) { temp_lines.push(format!( "aranet_temperature_celsius{{{}}} {:.2}\n", labels, reading.temperature )); } } ``` 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 `docker compose up -d` and you have a complete monitoring stack scraping your Aranet sensors. 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. ## Why Rust for IoT Environmental monitoring runs continuously on modest hardware, often a Raspberry Pi tucked behind a bookshelf. Rust's zero-cost abstractions and memory safety aren't academic niceties here; they're practical requirements for a long-running BLE daemon that needs to be reliable without consuming resources. 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't link against egui, and the GUI doesn't pull in the Prometheus library. Cross-compilation to `aarch64-unknown-linux-gnu` (Raspberry Pi) works out of the box with `cross`. The CI pipeline builds and tests on macOS, Linux, and Windows. ## Distribution: Meeting Users Where They Are A tool nobody can install is a tool nobody uses. The project ships through multiple channels: ```bash # Homebrew (macOS/Linux) brew tap cameronrye/aranet && brew install aranet # crates.io cargo install aranet-cli # Shell installer (macOS/Linux) curl --proto '=https' --tlsv1.2 -LsSf \ https://github.com/cameronrye/aranet/releases/latest/download/aranet-cli-installer.sh | sh # Docker (full monitoring stack) docker compose up -d ``` 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's a path that works. ![Downloading measurement history from an Aranet4 with progress tracking and incremental sync.](/images/blog/aranet/cli-history.gif) ## Liberating Your Data 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're renting access to your own measurements. 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 "sensor produces data" and "data is useful" was still filled entirely by a mobile app with no export, no API, and no automation. Building from BLE packets up through storage, APIs, and dashboards in a single workspace proves that this gap doesn'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. The project is [open source on GitHub](https://github.com/cameronrye/aranet) and published to [crates.io](https://crates.io/crates/aranet-cli). If you have an Aranet sensor, `brew install aranet` and run `aranet scan`. Your data is waiting. --- ## RSS Is Still Great (and Miniflux Is the Tool You Need) > 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. Date: 2026-02-05 Tags: open-web, self-hosting, web-standards URL: https://rye.dev/blog/rss-miniflux-2026/ In 2026, the best way to read the internet is a 29-year-old technology that most people think died with Google Reader. RSS (Really Simple Syndication) was created by Netscape in 1997 and later refined by Aaron Swartz. It'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's "relevant." No engagement metrics determine what surfaces. Just content, in chronological order, from sources you chose. This isn't nostalgia. It's a rational response to what the web has become. ## The Problem: Algorithms Ate the Web 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't ask for any of this. 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. As one writer put it, the post-Google Reader era gave us "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 'influencer.'" That's not hyperbole. That's a description of the current state of content consumption. ![A split comparison showing the mess of algorithmic feeds versus the clean, chronological order of RSS.](/images/blog/generated/rss-miniflux-2026-a-split-comparison-showing-the-1770314061766.jpg) Search results are polluted with SEO spam. AI-generated garbage floods every platform. Google's AI Overviews have reduced organic clicks by 34.5%, keeping users in Google'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. PC Gamer ran a piece in January calling 2026 "the year of the glorious return of the RSS reader," encouraging readers to "kill the algorithm in your head." They're not wrong. ## The Solution: Take Back Control RSS offers something radical: you choose what you read, in the order it was published, with zero tracking. **You choose your sources.** No algorithm decides what's "relevant" to you. You subscribe to writers, publications, and topics you care about. If something stops being valuable, you unsubscribe. Simple. **Chronological order.** Content appears when it's published, not when it's "trending." There's no algorithmic amplification of inflammatory takes. No engagement-bait rising to the top. Just a timeline that respects the passage of time. **No ads, no tracking, no engagement bait.** RSS feeds are just data. They don't contain tracking pixels, don't set cookies, don't build advertising profiles. Your reading habits remain yours. **Portability.** OPML export means you're never locked in. Don't like your current reader? Export your subscriptions and import them elsewhere. Try doing that with your YouTube recommendations or Twitter timeline. **It makes the web feel manageable.** Instead of the infinite scroll, you have a finite reading list. You can actually reach the end. There's something psychologically healthy about completing your reading rather than drowning in an endless stream. 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. ## Why Miniflux Gets It Right There are dozens of RSS readers available. I've tried most of them. Miniflux is the one that stuck, and the reason comes down to philosophy. Miniflux is a **minimalist and opinionated** self-hosted feed reader created by Frédéric Guillot. It'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. 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. As one reviewer noted: "Coming from feature-rich, busy social media apps, Miniflux's interface may feel boring at first." That's the point. The absence of distraction is the feature. ![A diagrammatic illustration showing how Miniflux strips trackers and ads, acting as a privacy filter.](/images/blog/generated/rss-miniflux-2026-a-diagrammatic-illustration-sh-1770314078151.jpg) ### Privacy by Design Miniflux treats privacy as an architectural concern, not an afterthought: - **Strips tracking pixels** automatically from feed content - **Removes UTM parameters** and other tracking cruft from URLs - **Proxies media** through the server to prevent third-party tracking - **Opens external links** with `rel="noopener noreferrer"` and `referrerpolicy="no-referrer"` - **Plays YouTube videos** via `youtube-nocookie.com` - **Zero telemetry, zero advertising** When every app harvests data by default, Miniflux's stance is refreshing. It respects HTTP caching headers to avoid hammering servers. It doesn't phone home. It just does its job. ### Keyboard-Driven Workflow Miniflux is designed for people who read a lot. Full keyboard shortcuts let you fly through hundreds of articles: - Arrow keys for navigation - `v` to open the original article - `s` to star/bookmark - `d` to fetch full article content - `/` for search 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. ![An illustration of the RSS ecosystem, showing Miniflux as the central hub connecting to devices and other services.](/images/blog/generated/rss-miniflux-2026-an-illustration-of-the-rss-eco-1770314095505.jpg) ### 25+ Integrations One of Miniflux's underrated strengths is its integration ecosystem: | Category | Services | |----------|----------| | **Read-it-later** | Wallabag, Instapaper, Pocket, Readwise Reader | | **Bookmarking** | Pinboard, Linkding, LinkAce, Shaarli | | **Notifications** | Discord, Slack, Telegram, Matrix, Ntfy, Pushover | | **Note-taking** | Notion | | **Automation** | Webhooks, Apprise | The full REST API means you can build whatever custom integrations you need. There's also Fever API and Google Reader API compatibility, which opens up dozens of existing mobile apps. ### Deployment Getting Miniflux running takes about five minutes: ```yaml # docker-compose.yml services: miniflux: image: miniflux/miniflux:latest ports: - "8080:8080" 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: ``` Run `docker-compose up -d`, navigate to `localhost:8080`, and you're done. For those who don't want to self-host, there's an official hosted option at reader.miniflux.app for $15/year. ## The RSS Ecosystem Miniflux doesn't exist in isolation. A whole ecosystem of tools makes RSS more useful. ### Feed Generators Many sites have removed their RSS feeds or never had them. These tools bridge the gap: **RSS-Bridge** is a PHP application that generates feeds for sites that removed them: YouTube, Twitter/X, Reddit, Telegram, and dozens more. The project's README includes a manifesto worth quoting: "Dear so-called 'social' websites... You're not social when you hamper sharing by removing feeds... We are rebuilding bridges you have willfully destroyed." **RSSHub** 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. ### Alternative Frontends Miniflux's spartan interface isn't for everyone. Third-party frontends offer alternatives: - **ReactFlux**: Beautiful React-based web frontend with a more visual approach - **Nextflux**: Modern Reeder-inspired UI, PWA-capable - **Reminiflux** and **Fluxjs**: Additional web frontend options These connect to Miniflux via its API, giving you the backend's reliability with a different presentation layer. ### Mobile Apps The Fever and Google Reader API compatibility means Miniflux works with excellent mobile apps: - **iOS**: Unread, Fiery Feeds, Lire - **Android**: Miniflutt (FOSS), Read You (Material You design), News+ ### Honest Limitations Miniflux isn't perfect for everyone: - **No feed discovery.** You need to know your sources. If you want recommendation features, FreshRSS might be better. - **Regex-only filtering.** Block rules require regex knowledge, not a simple keyword UI. - **Spartan by design.** Some people genuinely want more features. That's valid. For me, these limitations are features. The lack of discovery means I'm intentional about what I subscribe to. The minimal interface means I focus on reading, not fiddling with settings. ## Getting Started If you're new to RSS, here's a practical starting point: 1. **Deploy Miniflux** using the Docker Compose configuration above, or sign up for the hosted version. 2. **Add feeds you already read.** Most sites still have RSS feeds at `/feed/`, `/rss/`, or `/feed.xml`. Browser extensions like "Get RSS Feed URL" can help find them. 3. **Subscribe to writers, not publications.** Individual bloggers often have better signal-to-noise ratios than large publications. 4. **Use RSS-Bridge** for sites that don't have feeds. YouTube channels, Reddit subreddits, and Twitter accounts can all become RSS feeds. 5. **Export your OPML** periodically as a backup. This is your subscription list in a portable format. 6. **Resist the urge to subscribe to everything.** Start with 10-20 feeds. Add more only when you find yourself wanting more content. The goal isn't to replicate the firehose of social media. It's to curate a reading list that actually serves your interests. ## The Bigger Picture RSS won't save the internet. The platform incentives that created the current mess aren'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. But RSS might save your relationship with the internet. There'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. In a web increasingly optimized for everyone's attention, RSS is optimized for yours. The technology is 29 years old. It's been declared dead a dozen times. And it's still the best way to read the internet in 2026. --- **Resources:** - [Miniflux](https://miniflux.app) - Official site and documentation - [Miniflux GitHub](https://github.com/miniflux/v2) - Source code - [RSS-Bridge](https://github.com/RSS-Bridge/rss-bridge) - Generate feeds for sites without them - [RSSHub](https://github.com/DIYgod/RSSHub) - Community-driven feed generator - [awesome-selfhosted RSS readers](https://awesome-selfhosted.net/tags/feed-readers.html) - Comprehensive list of alternatives --- ## Building Zero Crust: Distributed State Management in Electron > 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. Date: 2026-01-28 Tags: electron, react, typescript, security URL: https://rye.dev/blog/building-zero-crust-distributed-state-electron/ 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're open to price tampering. [Zero Crust](https://github.com/cameronrye/zero-crust) is my exploration of these patterns in Electron. It's a reference implementation for distributed state management with the defense-in-depth security that desktop applications need. ## The Dual-Head Challenge In production POS deployments, the cashier terminal and customer-facing display are often separate physical devices. The cashier's screen shows product grids, payment controls, and management functions. The customer's screen shows only the cart, a simple display that trusts nothing. Electron'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's the only process with access to payment services, persistence, and the application state. ```text ┌─────────────────┐ ┌─────────────────┐ │ Cashier Window │ │ Customer Window │ │ (Renderer) │ │ (Renderer) │ │ ┌─────────┐ │ │ ┌─────────┐ │ │ │ Cart UI │ │ │ │ Cart UI │ │ │ └─────────┘ │ │ └─────────┘ │ └────────┬────────┘ └────────┬────────┘ │ │ │ IPC Commands │ ▼ ▼ ┌──────────────────────────────────────────┐ │ Main Process │ │ ┌──────────┐ ┌─────────┐ ┌──────────┐ │ │ │MainStore │ │Payment │ │Broadcast │ │ │ │ (State) │ │Service │ │Service │ │ │ └──────────┘ └─────────┘ └──────────┘ │ └──────────────────────────────────────────┘ ``` ## Centralized State with MainStore The heart of Zero Crust is `MainStore`, 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. ```typescript // MainStore.ts - Centralized state management export class MainStore { private state: InternalState; private listeners = new Set(); private updateState(recipe: (draft: InternalState) => void): void { this.state = produce(this.state, (draft) => { recipe(draft); draft.version++; }); this.notifyListeners(); } } ``` The key insight here is **state versioning**. 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's structural sharing, updates are both immutable and efficient. ![Diagram of the Command Pattern and state broadcasting between the renderers and the main process.](/images/blog/generated/building-zero-crust-distributed-state-electron-a-polished-visualization-of-th-1769401633388.png) ## The Command Pattern for IPC Renderers don't mutate state directly. They can't. They send **commands** to the main process, which validates and processes them. This is the Command Pattern applied to IPC: ```typescript // ipc-types.ts - Discriminated union of commands export type Command = | { type: 'ADD_ITEM'; sku: string } | { type: 'REMOVE_ITEM'; sku: string } | { type: 'UPDATE_QUANTITY'; sku: string; quantity: number } | { type: 'CLEAR_CART' } | { type: 'START_PAYMENT' } | { type: 'VOID_TRANSACTION' }; ``` Notice that renderers send **SKUs, not prices**. 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. ## Runtime Validation with Zod 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: ```typescript // schemas.ts - Runtime validation export const AddItemSchema = z.object({ type: z.literal('ADD_ITEM'), sku: z.string().min(1).max(50), }); export const CommandSchema = z.discriminatedUnion('type', [ AddItemSchema, RemoveItemSchema, UpdateQuantitySchema, ClearCartSchema, StartPaymentSchema, VoidTransactionSchema, ]); ``` 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. ## Defense in Depth: Electron Security Zero Crust implements six layers of security, each catching threats that slip past others: ![Layered security barriers protecting the core application state.](/images/blog/generated/building-zero-crust-distributed-state-electron-illustrates-the-concept-of-mul-1769401647983.jpg) **1. Electron Fuses:** Compile-time flags that cannot be changed at runtime. Node.js integration is disabled at the binary level. **2. Context Isolation:** Renderer processes run in an isolated JavaScript context. They cannot access Node.js APIs, Electron internals, or the preload script's scope. **3. Zod Validation:** Every IPC message is validated against a strict schema. Malformed or unexpected data is rejected. **4. Sender Verification:** IPC handlers check `event.sender` against known window IDs. Commands from unknown sources are dropped. **5. Navigation Control:** All navigation is blocked except to `file://` URLs. No external websites can be loaded into windows. **6. Permission Denial:** All permission requests (camera, microphone, geolocation) are denied by default. ```typescript // SecurityHandlers.ts - Sender validation function validateSender(event: IpcMainInvokeEvent): boolean { const webContents = event.sender; const knownIds = windowManager.getKnownWebContentsIds(); return knownIds.includes(webContents.id); } ``` ## The BroadcastService Pattern 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: **broadcast the entire state on every change**. ```typescript // BroadcastService.ts - Full-state sync export class BroadcastService { constructor(mainStore: MainStore, windowManager: WindowManager) { mainStore.subscribe((state) => { windowManager.broadcastState(state); }); } } ``` 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. This pattern eliminates entire categories of bugs: - **No stale state:** Renderers always have the latest version - **No synchronization drift:** State is identical across all windows by construction - **No ordering issues:** Each broadcast is a complete snapshot - **Trivial debugging:** Log any state snapshot and you see exactly what all renderers see The performance cost? Negligible. A typical POS cart has maybe 20 items. Serializing and deserializing that with `structuredClone` takes microseconds. ## The Architecture Debug Window Debugging distributed systems is hard. You can't set a breakpoint across process boundaries. You can't easily trace the flow of messages between windows. That's why Zero Crust includes a real-time Architecture Debug Window. ![Architecture Debug Window](/screenshots/debugger.png) The debug window shows: - **Event Timeline:** Every IPC message, state update, and trace event in chronological order - **Architecture Graph:** Visual representation of windows and message flow with animated edges - **State Inspector:** JSON tree view with diff highlighting showing exactly what changed - **Live Statistics:** Events per second, average latency, state version The implementation uses a circular buffer to store trace events without unbounded memory growth: ```typescript // TraceService.ts - Event collection export class TraceService { private events: TraceEvent[] = []; private maxEvents = 1000; record(event: Omit): void { const fullEvent = { ...event, id: this.nextId++, timestamp: Date.now(), }; this.events.push(fullEvent); if (this.events.length > this.maxEvents) { this.events.shift(); } this.broadcast(fullEvent); } } ``` Critically, the debug window is **lazy activated**. TraceService only collects events when the debug window is open. No overhead when you don't need it. ## Integer Math for Currency Here's a bug that bankrupts companies: ```javascript 0.1 + 0.2 === 0.3 // false! It's 0.30000000000000004 ``` 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 **integers representing cents**: ```typescript // currency.ts - Integer-only currency export type Cents = number & { __brand: 'Cents' }; export function toCents(dollars: number): Cents { return Math.round(dollars * 100) as Cents; } export function formatCurrency(cents: Cents): string { return `$${(cents / 100).toFixed(2)}`; } ``` The branded type `Cents` makes it impossible to accidentally mix cents and dollars. TypeScript will error if you pass a regular number where `Cents` is expected. This pattern extends to all calculations: - Tax is computed as `(subtotal * taxRate) / 100`, rounded - Discounts are stored and applied as cent values - Totals are summed, never multiplied by fractional amounts ## Screenshots Here's Zero Crust in action: ![Cashier Window](/screenshots/cashier.png) *The cashier window with product grid and cart sidebar* ![Customer Display](/screenshots/customer.png) *Customer display showing the synchronized cart state* ![Transaction History](/screenshots/transactions.png) *Transaction history with completed orders* ## Lessons Learned Building Zero Crust reinforced several principles: **Simplicity beats cleverness.** Full-state broadcast is "inefficient" but eliminates entire bug categories. The debuggability alone is worth it. **Security is layers.** 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. **TypeScript needs runtime backup.** Types are erased at runtime. Process boundaries need runtime validation. Zod provides this beautifully. **Debug tools pay dividends.** The Architecture Debug Window took significant effort to build. It's saved ten times that in debugging time. **Model real hardware.** Designing for dual-head from the start forced clean separation. The constraints improved the architecture. ## Try It Yourself Zero Crust is open source at [github.com/cameronrye/zero-crust](https://github.com/cameronrye/zero-crust). Clone it, run `pnpm dev`, and explore the architecture. The debug window (View > Architecture or Cmd+Shift+A) is the best way to understand the message flow. The patterns here aren't specific to point-of-sale. If you're working on any multi-window Electron app, I hope some of it transfers. --- ## Building Ask: A RAG-Powered Chatbot for My Portfolio > 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. Date: 2026-01-12 Tags: ai, search, typescript, astro URL: https://rye.dev/blog/building-ask-rag-portfolio-chatbot/ 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. 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're viewing. This post documents how I built it. ![A visual representation of the serverless/edge architecture described in the post, showing the flow of data between components.](/images/blog/generated/building-ask-rag-portfolio-chatbot-a-visual-representation-of-the-1768090335771.jpg) ## The Architecture Ask runs entirely on Cloudflare's edge infrastructure. There's no origin server, no container to manage, no cold starts to worry about. The stack consists of: - **Frontend**: Preact component with Nanostores for state management - **API**: Astro API routes deployed to Cloudflare Workers - **RAG**: AI Search (AutoRAG) with Vectorize fallback - **LLM**: Llama 3.3 70B via Workers AI - **Observability**: AI Gateway for request logging and analytics 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. ![Visualizes the concept of 'Chunking' and vector embedding, illustrating how raw text is broken down and processed for the AI.](/images/blog/generated/building-ask-rag-portfolio-chatbot-visualizes-the-concept-of-chun-1768090364218.png) ## RAG: Teaching the AI About My Work 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 "What MCP servers has Cameron built?", the system: 1. Searches the knowledge base for relevant content 2. Retrieves the top matches (blog posts about gopher-mcp, openzim-mcp, etc.) 3. Injects that context into the system prompt 4. Lets the LLM generate a grounded response The chunking strategy matters. I split content by paragraphs, respecting a 2000-character maximum with 200-character overlap between chunks: ```typescript export function chunkText( text: string, maxChars = 2000, overlap = 200 ): string[] { const chunks: string[] = []; const paragraphs = text.split(/\n\n+/); let currentChunk = ''; for (const paragraph of paragraphs) { const trimmed = paragraph.trim(); if (!trimmed) continue; if (currentChunk && currentChunk.length + trimmed.length + 2 > 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(' ') + '\n\n' + trimmed; } else { currentChunk = currentChunk ? currentChunk + '\n\n' + trimmed : trimmed; } } if (currentChunk.trim()) { chunks.push(currentChunk.trim()); } return chunks; } ``` The overlap ensures that concepts spanning paragraph boundaries don't get lost. Each chunk gets embedded using BGE Base EN v1.5, producing 768-dimensional vectors stored in Cloudflare Vectorize. ## Context-Aware Conversations 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's tech and architecture. On a 404, Ask tries to help you find what you were looking for. This works through a page context system. Each page passes metadata to the chat component: ```typescript interface PageContext { type: 'default' | 'blog' | 'project' | '404'; title?: string; slug?: string; tags?: string[]; description?: string; } ``` The system prompt gets augmented with this context, so the LLM understands what the visitor is currently reading and can provide more relevant responses. ## The System Prompt: Expert on My Work, Not Me One design decision I'm particularly happy with: Ask is an expert system *about* my work, not a simulation of me. The distinction matters. The prompt explicitly states: > "You are Ask, an AI assistant on Cameron Rye's portfolio website at rye.dev. You are an expert system about Cameron's work, projects, and technical expertise—not Cameron himself." 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. ![Illustrates the security layer and input sanitization, showing the filtering of 'malicious' prompt injections versus 'safe' user queries.](/images/blog/generated/building-ask-rag-portfolio-chatbot-illustrates-the-security-layer-1768090395222.png) ## Security: Hardening Against Prompt Injection Any public-facing LLM application needs security hardening. Ask implements multiple layers of defense: **Input Sanitization**: Before any processing, user input gets sanitized. Control characters are stripped, excessive whitespace is normalized, and the input is truncated to a reasonable length. **Prompt Injection Detection**: 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's behavior: ```typescript 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, ]; ``` **Rate Limiting**: 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. **Response Filtering**: The LLM'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. ## Streaming: Real-Time Response Delivery Nobody wants to wait for a complete response before seeing anything. Ask uses Server-Sent Events (SSE) to stream tokens as they're generated: ```typescript const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); for await (const chunk of aiStream) { const text = chunk.response || ''; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ text })}\n\n`) ); } controller.enqueue(encoder.encode('data: [DONE]\n\n')); controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); ``` The frontend parses these events and updates the UI in real-time, giving that satisfying "typing" effect as the response streams in. ## The UI: Minimal and Unobtrusive 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's a contained panel that doesn't interfere with the main content. 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. 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: ```typescript export const chatStore = atom({ messages: [], isLoading: false, error: null, isOpen: false, }); ``` ## Lessons Learned **RAG quality depends on chunking strategy.** My first attempt used fixed-size chunks that often split sentences mid-thought. Switching to paragraph-aware chunking with overlap dramatically improved retrieval quality. **System prompts need iteration.** 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. **Edge deployment changes everything.** Running on Cloudflare Workers means the entire request, from receiving the message to starting the stream, happens in under 50ms. There's no cold start penalty, no container spin-up, just immediate response. **Security is non-negotiable.** 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. ## What's Next Ask is live and working, but there's always room for improvement. Future enhancements I'm considering: - **Conversation memory**: Currently each message is independent. Adding conversation history would enable more natural multi-turn dialogues. - **Citation links**: When Ask references a blog post or project, it should link directly to that content. - **Analytics integration**: Understanding what visitors ask about could inform future content. The code is part of my portfolio site, which is open source. If you're building something similar, feel free to explore the implementation. --- *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'd like to know.* --- ## Uzumaki: One Spiral Engine, Two Native Platforms > Ten spiral algorithms, six deployment targets: how I kept React and SwiftUI renders of Uzumaki mathematically identical across web and Apple platforms. Date: 2026-01-08 Tags: react, typescript, visualization URL: https://rye.dev/blog/uzumaki-cross-platform-spiral-visualization/ 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. 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. ## Ten Algorithms, One Canvas 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 `r` is the radius and `theta` is the angle. Fibonacci golden spiral rendered with aurora color preset and glow effect ### Polar Coordinate Spirals The simpler spirals translate directly from mathematical formulas: ```typescript // 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; ``` ### Construction Spirals Other spirals require iterative construction rather than simple formulas: ```typescript // Theodorus: built from right triangles for (let n = 1; n <= 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 < numSteps; n++) { const theta = n * GOLDEN_ANGLE + rotation; const r = scale * Math.sqrt(n) * 2; } ``` The Curlicue fractal produces particularly striking patterns by accumulating angles based on the golden ratio squared. ## Web Performance: Workers and TypedArrays 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. ```typescript export function generateSpiralTyped(params: SpiralParams): TypedSpiralPoints { const points = createTypedPoints(numSteps); // Float32Array const rotation = time * spinRate; for (let i = 0; i < 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; } ``` TypedArrays are transferable between the main thread and Web Workers without copying, eliminating serialization overhead. The interleaved `[x0, y0, x1, y1, ...]` format maps directly to canvas drawing operations. Uzumaki running on macOS showing the Classic Golden spiral preset ## Swift Parity: SIMD Vectorization The Swift implementation needed matching performance. Apple's SIMD framework enables vectorized math operations that process multiple points simultaneously: ```swift func generatePolarSpiral(params: SpiralParams) -> [SIMD2] { var points: [SIMD2] = [] let rotation = params.time * params.spinRate for i in 0.. Key adaptations for watchOS: - **Digital Crown**: Smooth zoom control with haptic feedback at preset boundaries - **Swipe Navigation**: Horizontal swipes cycle through preset configurations - **Complications**: Circular, corner, rectangular, and inline complications show static spiral art - **Tap Gestures**: Single tap toggles animation, double tap resets zoom The complications put spiral art on the watch face, cycling to a different preset each hour. ## Shareable URLs One feature absent from native apps appears on web: shareable URLs. Every spiral configuration encodes into a URL that recreates the exact state: ```typescript 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)); } ``` Users can share spiral creations by copying the URL. The recipient sees the identical animation without any configuration. ## Lessons Learned **Canvas rendering scales well.** Both HTML Canvas and SwiftUI Canvas handle thousands of animated points at 60fps when computation moves off the render thread. **TypedArrays are underutilized.** Most JavaScript developers default to regular arrays. For numerical computation, Float32Array offers significant performance gains and enables zero-copy Worker communication. **Algorithm documentation prevents drift.** Without a formal specification, subtle differences accumulate between implementations. The shared algorithm document caught several bugs during development. **Platform idioms matter.** Users expect swipe gestures on iOS and keyboard shortcuts on desktop. Forcing identical interaction patterns across platforms feels unnatural. --- *Explore mathematical spirals at [uzumaki.app](https://uzumaki.app) or browse the source code on [GitHub](https://github.com/cameronrye/uzumaki). Download for [iOS and iPadOS](https://apps.apple.com/app/uzumaki/id6757408848) or [macOS](https://apps.apple.com/app/uzumaki/id6757408848?platform=mac) on the App Store.* --- ## Building ClarissaBot: Vehicle Safety Intelligence with Azure AI Foundry > I built an AI agent that answers 'should I worry about my 2020 Model 3?' with live NHTSA recall data: Azure AI Foundry, .NET, and RFT in practice. Date: 2025-12-20 Tags: ai, agents, dotnet URL: https://rye.dev/blog/building-clarissabot-azure-ai-foundry/ 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's an AI agent that answers natural language questions about recalls, safety ratings, and consumer complaints by querying NHTSA data in real-time. This project became an exploration of Azure AI Foundry's capabilities: function calling, streaming responses, managed identity authentication, and the emerging practice of Reinforcement Fine-Tuning. Here's what I learned building it. ## The Problem Space 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're looking for. Ask "should I be worried about my 2020 Tesla Model 3?" and you get a list of recall campaigns, not an answer. I wanted to build something that could: - Answer questions in natural language - Pull real-time data from authoritative sources - Maintain context across a conversation ("what about complaints?" after asking about recalls) - Decode VINs to identify vehicles automatically ## What Azure AI Foundry Provides Azure AI Foundry (formerly Azure Cognitive Services / Azure OpenAI) provides the infrastructure that makes ClarissaBot possible. Beyond just hosting models, it offers: - **Function Calling**: The model can decide to call external tools based on user intent - **Streaming Responses**: Server-Sent Events for real-time token delivery - **Managed Identity**: No API keys in configuration, just Azure RBAC - **Reinforcement Fine-Tuning**: Train specialized models using custom graders The SDK integration with .NET is surprisingly clean. Using `Azure.AI.OpenAI` and `DefaultAzureCredential`: ```csharp var credential = new DefaultAzureCredential(); var client = new AzureOpenAIClient(new Uri(endpoint), credential); var chatClient = client.GetChatClient(deploymentName); ``` No API keys to rotate. No secrets to manage. Just identity-based access. ![A visual representation of the ReAct pattern where the AI model connects to an external tool to retrieve data before answering.](/images/blog/generated/building-clarissabot-azure-ai-foundry-a-visual-representation-of-the-1766257607257.jpg) ## Function Calling: Teaching the Model to Act 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: ```csharp ChatTool.CreateFunctionTool( "check_recalls", "Check for vehicle recalls from NHTSA.", BinaryData.FromObjectAsJson(new { type = "object", properties = new { make = new { type = "string", description = "Vehicle manufacturer" }, model = new { type = "string", description = "Vehicle model name" }, year = new { type = "integer", description = "Model year" } }, required = new[] { "make", "model", "year" } })) ``` The model receives tool definitions, decides when to call them, and synthesizes the results into conversational responses. It's the ReAct pattern in action: Reason about the task, Act by calling tools, Observe results, Repeat. ## The Challenge of Vehicle Context The hardest problem wasn't calling APIs. It was maintaining conversational context. When a user asks "any recalls?" after discussing their Tesla Model 3, the agent needs to remember what vehicle they're talking about. The solution tracks vehicle context across turns: ```csharp public sealed class VehicleContextHistory { private readonly List<(VehicleContext Vehicle, DateTime AccessedUtc)> _vehicles = []; public VehicleContext? Current => _vehicles.Count > 0 ? _vehicles[^1].Vehicle : null; public bool AddOrUpdate(VehicleContext vehicle) { var existingIndex = _vehicles.FindIndex(v => v.Vehicle.Key == vehicle.Key); if (existingIndex >= 0) { _vehicles.RemoveAt(existingIndex); _vehicles.Add((vehicle, DateTime.UtcNow)); return false; } _vehicles.Add((vehicle, DateTime.UtcNow)); return true; } } ``` Context gets injected into the system prompt on each turn, reminding the model which vehicles are being discussed. ## Streaming: Making AI Feel Responsive Nothing kills user experience like staring at a blank screen. ClarissaBot streams responses token-by-token using Server-Sent Events: ```csharp public async IAsyncEnumerable 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); } } } } ``` The frontend receives typed events: `ContentChunkEvent` for text, `ToolCallEvent` when querying NHTSA, `VehicleContextEvent` when the vehicle changes. Users see the agent "thinking" in real-time. ## Reinforcement Fine-Tuning: Training with Live Data 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: ![A diagrammatic representation of the training loop where a grader evaluates and refines model outputs.](/images/blog/generated/building-clarissabot-azure-ai-foundry-a-diagrammatic-representation--1766257623041.jpg) ```python def grade_response(response: str, expected: dict) -> float: """Grades model response against live NHTSA data.""" api_result = query_nhtsa(expected['year'], expected['make'], expected['model']) if expected['query_type'] == 'recalls': return score_recall_response(response, api_result) elif expected['query_type'] == 'safety_rating': return score_rating_response(response, api_result) # ... ``` 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. ## Infrastructure as Code with Bicep The entire infrastructure deploys through Azure Bicep templates: ```bicep module apiApp 'modules/container-app.bicep' = { params: { name: '${baseName}-api-${environment}' containerAppsEnvironmentId: containerAppsEnv.outputs.id containerImage: apiImage useManagedIdentity: true envVars: [ { name: 'AZURE_OPENAI_ENDPOINT', value: azureOpenAIEndpoint } { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: monitoring.outputs.appInsightsConnectionString } ] } } ``` 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. ## Lessons Learned **Function calling changes how you supply knowledge.** Instead of cramming knowledge into model weights, give it tools. The model reasons about *when* to use tools; you implement *what* tools do. **Context management is underrated.** Users expect conversational continuity. Tracking vehicle context across turns transformed the experience from "query interface" to "conversation." **Streaming is non-negotiable.** Even with fast responses, the perceived latency of waiting for a complete response feels slow. Token-by-token streaming makes AI feel alive. **Managed identity simplifies everything.** No API key rotation, no secrets in configuration, no accidental exposure. Just RBAC permissions on Azure resources. **RFT trains against a moving target.** Training against live data means models stay current as the world changes. The grader becomes the source of truth. ## What's Next ClarissaBot currently uses GPT-4.1 through Azure OpenAI. The RFT training pipeline is ready for when Azure AI Foundry's reinforcement training becomes generally available. The goal: a specialized model that understands vehicle safety better than a general-purpose LLM. 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. --- *ClarissaBot is open source at [github.com/cameronrye/clarissabot](https://github.com/cameronrye/clarissabot). Try the live demo at [bot.clarissa.run](https://bot.clarissa.run) to check recalls on your vehicle.* --- ## Building Clarissa: Learning How AI Agents Actually Work > 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. Date: 2025-12-07 Tags: ai, agents, typescript, mcp URL: https://rye.dev/blog/building-clarissa-ai-terminal-assistant/ Building Clarissa started as a learning exercise. I wanted to understand how AI agents actually work. I'd used Claude, ChatGPT, and various coding assistants plenty, but I couldn'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. 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. ## Why Build a Terminal AI Agent? 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: - How do AI agents decide when to use tools versus just respond? - How do you manage context windows that can hold millions of tokens? - What makes tool execution safe and reliable? - How does the Model Context Protocol actually work? The best way to learn was to build. ## The ReAct Pattern: Reasoning + Acting The core of Clarissa is the ReAct (Reasoning + Acting) pattern. This isn't some complex neural architecture; it's a surprisingly simple loop: ```typescript async run(userMessage: string): Promise { this.messages.push({ role: "user", content: userMessage }); for (let i = 0; i < 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: "tool", tool_call_id: toolCall.id, content: result.content }); } continue; // Loop back for next response } // No tool calls = final answer return response.content; } } ``` The LLM doesn't "decide" 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. ![A diagram of the ReAct (Reasoning + Acting) loop: the LLM decides to use a tool, gets results, and loops back for the next step.](/images/blog/generated/building-clarissa-ai-terminal-assistant-a-diagrammatic-visualization-o-1765150787749.jpg) This loop is the entire agent. Everything else is infrastructure around it. ## What I Learned About Tool Design 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. ### Tool Confirmation Potentially dangerous operations need confirmation. But what's "dangerous"? I settled on this heuristic: - **No confirmation**: Reading files, listing directories, viewing git status - **Confirmation required**: Writing files, executing shell commands, making commits ```typescript 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 = 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 { const tool = this.tools.get(name); const parsedArgs = JSON.parse(args); const validatedArgs = tool.parameters.parse(parsedArgs); return await tool.execute(validatedArgs); } } ``` This pattern made MCP integration trivial. When connecting to an MCP server, I just convert its tools to my format and register them: ```typescript const tools = mcpTools.map((mcpTool) => ({ name: `mcp_${serverName}_${mcpTool.name}`, description: mcpTool.description, parameters: jsonSchemaToZod(mcpTool.inputSchema), execute: async (input) => client.callTool({ name: mcpTool.name, arguments: input }), requiresConfirmation: true // MCP tools are external })); toolRegistry.registerMany(tools); ``` ## Context Management: The Underrated Challenge Context windows are measured in tokens, but managing them well requires more than counting. Here's what I learned: ### Token Estimation You can't send requests to the API just to count tokens. You need local estimation: ```typescript 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 } ``` ![An illustration of token management and smart truncation: older messages fade out while atomic groups of data stay intact.](/images/blog/generated/building-clarissa-ai-terminal-assistant-a-conceptual-illustration-of-t-1765150803838.jpg) ### Smart Truncation When approaching the limit, you can't just drop the oldest messages. Tool calls and their results must stay together, or the LLM gets confused: ```typescript truncateToFit(messages: Message[]): Message[] { // Group messages into atomic units // User message -> Assistant response -> 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) => sum + this.estimateMessageTokens(msg), 0); if (totalTokens + groupTokens <= availableTokens) { toAdd.unshift(...group); totalTokens += groupTokens; } } } ``` 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. ## Building with Ink: React for the Terminal Choosing Ink (React for CLIs) was initially just curiosity, but it proved invaluable. Terminal UIs have the same state management challenges as web UIs: ```tsx function App() { const [messages, setMessages] = useState([]); const [isThinking, setIsThinking] = useState(false); const [streamContent, setStreamContent] = useState(''); const handleSubmit = async (input: string) => { setIsThinking(true); await agent.run(input, { onStreamChunk: (chunk) => setStreamContent(prev => prev + chunk), onToolCall: (name) => setMessages(prev => [...prev, { type: 'tool', name }]) }); setIsThinking(false); }; return ( {messages.map(msg => )} {isThinking && } {streamContent && } ); } ``` The streaming response visualization was particularly satisfying. Tokens appear as they arrive, giving users immediate feedback that something is happening. ## The Memory System: Persistent Context Sessions persist conversation history, but users also wanted to tell the agent facts it should always remember: ```typescript class MemoryManager { async add(content: string): Promise { const memory = { id: this.generateId(), content: content.trim(), createdAt: new Date().toISOString(), }; this.memories.push(memory); await this.save(); return memory; } async getForPrompt(): Promise { if (this.memories.length === 0) return null; const lines = this.memories.map((m) => `- ${m.content}`); return `## Remembered Context\n${lines.join("\n")}`; } } ``` 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. ## MCP Integration: Extending Without Modifying The Model Context Protocol was the final piece. Rather than building every possible tool, Clarissa can connect to external MCP servers: ```bash /mcp npx -y @modelcontextprotocol/server-filesystem /path/to/directory ``` 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): ```typescript function jsonSchemaToZod(schema: unknown): z.ZodType { const s = schema as Record; if (s.type === "object" && s.properties) { const shape: Record = {}; for (const [key, propSchema] of Object.entries(s.properties)) { shape[key] = jsonSchemaToZod(propSchema); } return z.object(shape); } if (s.type === "string") return z.string(); if (s.type === "number") return z.number(); if (s.type === "boolean") return z.boolean(); if (s.type === "array") return z.array(jsonSchemaToZod(s.items)); return z.unknown(); } ``` ## Key Learnings Building Clarissa taught me several things that weren't obvious from using AI tools: **Agents are loops, not magic.** The ReAct pattern itself is almost trivial. The complexity lives in the infrastructure around it: streaming, context management, tool safety. **Tool design is UX design.** The tools you provide shape what the agent can do. Too few and it's limited. Too many and it gets confused. The sweet spot requires iteration. **Context windows are precious.** Even with million-token windows, you can exhaust them quickly. Smart truncation and memory systems extend useful context far beyond raw limits. **Streaming matters.** Users hate staring at a blank screen. Showing tokens as they arrive transforms the experience from "is this broken?" to "I can see it thinking." **Confirmation builds trust.** Approving dangerous operations prevents mistakes, but it also changes how people interact with the agent. They're more willing to ask for ambitious tasks. ## Try It Yourself Clarissa is open source and available on npm: ```bash bun install -g clarissa # or npm install -g clarissa ``` Set your OpenRouter API key and you're ready to go: ```bash export OPENROUTER_API_KEY=your_key_here clarissa ``` The source code is at [github.com/cameronrye/clarissa](https://github.com/cameronrye/clarissa), and the documentation at [clarissa.run](https://clarissa.run) covers everything from basic usage to MCP integration. --- *Clarissa taught me more than most projects I've built. If you're curious how AI agents work, build one. The gap between using these tools and understanding them is smaller than it looks.* --- ## Retro Floppy: Building an Interactive 3.5" Floppy Disk React Component > 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. Date: 2025-11-23 Tags: react, typescript, css, retro-computing URL: https://rye.dev/blog/retro-floppy-react-component/ 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. ## Anatomy of a Floppy Disk Recreating the floppy disk faithfully requires attention to its distinctive features: - **The Metal Slider**: The spring-loaded cover protecting the magnetic media - **The Label Area**: Where users wrote cryptic file descriptions - **The Write-Protect Tab**: That small sliding switch that saved many files - **The Hub Ring**: The metal center that the drive motor engaged Each one is something to animate or make interactive. ![An exploded view diagram showing the different layers of the disk, visually representing the component composition described in the code.](/images/blog/generated/retro-floppy-react-component-an-exploded-view-diagram-showi-1764556347282.jpg) ## Component Architecture The component uses composition to separate visual elements: ```tsx interface FloppyDiskProps { label?: string; color?: string; onClick?: () => void; isInserted?: boolean; } export function FloppyDisk({ label = 'UNTITLED', color = '#1a1a2e', onClick, isInserted = false }: FloppyDiskProps) { return (
); } ``` CSS custom properties handle color theming without changing the component's structure. ## The Metal Slider Animation The sliding metal cover is the disk's most interactive element: ```scss .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 & { transform: translateX(30%); } } ``` The cubic-bezier timing function mimics the spring-loaded action of a real slider. ![A close-up focusing on texture and lighting, illustrating the goal of the CSS gradients and box-shadows discussed in the section.](/images/blog/generated/retro-floppy-react-component-a-close-up-focusing-on-texture-1764556365254.jpg) ## Realistic Material Rendering CSS gradients create the plastic texture: ```scss .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); } ``` The combination of gradients and shadows creates depth that suggests the molded plastic of the original. ## Label Typography The label area deserves special attention. Many users remember handwritten labels in various states of legibility: ```tsx function LabelArea({ text }: { text: string }) { return (
{text}
{[...Array(3)].map((_, i) => (
))}
); } ``` ```scss .label-area { background: #f5f5dc; border: 1px solid #ccc; padding: 8px; } .label-text { font-family: 'Courier New', monospace; font-size: 12px; text-transform: uppercase; } .label-lines { margin-top: 4px; .label-line { height: 1px; background: #ddd; margin: 4px 0; } } ``` The ruled lines evoke office supply aesthetics of the era. ## Insertion Animation Simulating disk insertion adds another layer of interactivity: ```scss @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; } ``` The slight rotation mimics the angle at which disks were typically inserted into drives. ## Sound Effects Integration Audio feedback sells the nostalgia: ```tsx function useFloppySounds() { const clickSound = useRef(new Audio('/sounds/disk-click.mp3')); const insertSound = useRef(new Audio('/sounds/disk-insert.mp3')); return { playClick: () => clickSound.current.play(), playInsert: () => insertSound.current.play() }; } ``` The characteristic clicking and whirring of floppy drives remains deeply embedded in the memory of anyone who used them. ## Accessibility Considerations Interactive components must remain accessible: ```tsx
{ if (e.key === 'Enter' || e.key === ' ') { onClick?.(); } }} > ``` Keyboard navigation and screen reader support ensure the component works for all users. ## Practical Applications The component finds use in various contexts: - **Retro-themed websites**: Adding period-appropriate UI elements - **Save indicators**: Visual feedback for save operations - **Portfolio pieces**: Showcasing creative CSS and React skills - **Educational content**: Illustrating computing history ## Performance Optimization Animations should not impact performance: ```scss .floppy-disk { will-change: transform; transform: translateZ(0); } ``` These hints enable GPU acceleration for smooth animations even on less powerful devices. --- *See the Retro Floppy component in action at [cameronrye.github.io/retro-floppy](https://cameronrye.github.io/retro-floppy/) or explore the source code on [GitHub](https://github.com/cameronrye/retro-floppy).* --- ## DosKit: Running DOS Software in Modern Browsers with WebAssembly > 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. Date: 2025-11-16 Tags: webassembly, javascript, retro-computing URL: https://rye.dev/blog/doskit-webassembly-dos-emulation/ 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. ## The Preservation Imperative 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. 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. ![An abstract diagram illustrating the translation of raw DOS binaries through the WebAssembly engine into smooth browser execution.](/images/blog/generated/doskit-webassembly-dos-emulation-an-abstract-diagram-illustrati-1764557610398.jpg) ## WebAssembly: The Enabling Technology WebAssembly makes browser-based DOS emulation practical by providing near-native execution speed: ```javascript async function initializeDosKit(containerElement, programUrl) { const bundle = await Dos(containerElement); const instance = await bundle.run(programUrl); return { instance, sendKey: (key) => instance.sendKeyEvent(key, true), setSpeed: (cycles) => instance.setConfig({ cycles }) }; } ``` The compiled DOSBox core executes at speeds sufficient for even demanding DOS software, including action games and complex demos. ## Cross-Platform Consistency One of DosKit's primary goals is consistent behavior across platforms: ```javascript const platformConfig = { mobile: { touchControls: true, virtualKeyboard: true, audioContext: 'user-gesture-required' }, 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; } ``` Mobile devices receive touch controls and virtual keyboards, while desktop browsers get full keyboard capture and fullscreen support. ## Audio Handling Challenges Browser audio policies require careful handling. Modern browsers block autoplay, requiring user interaction before audio can begin: ```javascript class AudioManager { constructor() { this.context = null; this.initialized = false; } async initialize() { if (this.initialized) return; this.context = new AudioContext(); if (this.context.state === 'suspended') { await this.context.resume(); } this.initialized = true; } } // Initialize on first user interaction document.addEventListener('click', () => { audioManager.initialize(); }, { once: true }); ``` That keeps audio working without fighting the browser's autoplay rules. ## File System Abstraction DOS programs expect a filesystem. DosKit provides virtual filesystem support: ```javascript 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, { '/CONFIG.SYS': 'FILES=40\nBUFFERS=25', '/AUTOEXEC.BAT': '@ECHO OFF\nPATH C:\\;C:\\DOS' }); ``` Programs can come from a URL, IndexedDB, or a user upload, and the DOS side sees a normal filesystem either way. ## Performance Tuning DOS software varies dramatically in resource requirements. DosKit provides configuration options: ```javascript const performanceProfiles = { '8086': { cycles: 300, type: 'real' }, '286': { cycles: 3000, type: 'real' }, '386': { cycles: 8000, type: 'real' }, '486': { cycles: 25000, type: 'real' }, 'max': { cycles: 'max', type: 'auto' } }; function applyPerformanceProfile(instance, profile) { const config = performanceProfiles[profile]; instance.setConfig({ cycles: config.cycles, cycleType: config.type }); } ``` Cycle-accurate emulation ensures software runs at authentic speeds, important for games with timing-dependent mechanics. ![A smartphone screen running a retro game with a visible virtual joystick overlay, highlighting mobile compatibility.](/images/blog/generated/doskit-webassembly-dos-emulation-a-smartphone-screen-running-a--1764557630204.jpg) ## Touch Controls for Mobile Mobile support requires virtual input devices: ```javascript class VirtualJoystick { constructor(container) { this.element = document.createElement('div'); this.element.className = 'virtual-joystick'; container.appendChild(this.element); this.bindTouchEvents(); } bindTouchEvents() { this.element.addEventListener('touchmove', (e) => { 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 < 0.3) this.sendKey('ArrowLeft'); if (x > 0.7) this.sendKey('ArrowRight'); if (y < 0.3) this.sendKey('ArrowUp'); if (y > 0.7) this.sendKey('ArrowDown'); } } ``` These controls make DOS software accessible on devices that never existed during the DOS era. ## State Preservation Save states enable users to pause and resume sessions: ```javascript async function saveState(instance) { const state = await instance.saveState(); const blob = new Blob([state], { type: 'application/octet-stream' }); // Store in IndexedDB for persistence await stateStorage.save('last-session', blob); } async function loadState(instance) { const blob = await stateStorage.load('last-session'); if (blob) { const state = await blob.arrayBuffer(); await instance.loadState(state); } } ``` Close the tab, come back later, and pick up where you left off. ## Conclusion Old software doesn'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. --- *Experience DOS classics at [doskit.net](https://doskit.net) or explore the source at [github.com/cameronrye/doskit](https://github.com/cameronrye/doskit).* --- ## Frostpane: A Modern CSS Library for Frosted Glass Effects > Frostpane is my SCSS library for liquid-glass UI: backdrop-filter blur, custom-property theming, and effects that stay fast across browsers. Date: 2025-11-08 Tags: css, open-source URL: https://rye.dev/blog/frostpane-liquid-glass-css/ 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. ## The Anatomy of Frosted Glass Effects The effect comes from a handful of CSS properties stacked together: ```scss .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); } ``` Each property contributes to the effect: the semi-transparent background provides the base layer, `backdrop-filter` creates the blur on content behind the element, the subtle border adds definition, and the shadow creates depth. ## SCSS Architecture for Flexibility Frostpane combines CSS custom properties with SCSS mixins so the look stays adjustable: ```scss :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); } ``` The custom properties give you runtime theming with sensible defaults. You can override individual values in the browser without recompiling the stylesheet. ![A side-by-side comparison showing how liquid glass effects adapt to light and dark color schemes.](/images/blog/generated/frostpane-liquid-glass-css-a-side-by-side-comparison-show-1764556449823.jpg) ## Light and Dark Mode Variants Liquid glass requires different treatments for light and dark backgrounds: ```scss @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; } } ``` The `prefers-color-scheme` query swaps variants automatically, so the panels match whichever theme the user is running. ## Performance Considerations Backdrop filters can impact rendering performance, particularly on lower-powered devices. Frostpane includes performance-conscious defaults: ```scss @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); } } ``` Browsers without `backdrop-filter` get a solid background, and anyone who has asked for reduced motion drops the blur entirely. ## Animation Integration A little animation sells the effect: ```scss @mixin frost-animated { @include frost-base; transition: backdrop-filter 0.3s ease, background 0.3s ease, transform 0.3s ease; &:hover { --frost-blur: 15px; --frost-opacity: 0.2; transform: translateY(-2px); } } ``` On hover the blur and opacity ramp up and the panel lifts a couple of pixels, all transitioned rather than snapped. ## Highlight Effects Adding highlights creates the impression of light catching the glass surface: ```scss @mixin frost-highlight { @include frost-base; position: relative; &::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px; background: linear-gradient( 90deg, transparent, rgba(255, 255, 255, 0.4), transparent ); } } ``` This subtle gradient along the top edge suggests a light source above the element, adding to the three-dimensional illusion. ## Browser Compatibility `backdrop-filter` has broad support now, but it's still worth handling the browsers that lack it: ```scss .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); } } ``` The feature query means older browsers get a solid panel instead of a transparent, unreadable one. ![A practical application shot showing how the different components (nav, modal, card) look when composed together in a full UI.](/images/blog/generated/frostpane-liquid-glass-css-a-practical-application-shot-s-1764556472515.jpg) ## Component Variations Frostpane includes pre-built component styles for common use cases: ```scss .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; } ``` Treat them as starting points and adjust from there to fit whatever you're building. ## Integration Patterns Dropping it into an existing project is straightforward: ```scss // Import the library @use 'frostpane' as frost; // Apply to custom components .my-sidebar { @include frost.frost-base; @include frost.frost-highlight; width: 280px; padding: 1rem; } ``` The namespaced `@use` keeps Frostpane's mixins from colliding with anything else in your styles. --- *See Frostpane in action at [cameronrye.github.io/frostpane](https://cameronrye.github.io/frostpane/) or explore the source code on [GitHub](https://github.com/cameronrye/frostpane).* --- ## Building ClaytonRye.com for My Father's 77th Birthday > A website honoring my father Clayton Rye's five decades as a documentary filmmaker, Vietnam veteran, and film professor, launched for his 77th birthday. Date: 2025-10-29 Tags: personal, astro URL: https://rye.dev/blog/building-claytonrye-com-for-my-fathers-77th-birthday/ Today, October 29, 2025, my father Clayton Rye turns 77. To celebrate, I'm launching [ClaytonRye.com](https://claytonrye.com/), a website honoring his life as an award-winning documentary filmmaker, Vietnam War veteran, and Professor Emeritus at Ferris State University. It's a birthday gift, but really it's a record of his life'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. ## A Life Worth Documenting My father's story begins with the war. From 1968 to 1970, he served in the U.S. Army'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. 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. ![A moody, atmospheric shot of physical archival items (film, photos, audio gear) representing the content being preserved.](/images/blog/generated/building-claytonrye-com-for-my-fathers-77th-birthday-a-moody-atmospheric-shot-of-ph-1764559996130.jpg) ## The Documentarian's Mission Over his career, Clayton made films that stand as historical documents. He wasn'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. ### Ten Vietnam Vets (1980s) One of his earliest major works, *Ten Vietnam Vets*, 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. ### Jim Crow's Museum (2004) 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. ### Detroit Civil Rights Trilogy (2010) Perhaps his most significant work, the *Detroit Civil Rights Trilogy* brought to light three pivotal stories from Michigan's civil rights history: **Last Survivor of the Ford Hunger March**: Dave Moore'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. **Rosa Parks of the Boblo Boat**: Sara Elizabeth Haskell's 1945 challenge to segregation in Detroit, a full decade before Rosa Parks' 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. **Mr. Interlocutor of Mount Clemens**: Duane Gerlach's story of performing in blackface minstrel shows and his journey from participant to advocate, examining how these racist performances shaped American culture. 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's work, his testimony would have died with him. ## The Educator's Legacy 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. 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. 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. ![An abstract representation of the 'stack': transforming raw film content into structured digital data/code.](/images/blog/generated/building-claytonrye-com-for-my-fathers-77th-birthday-an-abstract-representation-of--1764560012786.jpg) ## Building a Digital Legacy 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's work and learn from it: researchers, educators, students, family members. ### The Technical Challenge Building ClaytonRye.com wasn't a typical portfolio site or marketing page. It came with its own constraints, so it needed to be: - **Archival**: Complete documentation of his filmography - **Respectful**: Design that honored both the filmmaker and his subjects - **Accessible**: Fast, responsive, and usable by everyone - **Discoverable**: Properly structured for search engines and researchers - **Enduring**: Built to last, not dependent on trendy frameworks or services 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. ### Design Philosophy Every design decision followed Clayton's approach to his films: keep it restrained, keep the focus on the stories. **Typography**: Playfair Display for headings, a classic serif that carries some weight and dignity. The hierarchy keeps things clear without getting in the way. **Color Palette**: A gold accent (#c9a961) adds warmth without pulling attention off the content. It holds up in both light and dark modes. **Layout**: Open layouts with generous whitespace. The design stays out of the way of the content. **Performance**: Images are responsive and optimized, videos load lazily, and pages come in fast. ### Content Organization The site is organized around five main sections: **Films**: Complete filmography with detailed information about each work, awards, distribution, and historical context. Featured presentation of the *Detroit Civil Rights Trilogy* with embedded trailers and supplementary materials. **About**: A full biography covering his path from Vietnam veteran to documentarian: education, career timeline, teaching philosophy, and key collaborations. **Service**: Dedicated documentation of his Vietnam War service, including complete service record, historical context, and the connection between his military experience and documentary work. **Writing**: Showcase of his written work, including his book *Peckerwood* and screenplay development. **Videos**: A video archive with trailers, full documentaries where available, and supplementary content. ### Technical Implementation The site uses modern web tools while staying simple: - **Astro**: Static site generation with component islands for interactivity - **Custom Backend**: Content management and media handling - **Theme Switching**: Light/dark/system mode with localStorage persistence - **Video Integration**: Lightweight `lite-youtube` component for performance - **Structured Data**: Schema.org markup for discoverability - **Responsive Images**: Optimized images with modern formats (WebP, AVIF) - **Accessibility**: WCAG AA compliant with semantic HTML and keyboard navigation ## The Stories That Matter What strikes me most about my father'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. Dave Moore's testimony about the Ford Hunger March. Sara Elizabeth Haskell's fight against segregation a decade before Rosa Parks. The painful history of blackface minstrel shows. Vietnam veterans' firsthand accounts. The Jim Crow Museum's mission to teach tolerance through confronting intolerance. These aren't easy stories, and they aren't comfortable. But they're essential, and without documentarians like Clayton Rye, they'd be lost. ## Preserving What Matters Most video today is made for views: algorithmic feeds, engagement metrics, the whole machine. My father's work is a reminder of what documentary can be instead, a tool for education, empathy, and historical preservation. His films don't chase likes. They record testimony and try to do right by the people in them. Building ClaytonRye.com made me think hard about what actually matters here. It isn't flashy animations or trendy design patterns. It's clear presentation, accessibility, and something that will still be around in twenty years. ## The Gift of Time My father is 77 today. The last survivor of the Ford Hunger March was in his 90s when Clayton interviewed him. Sara Elizabeth Haskell's story might have been lost if not documented. The Vietnam veterans in *Ten Vietnam Vets* are aging, their numbers dwindling. 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. This website is my contribution to that fight. By making his work accessible and properly documented, I'm helping make sure his five decades of storytelling keep teaching people long after any of us are gone. ## Happy Birthday, Dad > [!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's life. Going through his filmography, watching his documentaries, and writing all of this down left me with a lot of respect and gratitude. 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. This website is my attempt to honor that legacy and ensure your work continues to inspire future generations. **Visit ClaytonRye.com: [claytonrye.com](https://claytonrye.com/)** --- ## Technical Notes For anyone curious about how it's built, here are a few of the patterns I used: ### Static Site Generation with Astro 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. ### Performance Optimization A few things keep the site fast and accessible: - **Image Optimization**: Responsive images with modern formats - **Lazy Loading**: Videos and below-the-fold images load on-demand - **Critical CSS**: Inline critical styles for instant rendering - **Font Optimization**: Efficient web font loading with system font fallbacks - **Minimal JavaScript**: Only essential interactivity included ### Accessibility First WCAG AA compliance ensures the site is accessible to everyone: - **Semantic HTML**: Proper heading hierarchy and landmark regions - **Keyboard Navigation**: Full keyboard accessibility throughout - **Screen Reader Support**: ARIA labels and descriptive text - **Color Contrast**: Compliant contrast ratios in both light and dark modes - **Focus Management**: Clear focus indicators and logical tab order ### Structured Data Schema.org markup handles discoverability: - **Person Schema**: Detailed biographical information - **FAQPage Schema**: Common questions about Clayton's work - **BreadcrumbList Schema**: Clear navigation hierarchy - **Optimized Metadata**: Proper titles, descriptions, and social sharing The complete source code and technical details are documented in the [ClaytonRye.com project page](/projects/claytonrye-com/). --- *Have stories about Clayton's films or teaching? I'd love to hear them. His work touched many lives, and preserving those connections is part of honoring his legacy.* --- ## The Web Audio API: A Cautionary Tale of Ambitious Design and Practical Limitations > Why the Web Audio API's ambitious design collided with what developers actually needed, and what its history teaches about web standards. Date: 2025-10-20 Tags: web-audio-api, web-standards, javascript, webassembly, api-design URL: https://rye.dev/blog/web-audio-api-design-philosophy-and-reality/ 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. The API'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. ## What Is the Web Audio API? The Web Audio API is a high-level JavaScript API for processing and synthesizing audio in web applications. Unlike the simple `