<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Nic Lydon</title>
    <description>The latest articles on Forem by Nic Lydon (@niclydon).</description>
    <link>https://forem.com/niclydon</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3908081%2F03e413b1-9c57-4561-9b6e-3ee8b60bc188.png</url>
      <title>Forem: Nic Lydon</title>
      <link>https://forem.com/niclydon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/niclydon"/>
    <language>en</language>
    <item>
      <title>GitHub Got Breached Through a VS Code Extension. MCP Servers Are Next.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Wed, 20 May 2026 14:01:08 +0000</pubDate>
      <link>https://forem.com/niclydon/github-got-breached-through-a-vs-code-extension-mcp-servers-are-next-5dgc</link>
      <guid>https://forem.com/niclydon/github-got-breached-through-a-vs-code-extension-mcp-servers-are-next-5dgc</guid>
      <description>&lt;p&gt;Yesterday, GitHub said it had detected and contained a compromise of an employee device involving a poisoned VS Code extension. The company said its current assessment is that the activity involved exfiltration of GitHub-internal repositories only, and that the attacker's claim of roughly 3,800 repositories is directionally consistent with its investigation so far. GitHub removed the malicious extension, isolated the endpoint, and prioritized rotation of critical credentials.&lt;/p&gt;

&lt;p&gt;A few days earlier, I had been doing something similar from the other direction. I yanked OpenAI's Codex Chronicle off my laptop and replaced it with a local Gemma 4 instance running on a Mac mini I own. Originally, that was a cost decision. The breach made the security implications of the architecture impossible to ignore.&lt;/p&gt;

&lt;p&gt;A trusted third-party binary. Installed locally. Full read access to your screen, your files, your tokens. An outbound network path the user set up themselves, allowed by every firewall because the user did it.&lt;/p&gt;

&lt;p&gt;Compromise the binary at any point in its supply chain, and you do not need to compromise the platform. The platform is doing what it was told.&lt;/p&gt;

&lt;p&gt;You walked in.&lt;/p&gt;

&lt;p&gt;That is the GitHub breach. That is also Codex Chronicle if a tool like it were ever compromised at the build pipeline or distribution layer. The architectures are siblings.&lt;/p&gt;

&lt;p&gt;A local model is not automatically safe. A compromised local agent with filesystem and shell access is still a privileged execution environment. The difference is that local architectures reduce the mandatory external trust boundary and make inspection possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning signs were already everywhere
&lt;/h2&gt;

&lt;p&gt;TeamPCP is not a one-off. The group is tracked in public reporting as UNC6780, and its 2026 target list before this week included Trivy, Checkmarx, LiteLLM, the Bitwarden CLI, PyTorch Lightning, and most recently the TanStack and durabletask npm and PyPI compromises connected to the Grafana breach a week earlier.&lt;/p&gt;

&lt;p&gt;Look at that list.&lt;/p&gt;

&lt;p&gt;Trivy. Checkmarx. LiteLLM. Bitwarden CLI. PyTorch Lightning.&lt;/p&gt;

&lt;p&gt;Every one of those is developer or developer-adjacent tooling. Trusted. Locally installed. Frequently updated. Each one is a trojan vector with a credential blast radius that extends from the developer's laptop through cloud accounts and into production.&lt;/p&gt;

&lt;p&gt;The npm side of the same campaign is worse. According to public analysis from SlowMist and other researchers, attackers compromised the npm account &lt;code&gt;atool&lt;/code&gt; and pushed hundreds of malicious package versions across hundreds of packages within minutes. The Mini Shai-Hulud malware family specifically targets GitHub tokens, AWS keys, Kubernetes secrets, SSH credentials, password manager databases, and local crypto wallet files.&lt;/p&gt;

&lt;p&gt;This is not a string of bad luck. It is a deliberate, sustained campaign against the supply chain that ends at every developer's &lt;code&gt;~/.config&lt;/code&gt;, &lt;code&gt;~/.aws&lt;/code&gt;, and &lt;code&gt;~/.ssh&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The developer endpoint is the perimeter
&lt;/h2&gt;

&lt;p&gt;For roughly a decade, the prevailing security model has been: trust the developer, harden the platform.&lt;/p&gt;

&lt;p&gt;Endpoint security on engineering laptops is usually a thin layer: corporate EDR, maybe a DLP agent, maybe MDM. The real controls live around code review, CI/CD policy, and production access.&lt;/p&gt;

&lt;p&gt;That model is over.&lt;/p&gt;

&lt;p&gt;The developer endpoint is the highest-privilege, least-monitored node in most environments. It has SSH keys to servers. It has cloud CLI tokens with broad blast radius. It has GitHub credentials with push access to repos that ship to production. It has unencrypted source code.&lt;/p&gt;

&lt;p&gt;And increasingly, it is running a fleet of trusted third-party processes the security team has never reviewed.&lt;/p&gt;

&lt;p&gt;VS Code extensions are one example. Most developer environments have dozens. Each one runs with the developer's full user-level privilege. Each one can read any file the developer can read.&lt;/p&gt;

&lt;p&gt;MCP servers and AI coding agents inherit the same trust model almost verbatim: local execution, broad filesystem visibility, ambient credentials, user-approved outbound network access, and a supply chain most organizations do not inspect.&lt;/p&gt;

&lt;p&gt;I run more than thirty MCP servers connected to my own development environment. I built several of them. I trust myself.&lt;/p&gt;

&lt;p&gt;I do not, in the strict sense, trust the supply chain of every dependency every one of them pulls in.&lt;/p&gt;

&lt;p&gt;Almost nobody does.&lt;/p&gt;

&lt;p&gt;The industry adopted AI-assisted developer tooling with the operational rigor of browser extensions, not privileged infrastructure. Convenience won faster than trust modeling caught up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What mechanical enforcement actually looks like
&lt;/h2&gt;

&lt;p&gt;The fix is not a memo telling developers to be careful. Telling a tired engineer at midnight to audit their extension list is not a control. It is a wish.&lt;/p&gt;

&lt;p&gt;The fix is mechanical enforcement that runs whether the developer is paying attention or not.&lt;/p&gt;

&lt;p&gt;In my own development setup, that looks like four layers. None of them are clever. All of them are boring.&lt;/p&gt;

&lt;p&gt;Boring is the point.&lt;/p&gt;

&lt;p&gt;Layer one: pre-commit hooks. Every commit, in every repo, runs a Python scanner before the commit is allowed to complete. The scanner has specific patterns for OpenAI, Anthropic, Google, GitHub, Slack, Discord webhooks, AWS access keys, and a dozen other token shapes, plus raw &lt;code&gt;.env&lt;/code&gt; and certificate/private-key file detection. It excludes example and placeholder shapes to keep the false positive rate low. If a real secret is staged, the commit blocks. The hook does not care if the developer noticed.&lt;/p&gt;

&lt;p&gt;Layer two: agent hooks. Claude Code and Codex both expose hooks that can fire before file writes and command execution. I run the same scanner against proposed edits. The agent cannot persist a secret to disk through the normal write path, because the hook denies the operation before the write happens.&lt;/p&gt;

&lt;p&gt;This catches genuine mistakes, like an agent paraphrasing a &lt;code&gt;.env&lt;/code&gt; it read into the next file it writes. It also catches credential reconnaissance. A Bash command that greps for &lt;code&gt;password&lt;/code&gt; across the repo or cats an &lt;code&gt;.env&lt;/code&gt; is blocked at the tool-call boundary, not at the application layer.&lt;/p&gt;

&lt;p&gt;I have actual logs of this firing. During a real OAuth flow earlier this spring, an agent tried to retrieve a shared password by probing the local vault directory, running &lt;code&gt;systemctl cat&lt;/code&gt;, and grepping across config files.&lt;/p&gt;

&lt;p&gt;Five Bash calls. Five denials.&lt;/p&gt;

&lt;p&gt;Each one cited the security policy by name. The correct path was for the user to explicitly authorize retrieval through the canonical vault command, which is exactly what eventually happened.&lt;/p&gt;

&lt;p&gt;The hook did its job.&lt;/p&gt;

&lt;p&gt;Layer three: &lt;code&gt;.claudeignore&lt;/code&gt;. Every repo on my development machine has one. It is the agent equivalent of &lt;code&gt;.gitignore&lt;/code&gt;. It prevents the AI tool from loading sensitive paths into context in the first place. The list is uncontroversial: &lt;code&gt;.env*&lt;/code&gt; files, certificate and key shapes, raw database files, build output, editor metadata. If the agent never sees the secret, it cannot accidentally leak it into a draft, summary, or commit.&lt;/p&gt;

&lt;p&gt;Layer four: a single secrets vault. All real credentials live in AWS Secrets Manager, in a dedicated account, accessed through a single CLI. Application code, agent tools, and CI all pull from the vault at runtime. Source code commits placeholders only. Rotating a credential means updating it in one place. If a secret is ever leaked, it can be rotated globally in seconds, not by tracking down every config file that ever held a copy.&lt;/p&gt;

&lt;p&gt;The line in my own security policy doc reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hook failures are security findings, not lint style.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the mechanical layer catches an actual secret, it is rotated. The hook is not asking for permission to be turned off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not do
&lt;/h2&gt;

&lt;p&gt;This stack would not have stopped the GitHub compromise.&lt;/p&gt;

&lt;p&gt;A poisoned VS Code extension running with the developer's full privilege has its own pathway. It does not need to commit anything. It does not need to call the agent's write path. It can read tokens directly from disk, hit any network endpoint, and exfiltrate immediately.&lt;/p&gt;

&lt;p&gt;None of my hooks would see it, because it does not flow through any of my hook points.&lt;/p&gt;

&lt;p&gt;That is the honest part. The mechanical layer is defense in depth, not a wall.&lt;/p&gt;

&lt;p&gt;What the stack does do is harden the common failure modes: the developer who pastes a real key into a commit, the agent that helpfully echoes a secret back into a file, the credential probe an attacker uses as next-step reconnaissance after initial compromise.&lt;/p&gt;

&lt;p&gt;It removes easy mistakes. It logs attempts. It makes privileged paths visible.&lt;/p&gt;

&lt;p&gt;The harder problem is the one the GitHub breach is screaming about: the supply chain that delivers third-party code to a developer's local environment has almost no security model.&lt;/p&gt;

&lt;p&gt;There is no meaningful review for VS Code extensions beyond "removed after it was reported." There is no meaningful review for MCP servers beyond "trust the maintainer." There is no enforced signing requirement, no provenance attestation requirement, and no runtime sandbox that most teams can rely on.&lt;/p&gt;

&lt;p&gt;If you are a security leader, the actionable question is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What did the attackers do to GitHub?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actionable question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"What is the inventory of third-party processes running with developer privilege in my environment, and what would happen if any single one of them was compromised this afternoon?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For most organizations, the answer is not encouraging.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is coming
&lt;/h2&gt;

&lt;p&gt;I expect three things in the next twelve months.&lt;/p&gt;

&lt;p&gt;First, the major platforms will harden. GitHub and Microsoft will tighten VS Code extension publishing controls. Anthropic and OpenAI will add provenance signatures to their tool ecosystems. npm and PyPI will likely see at least one more wave before the registry-level changes that need to happen actually happen.&lt;/p&gt;

&lt;p&gt;Second, MCP servers will get their first major incident. The trust model is wrong. The attack surface is large. The defender side has barely started. Someone will write a malicious MCP server that behaves like a malicious VS Code extension, and it will run for weeks before anyone notices.&lt;/p&gt;

&lt;p&gt;Third, the conversation about endpoint security on engineering laptops will move from "EDR plus a memo" to "your dev environment is a production system." The organizations that get to that mental model first will spend the next eighteen months less expensively than the ones that wait for their own TeamPCP moment.&lt;/p&gt;

&lt;p&gt;The Codex Chronicle install I pulled last weekend is a small example of the right reflex. The thing I removed was not malicious. It was a legitimate research preview from a major lab.&lt;/p&gt;

&lt;p&gt;I removed it because it had the wrong architecture for what I wanted: a local capability that periodically used recent screen context through a cloud service, a binary I could not audit, and a network path I did not need open.&lt;/p&gt;

&lt;p&gt;The replacement runs on hardware I own, in a way I can inspect, without a mandatory outbound dependency.&lt;/p&gt;

&lt;p&gt;That architecture choice is what more developer tooling should default to in 2026. Not local because of privacy theater. Local because the trust profile is smaller and the failure modes are more visible.&lt;/p&gt;

&lt;p&gt;The cloud round trip should be reserved for cases where the cloud is genuinely necessary, not cases where the vendor wants recurring usage.&lt;/p&gt;

&lt;p&gt;The GitHub breach made the security case for that reflex more obvious than any threat model I could have drawn on a whiteboard.&lt;/p&gt;

&lt;p&gt;The interesting question is not whether security teams agree with this in principle. Most will, when asked.&lt;/p&gt;

&lt;p&gt;The interesting question is whether they have enforcement that runs whether anyone agrees with it or not.&lt;/p&gt;

&lt;p&gt;If your developer endpoints are running on policy memos, the next year is going to be expensive.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>ai</category>
      <category>github</category>
    </item>
    <item>
      <title>Capture the Reasoning Path, Not the Final State</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 19 May 2026 23:46:00 +0000</pubDate>
      <link>https://forem.com/niclydon/capture-the-reasoning-path-not-the-final-state-c9d</link>
      <guid>https://forem.com/niclydon/capture-the-reasoning-path-not-the-final-state-c9d</guid>
      <description>&lt;p&gt;Two files, one discipline, and a measured 10-13% of my Claude Code budget.&lt;/p&gt;

&lt;p&gt;A while back, mid-session with Claude Code, I typed a pushback in the kind of broken English you only produce past midnight:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"are we using full netflix level doc uodsyed as ws go here ?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I meant: are we updating documentation at full Netflix-documentary depth as we go, or are we doing the lazy version that just records what changed without why? Claude correctly inferred the Netflix version. From that point forward the documentation standard for every one of my projects was set.&lt;/p&gt;

&lt;p&gt;That session became the basis for what I now call paper-trail: a portable ruleset that makes Claude Code (and any other AI coding agent that respects CLAUDE.md) write documentation at documentary depth instead of git-log depth.&lt;/p&gt;

&lt;p&gt;This post is about why that matters more when an AI is doing most of the typing, and what the discipline actually looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reasoning path is what AI loses
&lt;/h2&gt;

&lt;p&gt;Most documentation captures the final state. The README says what the system does. The CHANGELOG says what version shipped. The commit message says what file changed.&lt;/p&gt;

&lt;p&gt;What disappears at session end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The three alternatives you considered before picking option C&lt;/li&gt;
&lt;li&gt;The operator pushback that killed your original design&lt;/li&gt;
&lt;li&gt;The verification log that convinced you the fix worked&lt;/li&gt;
&lt;li&gt;The false start at 11pm that explains the weird workaround at line 240&lt;/li&gt;
&lt;li&gt;The dependency you didn't realize existed until something broke&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you're writing code yourself, this knowledge lives in your head, badly, for about a week. After that it's gone.&lt;/p&gt;

&lt;p&gt;When an AI agent is doing most of the typing, the gap gets worse. The agent has zero memory of the rejected alternatives. Six months later it confidently suggests a fix you already turned down. There is no record of why you turned it down.&lt;/p&gt;

&lt;p&gt;The reasoning path is what makes future debugging possible. AI makes it more valuable and more fragile at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two files, one discipline
&lt;/h2&gt;

&lt;p&gt;The structure is simple.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CHANGES.md&lt;/code&gt; at the repo root. Chronological log, newest entry on top. Updated as work happens, not after. Each entry covers what changed, why, what was decided, what was rejected, how it was verified, and what's still outstanding.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docs/narrative/&amp;lt;YYYY-MM-DD&amp;gt;-&amp;lt;topic&amp;gt;.md&lt;/code&gt; for the bigger arcs. Migrations, incidents, rewrites, source onboarding. Starting state, trigger, decisions, rejected alternatives, phases, verification, final state, what's unblocked.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CHANGES.md&lt;/code&gt; is the index. &lt;code&gt;docs/narrative&lt;/code&gt; is the story.&lt;/p&gt;

&lt;p&gt;Both are plain markdown. Both get committed. Both are designed to be grep-able by your tools and your future agent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftf6oyhwfqrihm9539yoe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftf6oyhwfqrihm9539yoe.png" alt="paper-trail" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A real CHANGES.md entry
&lt;/h2&gt;

&lt;p&gt;Here's the entry from a Music sync resurrection a few weeks ago (anonymized identifiers, real structure):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;2026-05-16: Music sync row restored in iOS Settings view

After the backend consolidation on 2026-05-04, the iOS Settings view lost the row that exposed Music sync to users. The sync pipeline itself was intact in the backend; only the toggle had been removed during the cleanup.

Restored via 6 lines in App/Views/SettingsView.swift, adding the row back under "Data Sources." TestFlight build 47 ships the restored row. Verified end-to-end by pulling a fresh sync from the device and confirming the delivery UUID 8b4f2a9c-7d15-4e83-9bcd-12fa8e5c61d4 landed in the backend.

Decided: restore the row as-is rather than redesign the Settings view (the consolidation rationale doesn't apply to this row).
Rejected: moving Music to a dedicated "Media" section. Too much surface area to redesign for one source.
Outstanding: wire the new Qwen commit a3f2c8e91 for next week's audio path.

commit e74b2c1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One paragraph plus a six-line code block plus four metadata lines. Names the dormant pipeline, the build that shipped, the cross-repo dependency, the rejected alternative.&lt;/p&gt;

&lt;p&gt;That's the index entry. The narrative doc tells the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same event as a narrative doc
&lt;/h2&gt;

&lt;p&gt;Title: "The Settings Row That Brought Music Back" at &lt;code&gt;docs/narrative/2026-05-16-music-resurrection.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sections:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Trigger:&lt;/strong&gt; what made us notice Music sync was dark (a test query returned zero rows from a source that should have been daily)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Diff:&lt;/strong&gt; what the original consolidation actually removed, with the line numbers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What Almost Happened:&lt;/strong&gt; the redesign-the-whole-view path I considered before realizing six lines of Swift was the answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verification:&lt;/strong&gt; the delivery UUID that proved the path was wired back end-to-end&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's Unblocked:&lt;/strong&gt; the audio path work that depended on Music being live&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It reads like a documentary episode. Tradeoffs, false starts, operator decisions, verification numbers. Anyone (including a future me, including a future agent) can reconstruct the reasoning path from the doc alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day-one install
&lt;/h2&gt;

&lt;p&gt;The whole thing is at &lt;code&gt;github.com/niclydon/paper-trail&lt;/code&gt;. MIT-licensed, drop-in.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Copy &lt;code&gt;DOCUMENTARY_STYLE_DOCUMENTATION.md&lt;/code&gt; into your project's root (e.g. &lt;code&gt;~/projects/&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;In your top-level &lt;code&gt;CLAUDE.md&lt;/code&gt;, add &lt;code&gt;@DOCUMENTARY_STYLE_DOCUMENTATION.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In each project's &lt;code&gt;CLAUDE.md&lt;/code&gt;, paste the per-project boilerplate from &lt;code&gt;templates/per-project-boilerplate.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Create an empty &lt;code&gt;CHANGES.md&lt;/code&gt; at each project root.&lt;/li&gt;
&lt;li&gt;For the first non-trivial migration or incident, create a narrative doc using the skeleton.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude will start appending &lt;code&gt;CHANGES.md&lt;/code&gt; entries on its next session in that tree.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;-LITE&lt;/code&gt; variant of the ruleset (~75% smaller, same discipline) for sub-agents or context-tight sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;The honest answer requires two measurements.&lt;/p&gt;

&lt;p&gt;The first: when I check &lt;code&gt;/status&lt;/code&gt; in Claude Code, my &lt;code&gt;/narrative-docs-update&lt;/code&gt; slash command shows up at about 9% of my weekly Claude Pro Max plan usage. That's the cleanly attributable cost. Every time I deliberately invoke the skill to write or update a narrative doc, it adds to that bucket.&lt;/p&gt;

&lt;p&gt;The second is harder to measure. &lt;code&gt;CHANGES.md&lt;/code&gt; appends happen inline during regular sessions, not as a separate skill invocation. They blend into general usage and don't show up as a line item in &lt;code&gt;/status&lt;/code&gt;. The only way to measure them is to look at the content itself.&lt;/p&gt;

&lt;p&gt;So I ran the math. Across 158,000 Claude Code messages from 1,737 sessions over the last 30 days, I summed the character count of all assistant output that referenced &lt;code&gt;CHANGES.md&lt;/code&gt;, &lt;code&gt;docs/narrative/&lt;/code&gt;, or &lt;code&gt;docs/migrations/&lt;/code&gt; paths. The result: 1.16 million characters out of 9.2 million total. 12.6% of Claude Code's written output over 30 days went to documentation work.&lt;/p&gt;

&lt;p&gt;The two measurements converge. 9% is the floor, cleanly counted from a dedicated skill. 12.6% is the broader signal that catches inline doc work too. Call it 10-13% of Claude Code output.&lt;/p&gt;

&lt;p&gt;The part that surprised me: the discipline isn't applied uniformly. Only 11.5% of my sessions involved documentation work at all. The other 88.5% never touched a &lt;code&gt;CHANGES.md&lt;/code&gt; or narrative doc. They're quick queries, exploration, one-offs.&lt;/p&gt;

&lt;p&gt;Where documentation work shows up is in the substantial sessions. The ones where I actually built or migrated or debugged something worth recording. Doc-meaningful sessions average about 50,000 characters of assistant output. No-docs sessions average about 850. Documentation effort scales with work effort, which is what you want.&lt;/p&gt;

&lt;p&gt;Three things make 10-13% the easiest spend in my Claude Code plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The output is durable.&lt;/strong&gt; The other ~88% of Claude's output is ephemeral chat that disappears when the session ends. That ~12% is markdown files that persist, get committed, and become referenceable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The agent doesn't remember what it built.&lt;/strong&gt; Without these docs, the next session has no idea what was rejected, why, or with what verification. Reconstructing reasoning later costs more than recording it now.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A recent debug story made the case concrete.&lt;/strong&gt; A few weeks ago an iOS pipeline went dark after a backend consolidation. The &lt;code&gt;CHANGES.md&lt;/code&gt; entry from the original consolidation told me exactly which row had been removed from the Settings view and why. Without that record I'd have spent an hour trace-debugging. With it: six lines of Swift to restore.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cost of the discipline is small. The cost of skipping it shows up when you need the record and it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Two files, one discipline, ten-to-thirteen percent of my Claude Code budget. In exchange: a searchable record of every non-trivial decision, the rejection rationale for the alternatives, and verification numbers that survive every session reset.&lt;/p&gt;

&lt;p&gt;AI doesn't remove the need for documentation. It makes the reasoning path both more valuable (because the agent does more of the typing) and more fragile (because the agent forgets everything when the session ends).&lt;/p&gt;

&lt;p&gt;If you're going to let an AI write most of your code, give it (and yourself) a paper trail.&lt;/p&gt;




&lt;p&gt;Drop-in repo: &lt;a href="https://github.com/niclydon/paper-trail" rel="noopener noreferrer"&gt;github.com/niclydon/paper-trail&lt;/a&gt;. MIT-licensed.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>documentation</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Codex Chronicle was paying for every frame.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Mon, 18 May 2026 20:54:32 +0000</pubDate>
      <link>https://forem.com/niclydon/codex-chronicle-was-paying-for-every-frame-i-built-a-four-sensor-gemma-4-replacement-on-a-mac-mini-55e7</link>
      <guid>https://forem.com/niclydon/codex-chronicle-was-paying-for-every-frame-i-built-a-four-sensor-gemma-4-replacement-on-a-mac-mini-55e7</guid>
      <description>&lt;p&gt;I built a four-sensor Gemma 4 replacement on a Mac mini.&lt;/p&gt;

&lt;p&gt;For about a week I had OpenAI’s research-preview Chronicle running on my MacBook. Every ten minutes it screenshotted my display, uploaded frames to OpenAI for analysis, and wrote Markdown summaries on my Mac. I was crawling that folder and ingesting the data in a Postgres table on my homelab.&lt;/p&gt;

&lt;p&gt;It worked.&lt;/p&gt;

&lt;p&gt;It also cost credits for every cycle of attention.&lt;/p&gt;

&lt;p&gt;This weekend I replaced it with a single Gemma 4 E4B 4-bit MLX instance running on a $599 Mac mini, summarizing four independent sensor streams locally with zero outbound LLM calls and effectively zero marginal inference cost.&lt;/p&gt;

&lt;p&gt;OpenAI describes the constraints plainly in their own documentation: screen captures are uploaded to OpenAI’s servers for processing, the feature “uses rate limits quickly,” it “increases risk of prompt injection,” memories are stored as “unencrypted Markdown files” on the user’s machine, and it is unavailable in the EU, UK, and Switzerland. Chronicle is a Pro-tier feature on a Pro-tier price. The architectural choice is honest: cloud inference, per-frame cost, the model belongs to OpenAI.&lt;/p&gt;

&lt;p&gt;I wanted a different shape.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;This weekend I replaced Chronicle. Not with a better cloud service. With a single Gemma 4 E4B 4-bit MLX instance on a $599 Mac mini, summarizing video from four sensors (my screen, a wearable camera, the security cameras in my living room, and the wearable’s realtime AI commentary) and writing them all to one Postgres table, redacted at ingest, queryable in SQL. Zero outbound LLM calls. Zero per-frame cost.&lt;/p&gt;

&lt;p&gt;The same model instance also serves the rest of my homelab’s vision workloads.&lt;/p&gt;

&lt;p&gt;The marginal cost of adding the fifth sensor (which is already in a box on the way) is whatever shipping cost was paid for a Raspberry Pi Zero 2 W.&lt;/p&gt;

&lt;p&gt;This is the sequel to a piece I published five days ago about putting Gemma 4 behind my homelab AI gateway. That one ended with: “Anvil is not just a dev box. For some multimodal work, it is a useful inference target.” This is about Anvil graduating.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Gemma 4 E4B specifically
&lt;/h2&gt;

&lt;p&gt;The reasoning, in order of how much each one mattered to me:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Native multimodal in one checkpoint.&lt;/strong&gt; Image AND video AND audio paths in the same file. The whole sensor mesh runs through one weights load. No model swap per input type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;16 GB of unified memory is enough.&lt;/strong&gt; The 4-bit MLX build sits at about 6 GB peak resident in isolation, around 8.5 GB under co-tenant load. On a base M-series Mac mini that leaves comfortable headroom for the OS, the FastAPI daemon, and a menubar app to watch it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apache 2.0 weights.&lt;/strong&gt; The model file is on my machine. Nobody can deprecate it out from under me, reprice it overnight, or restrict it by jurisdiction.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;It’s already loaded.&lt;/strong&gt; I was routing this exact model through Forge for unrelated work. Spinning a second model for Logbook specifically would have been waste. One Gemma 4 instance. Two production roles.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Four sensors, one envelope
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  [MacBook Screen]   [Looki Wearable]   [Blink Cameras]
         │                  │                  │
         └──────────────────┼──────────────────┘
                            ▼
                   [Logbook Producers]
                            │
                            ▼
                  [Anvil / Gemma 4 E4B]
                            │
                            ▼
                    [Redaction Layer]
                            │
                            ▼
                       [Postgres]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every Logbook row is an &lt;code&gt;observation.event.v1&lt;/code&gt; envelope. The schema fits in one paragraph: a deterministic UUIDv5, a source enum, a captured_at timestamp, a clip duration_s, optional frame_count, an image_summary, an optional video_summary, a media_uri for the staging location, an inference_metadata blob, and a source_metadata blob. Same schema, four producers.&lt;/p&gt;

&lt;p&gt;The producers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MacBook screen.&lt;/strong&gt; A Python capture daemon running as a LaunchAgent. Records a short screen video on a fixed cadence, pauses when HID idle exceeds 10 minutes, POSTs the clip to Anvil for analysis, then POSTs the resulting envelope to the homelab ingest endpoint.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Looki wearable (clips).&lt;/strong&gt; A worker polls the wearable’s cloud, stages new motion clips to local NVMe, runs them through the same Anvil daemon.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmclbfrfymfo7tv3m2hr0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmclbfrfymfo7tv3m2hr0.png" alt="Looki Ingest" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Looki wearable (realtime).&lt;/strong&gt; The wearable emits realtime AI commentary as text events. A second worker forwards those as image-summary-only observations into the same table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Blink security cameras.&lt;/strong&gt; A continuous Node.js daemon polls Blink’s cloud, stages motion clips to NVMe, hands them to Anvil.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every clip lands on the same Anvil daemon, which runs one Gemma 4 E4B 4-bit MLX instance. The daemon serves two surfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;/v1/analyze&lt;/code&gt; for Logbook (image-pass + native-video-pass per clip).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;/v1/chat/completions&lt;/code&gt; and &lt;code&gt;/v1/responses&lt;/code&gt; for every other Forge VLM client in the homelab.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model does not care which surface called it. The previous standalone gemma-4-multimodal LaunchAgent was retired and its plist removed. End state: one Gemma 4 instance, dual-purpose, no duplication.&lt;/p&gt;

&lt;p&gt;Redaction happens once, at the ingest endpoint, before the INSERT. UUIDs, filesystem paths, IPv4 and IPv6, internal hostnames, email addresses, API key shapes. Single pass.&lt;/p&gt;




&lt;h2&gt;
  
  
  The day the model pretended to watch video
&lt;/h2&gt;

&lt;p&gt;For most of the build day, Logbook produced two summaries per clip: one from a native-video call &lt;code&gt;mlx_vlm.generate(video=path, fps=1.0)&lt;/code&gt;, and one from a separate frame-extracted multi-image pass.&lt;/p&gt;

&lt;p&gt;The image summaries were excellent. They read pixels at 1280 px width and reported real strings: Termius, Phase 9, LOGBOOK_BUILD_BRIEF.md. Per-capture variation. Forensic detail. Anyone reading the raw table rows could tell which IDE window was on top.&lt;/p&gt;

&lt;p&gt;The video summaries were a different story. Every video summary for every mac_screen capture, hour after hour, described “a person standing in a kitchen setting, facing a counter, holding a small dark object.” Word for word. The MacBook does not have a webcam pointed at the kitchen. The capture content was screen recordings.&lt;/p&gt;

&lt;p&gt;I revised the prompt to be explicit (“you are observing a screen recording from a computer display”). Every video summary then described an identical Stack Overflow visit. Still word-for-word across captures.&lt;/p&gt;

&lt;p&gt;The model was not hallucinating. Hallucinating implies seeing something and misinterpreting it. The model was outputting the same paragraph because the same paragraph was the most likely next-token sequence given only the prompt. The video bytes were not reaching the attention layer at all.&lt;/p&gt;

&lt;p&gt;An MD5-hash query broke the case open. Across seven consecutive mac_screen captures of five different windows, every video summary collapsed to two unique hashes (one per prompt variant), perfectly correlated with the prompt text. The image summaries from the same seven captures produced seven unique hashes. Image was reading pixels. Video was reading nothing.&lt;/p&gt;

&lt;p&gt;Running the same script against two different Blink motion clips from the living room made it worse. Identical output on E4B. Identical output on E2B. E2B’s variant of the bug was more honest than E4B’s: where E4B confabulated plausible scenes, E2B simply replied “Please provide the video or a description of what you are seeing so I can describe it for you.” &lt;strong&gt;The model was literally asking for the video.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Root cause was four lines deep in &lt;code&gt;anvil/server.py&lt;/code&gt;. The daemon was building the formatted prompt with &lt;code&gt;apply_chat_template(processor, config, prompt, num_images=N)&lt;/code&gt; and then calling &lt;code&gt;generate(video=path, ...)&lt;/code&gt;. &lt;br&gt;
The dispatcher in mlx_vlm’s &lt;code&gt;prompt_utils.py&lt;/code&gt; checks &lt;code&gt;kwargs.get("video")&lt;/code&gt; on the chat template call to decide whether to insert the &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; placeholder. &lt;/p&gt;

&lt;p&gt;We were not passing it.&lt;/p&gt;

&lt;p&gt;The formatted prompt had no video marker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;generate()&lt;/code&gt;’s &lt;code&gt;video=path&lt;/code&gt; argument was effectively ignored at the attention layer: the video tokens had no anchor in the prompt to attend to.&lt;/p&gt;

&lt;p&gt;The fix is one branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;video_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;video&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;video_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;num_images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;formatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;num_images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;num_images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix, the same seven captures produced seven unique video summaries.&lt;/p&gt;

&lt;p&gt;The model was watching.&lt;/p&gt;

&lt;p&gt;The bug was masked by polite-looking output. The summaries were grammatical, plausible, well-formed paragraphs. They just had nothing to do with the input.&lt;/p&gt;




&lt;h2&gt;
  
  
  Numbers, and the redaction pass
&lt;/h2&gt;

&lt;p&gt;Isolated benchmarks on a single warmed clip, no other traffic on the daemon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Image pass: 4.08 s latency, 17.6 tok/s, 5.89 GB peak resident.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Video pass: 6.67 s latency, 14.1 tok/s, 6.03 GB peak resident.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Production averages across 467 ingested rows from a single day’s running, with the daemon also serving the rest of Forge’s VLM clients:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;source&lt;/th&gt;
&lt;th&gt;avg image latency&lt;/th&gt;
&lt;th&gt;image tok/s&lt;/th&gt;
&lt;th&gt;avg video latency&lt;/th&gt;
&lt;th&gt;video tok/s&lt;/th&gt;
&lt;th&gt;peak resident&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;mac_screen&lt;/td&gt;
&lt;td&gt;11.20 s&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;20.62 s&lt;/td&gt;
&lt;td&gt;33.9&lt;/td&gt;
&lt;td&gt;8.52 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;looki (clips)&lt;/td&gt;
&lt;td&gt;8.57 s&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;11.98 s&lt;/td&gt;
&lt;td&gt;33.9&lt;/td&gt;
&lt;td&gt;8.50 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blink&lt;/td&gt;
&lt;td&gt;24.85 s&lt;/td&gt;
&lt;td&gt;33.7&lt;/td&gt;
&lt;td&gt;27.31 s&lt;/td&gt;
&lt;td&gt;34.7&lt;/td&gt;
&lt;td&gt;8.52 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things shift between the bench and production.&lt;/p&gt;

&lt;p&gt;Throughput nearly doubles under load (33.7 tok/s vs. 17.6) because the model handles concurrent VLM work efficiently.&lt;/p&gt;

&lt;p&gt;Latency stretches by a factor of 2-6 depending on source because the same instance is now serving Logbook’s four producers alongside every other Forge VLM client.&lt;/p&gt;

&lt;p&gt;Peak resident memory climbs to 8.52 GB, still comfortably inside a 16 GB Mac mini.&lt;/p&gt;

&lt;p&gt;The latency stretch is the consolidation. One model, two surfaces, shared queue. Anvil idles at single-digit watts when the daemon is not actively inferring. Throughput is comfortable for the production cadence of all four sensors. No batching tricks required.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F95uerw6t9k7junhty7wq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F95uerw6t9k7junhty7wq.png" alt="Convergence" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The redaction pass is in production. A real row from this morning’s bronze layer, image summary verbatim:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Email visible: [REDACTED]. IP shown: [REDACTED]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model saw both. The Postgres row holds neither.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;The model is local.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The data is local.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The redaction is at the ingest boundary.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The audit trail is a SELECT statement against a table on hardware I own.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What this actually changes
&lt;/h2&gt;

&lt;p&gt;The headline is not “I replaced OpenAI with Gemma.”&lt;/p&gt;

&lt;p&gt;The headline is that inference is no longer the bottleneck.&lt;/p&gt;

&lt;p&gt;When Chronicle does a screen capture, the inference is a network round trip to an API the user does not own, billed per request, rate-limited by the provider, and explicitly described in the provider’s own documentation as carrying “increased risk of prompt injection,” “memories stored as unencrypted Markdown files,” and consumption that “uses rate limits quickly.” The architecture treats each sensor as a customer of a paid service.&lt;/p&gt;

&lt;p&gt;When Logbook does a screen capture, the inference is a function call on hardware I own.&lt;/p&gt;

&lt;p&gt;The bottleneck is bytes-on-wire and bytes-on-disk, both of which are problems we already know how to solve.&lt;/p&gt;

&lt;p&gt;The model is a fixed cost.&lt;/p&gt;

&lt;p&gt;Every new sensor pays for itself in the wall clock of the moment it is added, not in the per-frame economics of the API.&lt;/p&gt;

&lt;p&gt;What ends up running on the Mac mini is closer to a personal telemetry fabric than to an AI assistant: distributed multi-modal sensors, normalized events, local inference, append-only memory.&lt;/p&gt;

&lt;p&gt;Chronicle did one thing competently and charged per frame.&lt;/p&gt;

&lt;p&gt;Logbook does the same thing four times over, from 360°, runs locally, and charges per electron.&lt;/p&gt;




&lt;h2&gt;
  
  
  What’s next
&lt;/h2&gt;

&lt;p&gt;A Raspberry Pi Zero 2 W Basic was delivered to the house on May 16. A 250 g spool of 1.75 mm PLA filament arrived the day before.&lt;/p&gt;

&lt;p&gt;The shape of those two purchases together is a fifth sensor: a tiny always-on Linux SBC in a 3D-printed enclosure, somewhere on the spectrum of ambient sensor, audio recorder, or environmental probe.&lt;/p&gt;

&lt;p&gt;The exact function is the sensor’s business.&lt;/p&gt;

&lt;p&gt;The Logbook architecture does not care.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe8i6dzznzy6uy6whf6fu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe8i6dzznzy6uy6whf6fu.png" alt="Builder Desk" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fifth sensor will arrive at the same ingest endpoint, in the same envelope shape, summarized by the same Gemma 4 instance that is already running.&lt;/p&gt;

&lt;p&gt;Whatever it captures will slot into &lt;code&gt;raw_ingest_observations&lt;/code&gt; at its own &lt;code&gt;captured_at&lt;/code&gt; and interleave with the other four sources in time order.&lt;/p&gt;

&lt;p&gt;When it lands, the work will be writing one small handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model is already there.
&lt;/h2&gt;

</description>
      <category>gemmachallenge</category>
      <category>devchallenge</category>
      <category>ai</category>
      <category>gemma</category>
    </item>
    <item>
      <title>I Wrote an MCP Server for My 3D Printer</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sun, 17 May 2026 20:30:55 +0000</pubDate>
      <link>https://forem.com/niclydon/i-wrote-an-mcp-server-for-my-3d-printer-4om3</link>
      <guid>https://forem.com/niclydon/i-wrote-an-mcp-server-for-my-3d-printer-4om3</guid>
      <description>&lt;p&gt;I’m writing this on a Sunday afternoon. The 3D printer on my kitchen counter has been printing for 19 hours and 12 minutes. I know this because I just asked it.&lt;/p&gt;

&lt;p&gt;Not by walking into the kitchen. By calling a tool in a Claude conversation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;kiln_progress&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"printing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"looki_l1_tests.gcode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;257&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"target_layer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;315&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"progress"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.9112598299980164&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"print_duration_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;69192&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The printer is a Flashforge Adventurer 5M. It’s named “kiln” because everything in my home lab gets a fire-themed name and I’ve already used the good ones (Furnace runs the GPUs, Forge is the inference gateway). It sits next to the cutting board. I bought it on a whim a few weeks ago and I have no idea what I’m doing with 3D printing as a hobby.&lt;/p&gt;

&lt;p&gt;But I do know how to wrap an API in MCP tools, and the printer has two of them. So now I have 16 MCP tools for a machine I barely understand.&lt;/p&gt;

&lt;p&gt;This post is the receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two APIs
&lt;/h2&gt;

&lt;p&gt;The AD5M ships with firmware that exposes two ways in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An HTTP API on port 8898 that returns JSON for things like /detail (status, fans, temps, current job). This is what the FlashForge mobile app talks to.&lt;/li&gt;
&lt;li&gt;A legacy TCP port on 8899 that speaks G-code over a length-prefixed wire format. You send M115 and you get the firmware version back. Send M114 and you get the current head position.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The HTTP API is comfortable. The TCP port is from a more savage era. Both are running on the same printer.&lt;/p&gt;

&lt;p&gt;kiln-mcp is a small TypeScript MCP server that wraps both. Read-only G-codes go through the TCP port. State-changing operations like kiln_print and kiln_control (pause/resume/cancel) go through the HTTP API. All calls carry a check code so the printer trusts them.&lt;/p&gt;

&lt;p&gt;I also do not trust any LLM with a hot nozzle and an open command channel.&lt;/p&gt;

&lt;p&gt;That shaped the design more than anything else. Read-only telemetry is permissive. Stateful operations are constrained. The kiln_mcode tool only accepts read-only M-codes because the first version of this server had that gate softer, and I tightened it after the second time I caught a tool call trying to send M104 (set extruder temp) inside what I thought was a status query.&lt;/p&gt;

&lt;p&gt;Here’s what they look like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The read-only side
&lt;/h2&gt;

&lt;p&gt;kiln_info just dumps firmware and build volume. Under the hood it calls M115:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Machine Type: Flashforge Adventurer 5M
Machine Name: kiln
Firmware: v3.2.7
SN: [redacted]
X: 220 Y: 220 Z: 220
Tool Count: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kiln_temps is more useful. As of the moment I’m writing this paragraph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"nozzle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"temp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;219.67&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"temp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;59.53&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chamber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"temp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;219.67 / 220 is the nozzle holding steady on PLA. 59.53 / 60 is the bed. The chamber slot exists for a heated enclosure I don’t have.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkn0t471083ibh0ka85um.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkn0t471083ibh0ka85um.png" alt="Claude Mobile and 3D Printer" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;kiln_files lists what’s on the printer’s storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;looki_l1_tests.gcode
looki05.gcode
looki04.gcode
looki03.gcode
looki02.gcode
looki01.gcode
nameplate_batch_3_13-14_PLA_020mm.gcode
nameplate_batch_2_7-12_PLA_020mm.gcode
nameplate_batch_1x6_PLA_020mm.gcode
plate_1.gcode
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I’m iterating on a mount for a wearable AI camera I use, hence looki_l1_tests.gcode being on its fifth revision. The nameplate batches were for a friend. The file names are a journal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state-changing side
&lt;/h2&gt;

&lt;p&gt;kiln_print takes one of those file names and starts the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;kiln_print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;file_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;looki05.gcode&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kiln_control does pause / resume / stop. The stop is destructive in the obvious way: cancel an 18-hour print at hour 17, you have an 18-hour-old failed extrusion blob on the bed.&lt;/p&gt;

&lt;p&gt;I don’t let Claude call kiln_control casually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weird one
&lt;/h2&gt;

&lt;p&gt;The tool I actually built this server for is kiln_image2mesh.&lt;/p&gt;

&lt;p&gt;I wanted the shortest possible path from “that would make a neat print” to an STL on the printer.&lt;/p&gt;

&lt;p&gt;You hand it an image. It hands you back an STL ready to slice. It runs entirely on the iGPU of one of my mini PCs.&lt;/p&gt;

&lt;p&gt;Under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A FastAPI service called Modly that auto-spawns on first use (it isn’t running right now, which is fine).&lt;/li&gt;
&lt;li&gt;rembg strips the background from the image.&lt;/li&gt;
&lt;li&gt;TripoSG, an image-to-3D diffusion model from VAST AI, generates the mesh.&lt;/li&gt;
&lt;li&gt;A marching-cubes octree turns the implicit field into triangles.&lt;/li&gt;
&lt;li&gt;Mesh simplification brings the face count down to a printable target (default 80,000 faces).&lt;/li&gt;
&lt;li&gt;The result is written as an STL next to the input image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxnrmb64fsroyp23skyzj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxnrmb64fsroyp23skyzj.png" alt="Kiln MCP" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Total time: 5 to 15 minutes depending on diffusion steps. CFG, seed, foreground ratio, face count, and steps are all parameters. Defaults are tuned for “preview-grade,” which is what I want 95% of the time.&lt;/p&gt;

&lt;p&gt;The point: there is no cloud STL service in this pipeline. The image goes onto disk on Furnace, Modly runs locally on the iGPU, the STL lands on the same disk, and then kiln_print ships it. The only thing leaving my network is the message I typed at Claude asking it to do all of that. (And sometimes I don't run that through Claude.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest bits
&lt;/h2&gt;

&lt;p&gt;While drafting this, kiln_status timed out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timeout after 8000ms (path=/detail)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The printer’s HTTP server gets cranky when it’s deep into a long job. The legacy TCP port answered fine the whole time. Both APIs, one machine, very different attitudes about life. The MCP server papers over this poorly. That’s a TODO.&lt;/p&gt;

&lt;p&gt;kiln_modly_status reported api_up: false. The auto-spawn handles that on the next kiln_image2mesh call, but if I were writing this server today I’d add a –prewarm flag.&lt;/p&gt;

&lt;p&gt;The ugly old TCP side of the printer has actually been more reliable than the modern JSON API. Which feels about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;I write MCP servers for things at a much higher rate than is reasonable. Most of them are useful in the obvious “I can ask Claude about my pipeline state” way. A few are useful in the less obvious way of “I now have a sharp picture of what the underlying system actually exposes.”&lt;/p&gt;

&lt;p&gt;Wrapping the printer was the second category.&lt;/p&gt;

&lt;p&gt;The AD5M’s two APIs disagree about a lot of things: units, retry behavior, what “ready” means. Wrapping them forced me to pick a model. The MCP surface is the cleanest description of that printer I have.&lt;/p&gt;

&lt;p&gt;That’s the part of MCP work I think people miss. Once you expose a system through tools, you stop writing wrappers and start defining semantics. You have to decide what counts as state, what counts as safe, what operations deserve retries, and what an LLM should never be allowed to do.&lt;/p&gt;

&lt;p&gt;That, and: I can now ask Claude to print me a thing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foy0focif5eprvs0it5jn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Foy0focif5eprvs0it5jn.png" alt="Relaxed 3D Printing" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The amount of glue between an LLM and a hot extruder is not zero, but it’s smaller than you’d think.&lt;/p&gt;

&lt;p&gt;When I started writing this, the printer was at layer 257 of 315. I could check again.&lt;/p&gt;

&lt;p&gt;I’m not going to.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>Vibe Coding Is to Software Development as Desire Paths Are to City Planning</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sat, 16 May 2026 18:42:19 +0000</pubDate>
      <link>https://forem.com/niclydon/vibe-coding-is-to-software-development-as-desire-paths-are-to-city-planning-56d</link>
      <guid>https://forem.com/niclydon/vibe-coding-is-to-software-development-as-desire-paths-are-to-city-planning-56d</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm not a software developer. I'm the building inspector watching people pave their own paths through the enterprise. Here's what I'm seeing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In urban planning, there's a concept called a &lt;strong&gt;desire path&lt;/strong&gt;: the informal trail pedestrians wear into the grass when the sidewalk doesn't go where they actually need to go. It's not vandalism. It's feedback. The planned infrastructure failed to serve the people using it, and they routed around it.&lt;/p&gt;

&lt;p&gt;Vibe coding is the desire path of software development.&lt;/p&gt;

&lt;p&gt;But I'm not writing this to tell developers their profession is dying. I don't have standing for that. I'm a Director of Information Security. I manage security engineering and IAM teams. I've spent 15 years in cybersecurity and exactly zero of them shipping production applications.&lt;/p&gt;

&lt;p&gt;What I &lt;em&gt;do&lt;/em&gt; have is a front-row seat to what happens when the desire paths start forming inside an enterprise. And right now, they're everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Desire Paths Are Already There
&lt;/h2&gt;

&lt;p&gt;Here's what I'm seeing in my environment and hearing from peers:&lt;/p&gt;

&lt;p&gt;A financial analyst discovers they can use an AI coding assistant to build a Python script that automates a report they've been manually compiling every Monday for three years. It works. It runs on their laptop. Nobody in IT knows it exists.&lt;/p&gt;

&lt;p&gt;A compliance officer uses Claude to generate a small web app that tracks regulatory deadlines. It pulls from a shared spreadsheet. It sends Slack notifications. It took them an afternoon. The official request to IT for this tool has been in the backlog for 14 months.&lt;/p&gt;

&lt;p&gt;A project manager builds an internal dashboard by describing what they want to an LLM. It's not beautiful. It doesn't follow the design system. But it works, their team uses it, and it solved a problem that nobody else was going to solve for them.&lt;/p&gt;

&lt;p&gt;These are desire paths.&lt;/p&gt;

&lt;p&gt;And here's the uncomfortable truth: &lt;strong&gt;these people aren't wrong.&lt;/strong&gt; They needed something. The planned infrastructure — the IT backlog, the dev team's sprint priorities, the "submit a Jira ticket and wait" process — didn't serve them. So they walked across the grass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Leader's Problem
&lt;/h2&gt;

&lt;p&gt;As a security person, my instinct is obvious: this is terrifying. Ungoverned code running on laptops. API keys hardcoded in scripts. Data flowing to third-party AI services with no DLP, no audit trail, no access controls. Shadow IT, but now it's shadow &lt;em&gt;development&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The fence-building response is also obvious: block the AI tools, lock down the endpoints, send a policy memo. The digital equivalent of &lt;strong&gt;KEEP OFF THE GRASS&lt;/strong&gt; signs.&lt;/p&gt;

&lt;p&gt;But I've been in security long enough to know that prohibition doesn't work when the underlying need is legitimate. You don't stop desire paths by putting up fences. You just make people walk through the mud next to the fence.&lt;/p&gt;

&lt;p&gt;The question isn't "how do I stop this?"&lt;/p&gt;

&lt;p&gt;The question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do I pave these paths properly?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What a Paved Desire Path Looks Like
&lt;/h2&gt;

&lt;p&gt;If citizen developers are going to build things — and they are, whether you like it or not — security and engineering teams need to build the infrastructure that makes it safe.&lt;/p&gt;

&lt;p&gt;Not safe as in "we reviewed every line of code."&lt;/p&gt;

&lt;p&gt;Safe as in "the paths have drainage, lighting, and load-bearing foundations."&lt;/p&gt;

&lt;p&gt;Here's the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The AI Gateway: Your Sidewalk
&lt;/h2&gt;

&lt;p&gt;Instead of letting every citizen developer hit OpenAI, Anthropic, or Google directly with their own API keys, you put a gateway in front of everything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Citizen Developer → AI Gateway → [Local Models | Cloud Providers]
                        ↓
                   Audit Log
                   Policy Engine
                   Cost Controls
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my home lab, this is a service called Forge. Every AI request from every tool, agent, and script routes through it. In a 30-day window, that's 300K+ requests across 30+ models. Every single one is logged. Every cloud fallback is auditable at a dedicated endpoint.&lt;/p&gt;

&lt;p&gt;The numbers tell the story: $0.79 in actual cloud spend over 30 days, because the gateway routes to local models first and only falls back to cloud providers when necessary.&lt;/p&gt;

&lt;p&gt;But the cost savings aren't the point.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;auditability&lt;/strong&gt; is the point.&lt;/p&gt;

&lt;p&gt;When a regulator asks, "What data are your employees sending to AI services?", you need an answer.&lt;/p&gt;

&lt;p&gt;An enterprise version of this is an MCP proxy layer. MCP gives you a standardized interface between AI tools and the services they interact with. Put a proxy in front of it, and you control what every citizen-built tool can actually &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Guardrails: Your Drainage and Curbs
&lt;/h2&gt;

&lt;p&gt;A paved desire path still needs drainage so it doesn't flood. In the citizen developer context, guardrails are the constraints that prevent well-intentioned people from accidentally causing incidents.&lt;/p&gt;

&lt;p&gt;Concrete examples:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data classification enforcement.&lt;/strong&gt; The gateway inspects outbound requests. If someone's Python script is trying to send customer PII to a cloud model, the request gets blocked before it leaves the network. The citizen developer doesn't need to know about data classification policies. The infrastructure handles it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential management.&lt;/strong&gt; No citizen developer should ever have a raw API key. The gateway handles authentication. The developer gets a single internal endpoint. If a key needs to be rotated, it happens once at the gateway, not in 47 scripts on 47 laptops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope limitation.&lt;/strong&gt; An MCP proxy can restrict which tools a citizen-built application can invoke. Your compliance officer's deadline tracker can read from the shared spreadsheet and send Slack notifications. It cannot access the HR system, modify financial records, or provision cloud resources. The path goes where it needs to go and nowhere else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Example: MCP proxy policy for a citizen developer tool&lt;/span&gt;
&lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compliance-deadline-tracker&lt;/span&gt;
  &lt;span class="na"&gt;allowed_tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;google_sheets:read&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;slack:post_message&lt;/span&gt;
  &lt;span class="na"&gt;blocked_tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*:write"&lt;/span&gt;          &lt;span class="c1"&gt;# No writes to any data source&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*:delete"&lt;/span&gt;         &lt;span class="c1"&gt;# No deletions&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hr_system:*"&lt;/span&gt;      &lt;span class="c1"&gt;# No HR system access at all&lt;/span&gt;
  &lt;span class="na"&gt;data_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;block_pii_outbound&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;max_tokens_per_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4096&lt;/span&gt;
  &lt;span class="na"&gt;audit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;log_all_requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;alert_on_block&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The CI/CD Pipeline: Your Building Code
&lt;/h2&gt;

&lt;p&gt;This is where the city planning analogy lands hardest. A desire path that gets paved still has to meet building codes.&lt;/p&gt;

&lt;p&gt;For citizen developers, this means:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A defined deployment path.&lt;/strong&gt; The tool doesn't run on someone's laptop forever. There's a simple process: push it to a repo, it goes through automated scanning — SAST, dependency checks, secrets detection — and it deploys to a managed environment. The citizen developer doesn't need to understand CI/CD. They need a button that says "make this official."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated security scanning.&lt;/strong&gt; Every citizen-built tool gets the same baseline checks that production code gets. Not a full security review — that doesn't scale — but automated detection of the things that cause most incidents: hardcoded secrets, known-vulnerable dependencies, SQL injection patterns, unvalidated inputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment isolation.&lt;/strong&gt; Citizen developer tools run in sandboxed environments with limited network access, no production database credentials, and resource caps. If the tool breaks, it breaks in its sandbox. It doesn't take down the ERP system.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F531ffgk9agjom9q5w2l0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F531ffgk9agjom9q5w2l0.png" alt="Late Night Office" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Maintenance and Ownership: Your Public Works Department
&lt;/h2&gt;

&lt;p&gt;Here's the part every enterprise learns the hard way: paving the path is only the beginning.&lt;/p&gt;

&lt;p&gt;The compliance officer who built the regulatory tracker changes roles. The financial analyst who automated the Monday report leaves the company. Six months later, nobody knows who owns the tool, what depends on it, or whether it's still making correct decisions.&lt;/p&gt;

&lt;p&gt;This is where desire paths become technical debt corridors.&lt;/p&gt;

&lt;p&gt;A governed citizen development platform needs more than deployment pipelines and security scanning. It needs lifecycle management. Every deployed tool should have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a recorded owner,&lt;/li&gt;
&lt;li&gt;a business purpose,&lt;/li&gt;
&lt;li&gt;dependency metadata,&lt;/li&gt;
&lt;li&gt;access scope documentation,&lt;/li&gt;
&lt;li&gt;and an expiration or review date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not because bureaucracy is fun, but because abandoned automation is one of the most dangerous forms of enterprise risk. A broken dashboard is visible. A silently incorrect dashboard can influence business decisions for months before anyone notices.&lt;/p&gt;

&lt;p&gt;That means periodic re-certification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the tool still need the access it was granted?&lt;/li&gt;
&lt;li&gt;Is anyone still using it?&lt;/li&gt;
&lt;li&gt;Are the underlying models or APIs behaving differently now?&lt;/li&gt;
&lt;li&gt;Has the source data changed format?&lt;/li&gt;
&lt;li&gt;Does the automation still align with current policy and process?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In city planning terms, this is the public works department. Roads crack. Drainage fails. Traffic patterns change. Some paths need widening because they became critical infrastructure. Others should be closed because the need disappeared.&lt;/p&gt;

&lt;p&gt;The same thing happens with citizen-built software. Some tools will prove valuable enough to formalize into fully supported applications. Others should expire automatically unless someone actively renews ownership and validates their continued use.&lt;/p&gt;

&lt;p&gt;If you don't build maintenance into the system from the beginning, today's paved path becomes tomorrow's forgotten infrastructure problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Skill Libraries: Your Signage and Lighting
&lt;/h2&gt;

&lt;p&gt;Smart cities don't just pave desire paths. They add lighting, signage, and benches. They make the path &lt;em&gt;better&lt;/em&gt; than the grass was.&lt;/p&gt;

&lt;p&gt;For citizen developers, this means pre-built, vetted capabilities they can use instead of building from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pre-approved integrations:&lt;/strong&gt; vetted connectors to internal systems, such as read-only Salesforce access, Slack posting, or Jira ticket creation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template repositories:&lt;/strong&gt; starter projects with security best practices already baked in: environment variable management, logging, error handling, input validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curated model access:&lt;/strong&gt; purpose-specific model configurations for summarization, data extraction, code generation, and other common patterns.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Role That Emerges
&lt;/h2&gt;

&lt;p&gt;Here's the part software developers should actually pay attention to.&lt;/p&gt;

&lt;p&gt;The city planners didn't disappear when cities started paving desire paths. The profession matured. The job shifted from "design where people should walk" to "design systems that accommodate where people &lt;em&gt;do&lt;/em&gt; walk."&lt;/p&gt;

&lt;p&gt;That's what's happening in software development.&lt;/p&gt;

&lt;p&gt;The highest-leverage work isn't writing the compliance deadline tracker. It's building the platform that lets the compliance officer build it safely.&lt;/p&gt;

&lt;p&gt;It's the gateway, the proxy layer, the policy engine, the scanning pipeline, the sandboxed runtime, the skill libraries, and the lifecycle controls.&lt;/p&gt;

&lt;p&gt;The enduring engineering advantage shifts upward into platforms, governance, orchestration, and operational architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Actually Doing About It
&lt;/h2&gt;

&lt;p&gt;I'm not writing this from theory. I'm the security leader who has to make a decision: fence or sidewalk?&lt;/p&gt;

&lt;p&gt;Here's my approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Acknowledge the desire paths exist.&lt;/strong&gt; The shadow AI tools are already in your environment. Pretending otherwise is negligence, not strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument before you govern.&lt;/strong&gt; Before writing policies, understand what's actually happening. Where are the API calls going? What data is flowing? What tools are people building?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the governed path.&lt;/strong&gt; Stand up the gateway, the proxy layer, the scanning pipeline. Make the official path easier than the unofficial one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the right thing the easy thing.&lt;/strong&gt; Every security control that adds friction to the citizen developer's workflow is a control they'll route around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit continuously, review periodically.&lt;/strong&gt; Automated scanning catches the baseline. Periodic human review catches the architectural issues. Neither alone is sufficient.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Uncomfortable Conclusion
&lt;/h2&gt;

&lt;p&gt;The software development industry spent decades building beautiful, winding paths through meticulously planned courtyards: frameworks, design patterns, architectural review boards, sprint ceremonies, code review processes.&lt;/p&gt;

&lt;p&gt;Then someone handed everyone an AI assistant and they cut straight across the grass.&lt;/p&gt;

&lt;p&gt;That's not a failure of the people walking.&lt;/p&gt;

&lt;p&gt;That's feedback about the path.&lt;/p&gt;

&lt;p&gt;The question for security leaders isn't whether to allow it. It's already happening. The question is whether you're the one who paves the path with proper drainage, or the one standing next to a &lt;strong&gt;KEEP OFF THE GRASS&lt;/strong&gt; sign watching everyone walk through the mud.&lt;/p&gt;

&lt;p&gt;I know which one I'm choosing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Put Gemma 4 Behind My Homelab AI Gateway. This Is the Beginning.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Wed, 13 May 2026 02:22:11 +0000</pubDate>
      <link>https://forem.com/niclydon/i-put-gemma-4-behind-my-homelab-ai-gateway-this-is-the-beginning-487m</link>
      <guid>https://forem.com/niclydon/i-put-gemma-4-behind-my-homelab-ai-gateway-this-is-the-beginning-487m</guid>
      <description>

&lt;p&gt;Most model experiments start with a notebook, a benchmark script, or a quick API call.&lt;/p&gt;

&lt;p&gt;This one started with a production-shaped question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can I swap out an entire model family that is currently serviing the default paths through my actual local AI gateway?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not a side demo. Not a one-off curl. Not "look, it runs."&lt;/p&gt;

&lt;p&gt;I mean the real route: the gateway that agents, background jobs, app surfaces, benchmark harnesses, and my own tools already call.&lt;/p&gt;

&lt;p&gt;That is the experiment I started with Gemma 4.&lt;/p&gt;

&lt;p&gt;This post is the beginning of that story, not the final verdict. I am writing it while the platform is still in the trial window. The follow-up will be more interesting: what stayed stable, what broke under real load, what got rolled back, and what I would keep after a week or two of actual use.&lt;/p&gt;

&lt;p&gt;For now, this is the setup: what I changed, why I changed it, and what failed immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Platform Before The Swap
&lt;/h2&gt;

&lt;p&gt;My local AI stack is built around a gateway I call Forge.&lt;/p&gt;

&lt;p&gt;Forge gives callers one OpenAI-ish API surface and handles the messy parts behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which model should answer this kind of request&lt;/li&gt;
&lt;li&gt;which machine is hosting it&lt;/li&gt;
&lt;li&gt;whether the model is hot, cold, deprecated, or on-demand&lt;/li&gt;
&lt;li&gt;whether a request is chat, vision, embedding, transcription, code, extraction, or something else&lt;/li&gt;
&lt;li&gt;whether a backend is available or should be skipped&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The machines behind it are consumer hardware, not datacenter gear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Furnace&lt;/td&gt;
&lt;td&gt;Primary inference box, AMD Strix Halo, 96 GB unified VRAM allocated to the iGPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crucible&lt;/td&gt;
&lt;td&gt;Secondary AMD box for creative workloads, permissive models, and burst/bulk work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anvil&lt;/td&gt;
&lt;td&gt;M4 Mac mini, useful for MLX/Metal paths and lightweight resident services&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before this experiment, the default local text path was mostly Qwen-family. That was not an accident. Qwen had become the operating baseline because it was predictable enough for a platform, not just impressive in isolation.&lt;/p&gt;

&lt;p&gt;I had also tested other models. Devstral2, for example, was interesting enough to onboard and benchmark seriously. The smaller 24B variant was competitive in code scenarios, but it did not become the default path. The 123B model was too slow for the role I needed. That distinction matters:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model can be good and still not be a good platform default.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is the bar Gemma 4 had to clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Did An In-Place Swap
&lt;/h2&gt;

&lt;p&gt;I could have added Gemma 4 as another optional model and called it a day.&lt;/p&gt;

&lt;p&gt;That would have been safer. It also would have taught me much less.&lt;/p&gt;

&lt;p&gt;Instead, I treated it like a real migration. For the trial window, Gemma 4 took over the canonical roles that real callers already use.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Previous route&lt;/th&gt;
&lt;th&gt;Trial route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;default chat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3.6-chat-35b-a3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-chat-31b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;priority chat&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3-8b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-chat-26b-a4b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vision / multimodal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3-vl-30b-a3b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-multimodal-8b-e4b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prompt enhancement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3-4b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma-4-multimodal-2b-e2b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The old Qwen routes were not deleted. They were marked deprecated with a planned rollback window. That gives me a clean flip-back path if the experiment does not earn its keep.&lt;/p&gt;

&lt;p&gt;This is the part I think model posts often skip. A real model migration is not just "can I run it?" It is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;do I have the right weights on disk?&lt;/li&gt;
&lt;li&gt;does my serving stack understand the architecture?&lt;/li&gt;
&lt;li&gt;can I fit the hot set in memory?&lt;/li&gt;
&lt;li&gt;do my existing aliases and callers still work?&lt;/li&gt;
&lt;li&gt;can I roll back without spelunking through five repos?&lt;/li&gt;
&lt;li&gt;do I have telemetry that will tell me the difference between model failure, gateway failure, and benchmark nonsense?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Failure Was Not The Model
&lt;/h2&gt;

&lt;p&gt;The first deploy crashed.&lt;/p&gt;

&lt;p&gt;Forge restarted cleanly. The model catalog showed the new Gemma 4 ids. The first smoke request hit the gateway, routed to llama-swap, and came back as a 502.&lt;/p&gt;

&lt;p&gt;The useful error was one layer lower:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unknown model architecture: 'gemma4'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem was not Gemma 4 quality. The problem was my serving binary.&lt;/p&gt;

&lt;p&gt;My llama.cpp build was from April 1. It was 466 commits behind the branch I needed. The GGUF files declared &lt;code&gt;general.architecture: gemma4&lt;/code&gt;, and the old build simply did not know what that meant.&lt;/p&gt;

&lt;p&gt;So the first chapter of the Gemma 4 experiment was not prompting. It was infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;back up the existing build tree&lt;/li&gt;
&lt;li&gt;rebuild llama.cpp with ROCm/HIP for Strix Halo&lt;/li&gt;
&lt;li&gt;verify the new binary recognizes the Gemma 4 architecture&lt;/li&gt;
&lt;li&gt;regression-check the existing Qwen route&lt;/li&gt;
&lt;li&gt;restart the serving layer&lt;/li&gt;
&lt;li&gt;smoke test through the actual gateway, not a side process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after that did the model start answering.&lt;/p&gt;

&lt;p&gt;That is a useful reminder: "model support" is not a binary property. A model can be downloadable, quantized, and present on disk, and still be unusable because the serving stack is one architecture handler behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Failure Was More Interesting
&lt;/h2&gt;

&lt;p&gt;Once Gemma 4 loaded, the first real chat benchmark looked bad.&lt;/p&gt;

&lt;p&gt;Not "a little worse than Qwen" bad. Broken bad.&lt;/p&gt;

&lt;p&gt;On the initial chat-bench run, &lt;code&gt;gemma-4-chat-31b&lt;/code&gt; failed the structured extraction and format-compliance scenarios. It was also slow enough that something was clearly wrong. These were not hard prompts. These were the boring, throughput-oriented tasks that agents and background workers need to complete cleanly.&lt;/p&gt;

&lt;p&gt;A direct request showed the issue immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;think&amp;gt;
The user is asking a basic arithmetic question...
&amp;lt;/think&amp;gt;

2 + 2 = 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model was spending the answer budget on a reasoning block.&lt;/p&gt;

&lt;p&gt;For a human chat UI, visible thinking might be useful. For a benchmark expecting JSON, or an agent expecting a short answer, it is poison. The model can know the right answer and still fail the task because the caller never receives the shape it asked for.&lt;/p&gt;

&lt;p&gt;This was familiar. Forge had already solved the same class of problem for Qwen3.&lt;/p&gt;

&lt;p&gt;The fix was to make "thinking mode" a gateway policy, not a model identity.&lt;/p&gt;

&lt;p&gt;Programmatic callers get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chat_template_kwargs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enable_thinking"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;injected by default when the model family needs it.&lt;/p&gt;

&lt;p&gt;Chat UIs can opt back in explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chat_template_kwargs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"enable_thinking"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the right abstraction for my platform. I do not want every agent, benchmark, worker, and internal tool to remember which local model family wraps output in thinking tokens this week. I want the gateway to know that once.&lt;/p&gt;

&lt;p&gt;After that change, the chat benchmarks became meaningful. The three relevant routes - Gemma 4 31B, Gemma 4 26B-A4B, and the displaced Qwen3.6 35B-A3B baseline - reached the same pass-rate shape across the default chat scenarios.&lt;/p&gt;

&lt;p&gt;The interesting result was latency. The 26B-A4B route was materially faster than both the dense 31B and the Qwen3.6 baseline on several workloads, while keeping the same pass rate in the corrected harness.&lt;/p&gt;

&lt;p&gt;That is the kind of result I care about. Not "model X wins," but "model X belongs in this role."&lt;/p&gt;

&lt;h2&gt;
  
  
  Vision Exposed A Different Problem
&lt;/h2&gt;

&lt;p&gt;The multimodal side taught a separate lesson.&lt;/p&gt;

&lt;p&gt;I added a new VLM benchmark harness and ran the obvious first test. The initial scenario was too weak. It was good enough as a smoke test, but not good enough to tell me which model or host was better.&lt;/p&gt;

&lt;p&gt;So I built a more discriminating scenario with three generated fixtures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a bar chart that required OCR and chart reasoning&lt;/li&gt;
&lt;li&gt;a code screenshot that required reading a function name and language&lt;/li&gt;
&lt;li&gt;a homelab topology diagram that required identifying the hub and connected nodes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then another problem appeared: concurrency.&lt;/p&gt;

&lt;p&gt;Promptfoo's default concurrency sent multiple image-bearing requests at once during a backend startup window, which produced misleading 502s. Some errors appeared to implicate the wrong backend because parallel requests were failing around the same time.&lt;/p&gt;

&lt;p&gt;Sequential runs told the truth.&lt;/p&gt;

&lt;p&gt;With concurrency set to 1 and the output budget raised, the final VLM run passed cleanly across the tested local routes. The surprising part was not that Gemma 4 could read the images. The surprising part was that an M4 Mac mini running an MLX path was effectively tied with the AMD inference box on this small, practical vision benchmark.&lt;/p&gt;

&lt;p&gt;That is not a leaderboard result. It is a routing result.&lt;/p&gt;

&lt;p&gt;It tells me Anvil is not just a dev box. For some multimodal work, it is a useful inference target.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio Needed A Sidecar
&lt;/h2&gt;

&lt;p&gt;Gemma 4's multimodal story is not just text and images. Audio is part of the interesting surface.&lt;/p&gt;

&lt;p&gt;But my normal GGUF plus llama.cpp path did not support Gemma 4 audio input yet. The text and vision path worked through llama-swap. The audio conformer path did not.&lt;/p&gt;

&lt;p&gt;So I built it as a sidecar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a separate FastAPI worker&lt;/li&gt;
&lt;li&gt;safetensors weights&lt;/li&gt;
&lt;li&gt;HuggingFace Transformers&lt;/li&gt;
&lt;li&gt;ROCm-specific PyTorch wheels&lt;/li&gt;
&lt;li&gt;a Forge route at &lt;code&gt;/v1/audio_qa&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That route is intentionally not a replacement for Whisper.&lt;/p&gt;

&lt;p&gt;Whisper remains the right tool for long-form transcription. Gemma 4 audio is more interesting for short audio understanding, audio Q&amp;amp;A, emotion or intent questions, and cross-modal prompts that combine audio and an image.&lt;/p&gt;

&lt;p&gt;The first useful test was simple: the JFK sample clip. The route returned a good short transcription in under six seconds once warm. A 60 second clip correctly failed fast with a 413 because the audio path is capped at 30 seconds. An audio plus image prompt produced a coherent response grounded in both modalities.&lt;/p&gt;

&lt;p&gt;That sidecar is not the end state. It is a bridge. When the standard serving path supports the audio input cleanly, the route can stay and the backend can change.&lt;/p&gt;

&lt;p&gt;Again, that is the platform lesson: callers should not care which inference backend made the modality work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Am Actually Testing
&lt;/h2&gt;

&lt;p&gt;The easy version of this post would end with a benchmark table and a confident take.&lt;/p&gt;

&lt;p&gt;I do not have that yet, and pretending otherwise would be silly.&lt;/p&gt;

&lt;p&gt;What I have is the beginning of a platform trial:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemma 4 is now in the main chat, priority-chat, multimodal, and prompt-enhance roles.&lt;/li&gt;
&lt;li&gt;Qwen is still available as a rollback path.&lt;/li&gt;
&lt;li&gt;Devstral2 remains useful but not a default for this platform.&lt;/li&gt;
&lt;li&gt;Forge now handles thinking-mode policy for both Qwen3 and Gemma 4.&lt;/li&gt;
&lt;li&gt;The benchmark harness is better than it was before the experiment.&lt;/li&gt;
&lt;li&gt;The audio path exists, but it is a sidecar until the normal serving stack catches up.&lt;/li&gt;
&lt;li&gt;The real evaluation is now happening under actual workloads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4zgtw0skqr40dpsmdtgz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4zgtw0skqr40dpsmdtgz.png" alt="LLM Benchmark" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The question I care about over the next week is not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is Gemma 4 better than Qwen?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Which parts of the platform are better with Gemma 4 in the route, and which parts should move back?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That means watching boring things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;error rates&lt;/li&gt;
&lt;li&gt;429s and backend saturation&lt;/li&gt;
&lt;li&gt;latency under background jobs&lt;/li&gt;
&lt;li&gt;whether agent outputs stay clean&lt;/li&gt;
&lt;li&gt;whether structured tasks remain reliable&lt;/li&gt;
&lt;li&gt;whether multimodal routes are useful often enough to stay hot&lt;/li&gt;
&lt;li&gt;whether the memory footprint is worth it&lt;/li&gt;
&lt;li&gt;whether fallback behavior is predictable when the boxes are busy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is less glamorous than a benchmark screenshot. It is also where the real answer lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway So Far
&lt;/h2&gt;

&lt;p&gt;The first day did not teach me that Gemma 4 is universally better. It taught me something more useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model family becomes valuable when the platform can route it intentionally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The gateway mattered more than any single model call.&lt;/p&gt;

&lt;p&gt;Without Forge, I would have been debugging each app and agent separately. With Forge, the migration became a small number of role changes, a serving-stack rebuild, one generalized thinking-policy fix, and a better set of benchmarks.&lt;/p&gt;

&lt;p&gt;That is the part I want to keep building toward: a local AI platform where model families can change without rewriting every caller, and where the system learns from real workloads instead of one-off demos.&lt;/p&gt;

&lt;p&gt;This is the start of the Gemma 4 trial in my homelab.&lt;/p&gt;

&lt;p&gt;In a week or two, I should have the more honest post: what survived real use, what got demoted, and what I would do differently if I were starting the swap again.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>gemmachallenge</category>
      <category>gemma</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Audited My AI Agents and Found That Most of Their Reasoning Wasn’t Observable</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Tue, 12 May 2026 01:01:24 +0000</pubDate>
      <link>https://forem.com/niclydon/i-audited-my-ai-agents-and-found-that-most-of-their-reasoning-wasnt-observable-4a5</link>
      <guid>https://forem.com/niclydon/i-audited-my-ai-agents-and-found-that-most-of-their-reasoning-wasnt-observable-4a5</guid>
      <description>&lt;p&gt;I run a personal AI platform with eight active agents, dozens of processors, and a fully self-hosted Langfuse instance. I built the observability layer myself. I shipped it a few weeks ago. Last week I ran the audit query for the first time.&lt;/p&gt;

&lt;p&gt;The agents that talk to me the most only had Langfuse-level lineage coverage for about 13% of their decisions.&lt;/p&gt;

&lt;p&gt;This is the writeup of what I found, why it happened, and the schema and code that explain it. If you run agents and you've never run this audit, you have a very good chance of finding the same gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Quick context. The platform is called Nexus. It's a TypeScript monorepo plus a fleet of Python processors, running on a couple of mini PCs in my apartment. It ingests 26 data sources, runs 8 reasoning agents on schedules, and serves an MCP tool surface I use as my daily driver.&lt;/p&gt;

&lt;p&gt;Two layers matter for this post:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agents&lt;/strong&gt; are reasoning entities. They read from gold-layer tables, decide things, and write proposals to inbox tables. ARIA is the user-facing coordinator. Chronicler owns the timeline. Insight does anomaly detection. Five others fill in around them. They're scheduled, bounded, and they don't directly execute infrastructure changes — they propose, a human decides.&lt;/p&gt;

&lt;p&gt;Every agent decision lands in a row in &lt;code&gt;agent_decisions&lt;/code&gt;. Every row has a &lt;code&gt;trace_id&lt;/code&gt; like &lt;code&gt;aria-1777559470433-5c0db36c&lt;/code&gt;. That trace_id is generated by the agent itself at the start of a cycle and is 100% covered. It tells you the agent ran. It does not tell you what the LLM was asked or what it returned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The processors&lt;/strong&gt; are the deterministic side. They read raw data, enrich it, write to silver and gold. Some call LLMs (Gmail enrichment, ambient capture upgrade, financial event extraction). Each run lands in &lt;code&gt;aurora_processing_runs&lt;/code&gt; with a &lt;code&gt;langfuse_trace_id&lt;/code&gt; column populated when the run had Langfuse turned on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Langfuse itself&lt;/strong&gt; is self-hosted on a host on my private network. It's been running fine for weeks. It has traces in it. The dashboard shows traces. I have used the dashboard.&lt;/p&gt;

&lt;p&gt;I just hadn't asked the question "what fraction of my agent and processor activity is actually represented there."&lt;/p&gt;




&lt;h2&gt;
  
  
  The Audit Query
&lt;/h2&gt;

&lt;p&gt;The MCP tool that surfaced this is &lt;code&gt;nexus_agent_architecture_status&lt;/code&gt;. Under the hood it's running this against the operational Nexus Postgres:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cycle'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;decisions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;
         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;state_snapshot&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'langfuse_enabled'&lt;/span&gt;
       &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_langfuse_flag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;state_snapshot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'langfuse_enabled'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;langfuse_enabled_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state_snapshot&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'langfuse_trace_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
       &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;with_langfuse_trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;last_decision_at&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;agent_decisions&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cycle'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;invocation_type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;state_snapshot&lt;/code&gt; column is JSONB. Every agent cycle writes a small snapshot of the runtime config it ran under, including whether Langfuse was enabled, the active trace ID, and (when disabled) a &lt;code&gt;langfuse_disabled_reason&lt;/code&gt; string. This is the schema that lets me tell the difference between "we never tried to trace" and "we tried and failed."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fha91jd4laxpul5i0p0hg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fha91jd4laxpul5i0p0hg.png" alt="Audit Query" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The result over a 30-day window, sorted by decision volume:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Decisions&lt;/th&gt;
&lt;th&gt;Internal trace&lt;/th&gt;
&lt;th&gt;Langfuse trace&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Executor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ARIA&lt;/td&gt;
&lt;td&gt;31,451&lt;/td&gt;
&lt;td&gt;31,451&lt;/td&gt;
&lt;td&gt;5,452&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insight&lt;/td&gt;
&lt;td&gt;25,913&lt;/td&gt;
&lt;td&gt;25,913&lt;/td&gt;
&lt;td&gt;4,402&lt;/td&gt;
&lt;td&gt;17%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chronicler&lt;/td&gt;
&lt;td&gt;23,297&lt;/td&gt;
&lt;td&gt;23,297&lt;/td&gt;
&lt;td&gt;2,950&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Circle&lt;/td&gt;
&lt;td&gt;21,510&lt;/td&gt;
&lt;td&gt;21,510&lt;/td&gt;
&lt;td&gt;2,490&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infra&lt;/td&gt;
&lt;td&gt;19,701&lt;/td&gt;
&lt;td&gt;19,701&lt;/td&gt;
&lt;td&gt;2,524&lt;/td&gt;
&lt;td&gt;13%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correlator&lt;/td&gt;
&lt;td&gt;2,594&lt;/td&gt;
&lt;td&gt;2,594&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planner&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;2,592&lt;/td&gt;
&lt;td&gt;2,591&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;executor-A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keeper&lt;/td&gt;
&lt;td&gt;696&lt;/td&gt;
&lt;td&gt;696&lt;/td&gt;
&lt;td&gt;696&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;executor-B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table in two passes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First pass:&lt;/strong&gt; the agents producing the most decisions (ARIA at 31K, Insight at 25K) are the ones with the lowest Langfuse coverage (12–17%). The agents with low volume (Correlator, Planner, Keeper) sit at 100%. Inversely correlated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second pass:&lt;/strong&gt; it's not actually about volume. It's about something the volume happens to correlate with. The five high-volume agents are the ones whose execution is shaped by an older code path; the three high-coverage agents are on the newer one. Keeper runs on a different executor entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's in the Untraced Rows
&lt;/h2&gt;

&lt;p&gt;Pulling a sample of the rows where &lt;code&gt;langfuse_enabled&lt;/code&gt; is false tells the story directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;141946&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aria"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invocation_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cycle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aria-1777559470433-5c0db36c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-30T14:31:17.266Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"langfuse_disabled_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LANGFUSE_ENABLED is false"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That field is the answer. At the moment of that decision, the agent process saw &lt;code&gt;LANGFUSE_ENABLED=false&lt;/code&gt; in its environment and routed every LLM call through the no-op path.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the No-Op Path Works
&lt;/h2&gt;

&lt;p&gt;Here's the actual gating code, lightly trimmed, from &lt;code&gt;packages/core/src/services/langfuse-client.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLangfuseConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;LangfuseConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;parseBool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_ENABLED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// default false&lt;/span&gt;
    &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_PUBLIC_KEY&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;trimTrailingSlash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LANGFUSE_BASE_URL&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runWithLangfuseTrace&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LangfuseTraceParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LangfuseTraceContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getLangfuseConfig&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDisabledReason&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;warnDisabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// logs once per process&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// run the work, no trace&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... normal trace path&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a textbook pattern. Default off. Fail open. Log once. Never block the agent.&lt;/p&gt;

&lt;p&gt;The pattern is right. It's the same one the Python services use, and the same one the publishing pipeline uses for its drafting code. You don't want a Langfuse outage taking down agents.&lt;/p&gt;

&lt;p&gt;What the pattern doesn't do is tell you when it's been firing for weeks.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;warnDisabled&lt;/code&gt; call is guarded by a module-level boolean so it only logs once per process lifetime. The next 10,000 calls to &lt;code&gt;runWithLangfuseTrace&lt;/code&gt; from that process are silent. No counter, no metric, no row in the disabled-runs table. Just a single line in stdout that scrolled past at startup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Story: It Was Never Turned On
&lt;/h2&gt;

&lt;p&gt;I went looking through every checked-in config file for &lt;code&gt;LANGFUSE_ENABLED=true&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;rg &lt;span class="s2"&gt;"LANGFUSE_ENABLED"&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yaml &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero hits. The flag isn't set in any committed config. The agents that have full Langfuse coverage are the ones whose runtime environment happens to have &lt;code&gt;LANGFUSE_ENABLED=true&lt;/code&gt; set somewhere out of band — a systemd unit, an inherited shell env, a compose override that lives on the host.&lt;/p&gt;

&lt;p&gt;That explains the table.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keeper&lt;/strong&gt; runs under the newer executor process, which inherits an env that has the flag set. 100% coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlator and Planner&lt;/strong&gt; are recent additions wired into a different runtime path that always emits Langfuse spans regardless of the flag. 100% coverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The five high-volume agents&lt;/strong&gt; (ARIA, Insight, Chronicler, Circle, Infra) run under the older executor. Most of the time it doesn't see the flag. Occasionally it does — about 12-17% of cycles — probably the ones that happen to fall after a manual restart in a shell where the flag was exported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's not drift. It's never having been turned on in the first place for the path that does the most work.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Processor Side Has the Same Shape
&lt;/h2&gt;

&lt;p&gt;Pulling the 30 most recent rows from &lt;code&gt;aurora_processing_runs&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Processor Name&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Has Trace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ambient-moment-sync&lt;/td&gt;
&lt;td&gt;2026-04-29.langfuse-v1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gmail-enrich&lt;/td&gt;
&lt;td&gt;2026-04-29.events-v1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gmail-appointment-extract&lt;/td&gt;
&lt;td&gt;2026-04-30.v1&lt;/td&gt;
&lt;td&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mem-bronze-drain&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;plans-to-kg&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;voice-to-kg&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;social-bronze-drain&lt;/td&gt;
&lt;td&gt;v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ambient-context-upgrade-processor&lt;/td&gt;
&lt;td&gt;2026-04-29.context-v1&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;health-timeline-promote&lt;/td&gt;
&lt;td&gt;2026-04-30.v2&lt;/td&gt;
&lt;td&gt;✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same pattern. Processors with a &lt;code&gt;langfuse-v1&lt;/code&gt; or &lt;code&gt;events-v1&lt;/code&gt; tag in the version string emit trace IDs because their code was explicitly migrated to call &lt;code&gt;runWithLangfuseTrace&lt;/code&gt;. Processors still on &lt;code&gt;v1&lt;/code&gt; were written before the migration helper existed and never adopted it. They call &lt;code&gt;traceLlmGeneration&lt;/code&gt; if they make LLM calls, but the outer trace context is missing, so the spans don't correlate to anything queryable.&lt;/p&gt;

&lt;p&gt;The version string is doing the work the env flag isn't. It encodes whether the code knows about the tracing helper.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Generalizes
&lt;/h2&gt;

&lt;p&gt;I run this stack as one person. Eight agents, a handful of processors, one Langfuse instance, one set of credentials. The fix is a long afternoon. The same problem at any non-trivial agent deployment is much more expensive to discover and much more expensive to close, because by the time you ask the question you have hundreds of thousands of decisions you can't reconstruct.&lt;/p&gt;

&lt;p&gt;Three patterns that generalize from this audit:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Decision counts are not coverage.
&lt;/h3&gt;

&lt;p&gt;Every dashboard I had was counting decisions and showing them as green. None of them computed coverage ratios. Decision counts tell you the agent ran. They don't tell you whether you can answer what it did. If you're going to instrument observability, instrument the observability itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Default-off is correct. Silent default-off is not.
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;parseBool(env.LANGFUSE_ENABLED, false)&lt;/code&gt; default is right. You don't want observability code that fails closed and breaks the agent. But there's a difference between "fails open" and "fails open silently for weeks." The fix is a periodic check, on a separate cadence from the agents themselves, that reports &lt;code&gt;langfuse_enabled=false&lt;/code&gt; across {n} cycles in the last hour to a channel a human will see. The disabled-reason field already exists. Aggregating it is one cron job.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Code-version is the actual observability gate.
&lt;/h3&gt;

&lt;p&gt;The flag check is a red herring. The real question is whether the agent or processor was written to call into the tracing helper at all. &lt;code&gt;2026-04-29.langfuse-v1&lt;/code&gt; in a version string is a much better predictor of coverage than the env flag. Treat your tracing migration as a code migration, audit by version, and don't assume an env flag covers the gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe02ucc9bb22u84fu0mx0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe02ucc9bb22u84fu0mx0.png" alt="Generalizations" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm Doing About It
&lt;/h2&gt;

&lt;p&gt;Three things, in this order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the flag where it should always have been set.&lt;/strong&gt; This is the embarrassing one. Add &lt;code&gt;LANGFUSE_ENABLED=true&lt;/code&gt; to the older executor's systemd unit, restart, verify with one cycle from each of the five low-coverage agents. This closes the going-forward gap immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Materialize coverage as a first-class metric.&lt;/strong&gt; A view, &lt;code&gt;agent_observability_coverage&lt;/code&gt;, computed from the audit query above on a rolling 24-hour window. A small alert that fires if any active agent drops below 95%. The view is gitignored config; the alert lives in the existing notification path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backfill triage.&lt;/strong&gt; I can't recover the prompts and responses for the 100,000+ untraced decisions. They're gone. What I can do is replay the inputs for the high-importance subset — anything that touched a person record, anything in the financial event flow, anything routed through ARIA's user-facing path — and emit a post-hoc trace with whatever the prompt would have been at the version pin recorded in &lt;code&gt;state_snapshot.prompt_version&lt;/code&gt;. The output won't match what actually happened. But it gives a baseline for behavioral drift detection going forward.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;The Nexus doctrine line is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nexus is best understood as a data and memory platform with bounded reasoning agents on top, not as an unbounded autonomous swarm.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The corollary I hadn't written down until now is that bounded reasoning is only bounded if you can see the reasoning. A &lt;code&gt;trace_id&lt;/code&gt; that points to a row with no LLM-level lineage isn't bounded reasoning. It's bounded execution with hidden reasoning behind it.&lt;/p&gt;

&lt;p&gt;The agents I was most worried about turned out to be the ones I was least able to inspect. That's the inverse of the order I would have chosen.&lt;/p&gt;

&lt;p&gt;The fix is straightforward. The lesson is that I had to write a query to find out.&lt;/p&gt;




&lt;p&gt;The public architectural repository for Nexus is available here: &lt;a href="https://github.com/niclydon/nexus-public" rel="noopener noreferrer"&gt;github.com/niclydon/nexus-public&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One important clarification: nexus-public intentionally does not ship with hard dependencies on vendor-specific observability and evaluation tooling like Langfuse, Promptfoo, and several other operational integrations I use in the live runtime. The public repo is designed more as an architectural reference implementation — agents, processors, MCP tooling, schemas, orchestration boundaries, and execution patterns — so someone can wire in whichever tracing and observability stack they prefer rather than inheriting mine by default.&lt;/p&gt;

&lt;p&gt;The Langfuse integration, executor runtime paths, and audit tooling discussed in this post come from the private operational implementation that powers the platform day to day.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>observability</category>
      <category>typescript</category>
      <category>devops</category>
    </item>
    <item>
      <title>It’s Not Just the College Kids</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sun, 10 May 2026 20:01:15 +0000</pubDate>
      <link>https://forem.com/niclydon/its-not-just-the-college-kids-57ha</link>
      <guid>https://forem.com/niclydon/its-not-just-the-college-kids-57ha</guid>
      <description>&lt;p&gt;Sam Altman told a Sequoia Capital audience that older people use ChatGPT like Google, millennials use it as a life advisor, and college students use it as an operating system.&lt;/p&gt;

&lt;p&gt;He’s not wrong about the college students. He’s wrong about who else is doing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data doesn’t support the generational frame
&lt;/h2&gt;

&lt;p&gt;Altman’s taxonomy is intuitive. Younger people grew up with these tools. Of course they’d go deeper.&lt;/p&gt;

&lt;p&gt;But the &lt;a href="https://survey.stackoverflow.co/2025/" rel="noopener noreferrer"&gt;Stack Overflow 2025 Developer Survey&lt;/a&gt; tells a different story. Developers with 10-19 years of experience were among the heaviest AI adopters, consistently reported strong productivity gains, and were simultaneously the &lt;em&gt;least likely&lt;/em&gt; cohort to highly trust AI output.&lt;/p&gt;

&lt;p&gt;That combination is the whole story.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://www.qlik.com/" rel="noopener noreferrer"&gt;Qlik survey&lt;/a&gt; found mid-career professionals, not Gen Z, emerging as AI’s most active power users. And an arXiv paper from December 2025 nailed it in the title: &lt;a href="https://arxiv.org/abs/2512.09615" rel="noopener noreferrer"&gt;“Professional Software Developers Don’t Vibe, They Control.”&lt;/a&gt; Experienced developers deploy deliberate strategies to manage agent behavior rather than handing over the keys.&lt;/p&gt;

&lt;p&gt;The pattern isn’t younger equals deeper. Experience changes what “deep” looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually built
&lt;/h2&gt;

&lt;p&gt;I’m 45. Director of Information Security by day. Builder of things by night (and also by day, and also at 3 AM).&lt;/p&gt;

&lt;p&gt;I can tell you exactly how I use AI because I built a system that tracks it: 34,000+ messages across AI platforms over two years, logged and queryable.&lt;/p&gt;

&lt;p&gt;The system is called &lt;a href="https://github.com/niclydon/nexus-public" rel="noopener noreferrer"&gt;Nexus&lt;/a&gt;. It’s a biographical intelligence platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;250-table Postgres schema&lt;/strong&gt; on hardware I own&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;26 data sources&lt;/strong&gt; across 14 sync intervals (communication, location, health, photos, git activity, AI conversations, financial data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8 autonomous agents&lt;/strong&gt; coordinating across hundreds of tools from 10 connected MCP services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local LLM inference&lt;/strong&gt; serving large models on consumer hardware (160GB VRAM across two machines, zero cloud compute for personal data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private mesh network&lt;/strong&gt;, no cloud exposure, every query auditable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwg8jw2qqwrg9sp1mvp5h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwg8jw2qqwrg9sp1mvp5h.png" alt="Nexus" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent runtime uses a doctrine I spent months refining. The first line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Nexus is best understood as a data and memory platform with bounded reasoning agents on top, not as an unbounded autonomous swarm.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agents reason, explain, coordinate, and propose. They don’t own execution. Processors and handlers do the deterministic work. The database owns state. The agents are an interpretation layer, not a replacement for engineering discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  One weekend, with receipts
&lt;/h2&gt;

&lt;p&gt;Two weekends ago a smart ring I’d backed on Kickstarter 25 months earlier finally shipped. By Sunday morning I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fully reverse-engineered the undocumented BLE protocol&lt;/li&gt;
&lt;li&gt;Decoded the audio codec&lt;/li&gt;
&lt;li&gt;Written a complete protocol reference document&lt;/li&gt;
&lt;li&gt;Scaffolded two new applications consuming the protocol&lt;/li&gt;
&lt;li&gt;Touched nine repositories&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zero sleep. I know because Nexus reconstructed the timeline by cross-referencing git commit timestamps, Claude Code session logs, and ambient capture data from a wearable camera. Two gaps longer than 15 minutes in my activity between 8 PM Saturday and 6 AM Sunday. The longest was 29 minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1fh5kewdgfjh9o91m3qq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1fh5kewdgfjh9o91m3qq.png" alt="Timeline" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then the system did something I didn’t ask for. It looked back at the preceding days and showed me the all-nighter wasn’t unusual. The Thursday before, I’d also coded straight through the night shipping infrastructure changes across multiple repos.&lt;/p&gt;

&lt;p&gt;That conversation ended with Claude telling me to talk to a human instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Product vs. material
&lt;/h2&gt;

&lt;p&gt;Altman’s generational frame obscures the more useful distinction: &lt;strong&gt;people who use AI as a product&lt;/strong&gt; vs. &lt;strong&gt;people who use it as a material&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Product users open ChatGPT, ask a question, get an answer. Memory is a convenience feature. Someone else runs the infrastructure.&lt;/p&gt;

&lt;p&gt;Material users wire AI into their own systems. They build the memory layer because the commercial one isn’t deep enough. They run local models because privacy isn’t optional when you’re processing decades of personal data. They treat AI like a machinist treats metal: something you shape, cut, and build with.&lt;/p&gt;

&lt;p&gt;When Nexus fabricated a sleep window during my ring weekend (confidently claiming I’d slept 3-4 hours based on a gap in commit timestamps), I challenged it. It ran additional queries across every data source, found continuous activity filling the gap, and corrected itself.&lt;/p&gt;

&lt;p&gt;That kind of interaction requires knowing the tool well enough to catch it lying. That comes from experience, not from growing up with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;I open-sourced the full architecture: &lt;a href="https://github.com/niclydon/nexus-public" rel="noopener noreferrer"&gt;github.com/niclydon/nexus-public&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Agent runtime, tool catalog, job system with 93 handlers, knowledge pipeline, LLM router with circuit breakers, distributed autoscaler. MIT license.&lt;/p&gt;

&lt;p&gt;The college students Altman described are building AI judgment naturally, by using it so heavily they start to feel its edges. Practitioners are building it deliberately, with explicit boundaries, approval gates, and doctrine documents that say “the agent proposes, the human decides.”&lt;/p&gt;

&lt;p&gt;Both paths lead to the same place. One arrives by instinct. The other by architecture.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Trained an LLM on 75K of My Own Messages So It Would Stop Writing Like a Chatbot</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Sat, 09 May 2026 05:03:40 +0000</pubDate>
      <link>https://forem.com/niclydon/i-trained-an-llm-on-75k-of-my-own-messages-so-it-would-stop-writing-like-a-chatbot-1dpi</link>
      <guid>https://forem.com/niclydon/i-trained-an-llm-on-75k-of-my-own-messages-so-it-would-stop-writing-like-a-chatbot-1dpi</guid>
      <description>&lt;p&gt;Frontier LLMs are good at figuring out &lt;em&gt;what&lt;/em&gt; to say. They're bad at saying it the way you would.&lt;/p&gt;

&lt;p&gt;I've spent months using Claude and GPT-4o to draft content for a personal publishing system. The system prompt is detailed: first person, short sentences mixed with long ones, no LinkedIn buzzwords, lead with specifics. The drafts come back structurally correct every time. And they sound like a talented intern who studied my writing for an afternoon.&lt;/p&gt;

&lt;p&gt;Here's what a prompted frontier model produces when asked to write in my voice:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;By maintaining complete control over the hardware infrastructure, I eliminate the need to navigate third-party terms of service entirely.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's what I actually write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;The data lives on hardware I control. There's no terms of service to read because there's no service.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same idea. One sounds like me. The other sounds like a model following instructions about how I sound.&lt;/p&gt;

&lt;p&gt;Prompt engineering has a ceiling for voice matching. I built the thing that goes above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture: two models, one job each
&lt;/h2&gt;

&lt;p&gt;Most "personal AI" projects I've seen collapse three different jobs into one model: reasoning about what to write, grounding it in facts, and matching the author's voice. Those are three different capabilities with three different training signals. Collapsing them means each one compromises the others.&lt;/p&gt;

&lt;p&gt;My architecture separates them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Frontier model generates content] → [Fine-tuned 3B model rewrites in my voice] → output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tier 1&lt;/strong&gt; is a frontier model (Claude Opus, Llama 70B, whatever fits the task). It receives context from my work: git commits, knowledge graph facts, calendar events. It handles the reasoning, structure, and factual grounding. It's good at this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2&lt;/strong&gt; is a Qwen 2.5 3B model fine-tuned on 75,329 samples of my actual writing. It doesn't reason. It doesn't need to be smart. It rewrites. It takes competent-but-generic text and makes it sound like me.&lt;/p&gt;

&lt;p&gt;The 3B parameter count was deliberate. I evaluated Phi-3 Mini and Llama 3.2 3B as well. Qwen won on three criteria: it fits comfortably on consumer hardware (about 2GB quantized), a 3B model has more than enough capacity for style transfer since it's not doing reasoning, and the smaller parameter space means the voice signal doesn't get drowned out by general capabilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data: 23 years of my own writing
&lt;/h2&gt;

&lt;p&gt;The training data comes from a personal data warehouse I've built over several years. I extracted every piece of text I've written across 12 platforms, spanning back to 2004.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Samples&lt;/th&gt;
&lt;th&gt;What it captures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iMessage&lt;/td&gt;
&lt;td&gt;34,987&lt;/td&gt;
&lt;td&gt;Casual conversation, how I talk to people I know&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Chat&lt;/td&gt;
&lt;td&gt;10,350&lt;/td&gt;
&lt;td&gt;Work chat from several years at law firms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plaud recordings&lt;/td&gt;
&lt;td&gt;10,514&lt;/td&gt;
&lt;td&gt;My actual spoken words, transcribed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChatGPT prompts&lt;/td&gt;
&lt;td&gt;7,645&lt;/td&gt;
&lt;td&gt;How I phrase technical requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram DMs&lt;/td&gt;
&lt;td&gt;4,535&lt;/td&gt;
&lt;td&gt;Social, casual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gmail sent&lt;/td&gt;
&lt;td&gt;5,088&lt;/td&gt;
&lt;td&gt;Email across the formality spectrum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facebook&lt;/td&gt;
&lt;td&gt;1,876&lt;/td&gt;
&lt;td&gt;Social posts and comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude prompts&lt;/td&gt;
&lt;td&gt;303&lt;/td&gt;
&lt;td&gt;Technical prompts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;Text messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old Outlook (PST)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;Work email from early career&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;75,329&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23 years, ~3.1M tokens&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fikerr6uowkqz9xoiwmtf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fikerr6uowkqz9xoiwmtf.png" alt="Training Data" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's interesting about this corpus isn't the volume. It's the temporal range. You can watch a voice form across two decades. My emails from 2005 don't sound like my iMessages from 2024, but they share structural patterns: compression, directness, a preference for concrete over abstract.&lt;/p&gt;

&lt;h3&gt;
  
  
  The extraction script
&lt;/h3&gt;

&lt;p&gt;The extraction runs against a PostgreSQL database on my home server. Each source has its own table with a different schema, so the script handles 12 different query patterns. A few things I learned building it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filter aggressively.&lt;/strong&gt; Raw message data is full of noise. I strip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tapback reactions (iMessage's "Loved", "Liked", etc.)&lt;/li&gt;
&lt;li&gt;URL-only messages&lt;/li&gt;
&lt;li&gt;Emoji-only messages&lt;/li&gt;
&lt;li&gt;Anything under 10 characters&lt;/li&gt;
&lt;li&gt;Automated emails (IFTTT, cron notifications, shipping confirmations)&lt;/li&gt;
&lt;li&gt;Quoted reply text and email signatures ("Sent from my iPhone", forwarded message headers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The quote-stripping matters more than you'd expect. Without it, half your Gmail training data is other people's writing with your two-line reply appended.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;strip_quoted_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Strip quoted reply text, forwarded headers, and signatures.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;stripped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^On .+ wrote:$&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-----Original Message-----&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# Strip signatures
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;-- &lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Sent from my iPhone&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Sent from my Mac&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Get Outlook for&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deduplicate by prefix.&lt;/strong&gt; Messages get forwarded, quoted, copied. A 200-character prefix hash catches most of it without being so aggressive that you lose legitimately similar messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Format as ShareGPT conversations.&lt;/strong&gt; The training framework (unsloth + trl) expects chat-format data. Each record becomes a two-turn conversation: a system prompt establishing context, a "human" turn with the preceding message or context, and a "gpt" turn containing my actual writing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Privacy before training
&lt;/h3&gt;

&lt;p&gt;This is the part most QLoRA tutorials skip entirely, because most people train on public datasets.&lt;/p&gt;

&lt;p&gt;Before writing a single training script, I built sanitization into the extraction layer. No real names of private individuals make it into training data. No credentials, no specific addresses, no dollar amounts. The system generalizes by design.&lt;/p&gt;

&lt;p&gt;This isn't optional when your training data is your own life. If you train on 23 years of personal messages without sanitization, the model will happily reproduce your friends' names, your home address, and your credit card's last four digits in its output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Training: 17 hours on consumer hardware
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hardware:&lt;/strong&gt; AMD Radeon 8060S (Strix Halo, 96GB unified VRAM), ROCm&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Base model:&lt;/strong&gt; &lt;code&gt;unsloth/Qwen2.5-3B-Instruct-bnb-4bit&lt;/code&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Method:&lt;/strong&gt; QLoRA (4-bit quantized frozen base + LoRA adapter)&lt;/p&gt;

&lt;h3&gt;
  
  
  LoRA configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FastLanguageModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_peft_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;target_modules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;q_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;k_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;v_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;o_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gate_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;up_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;down_proj&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;lora_alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lora_dropout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;use_gradient_checkpointing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;unsloth&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on these choices:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rank 16&lt;/strong&gt; is the sweet spot I landed on. Rank 8 underfit (the model produced generic responses indistinguishable from the base). Rank 32 was slightly better on short messages but no meaningful improvement on longer-form writing, and it doubled the adapter size. For a style-transfer task on a 3B base, rank 16 captures enough voice signal without overfitting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All seven projection targets&lt;/strong&gt; (not just q/k/v). For voice transfer, the gate, up, and down projections in the MLP layers matter as much as the attention projections. The model needs to learn word choice and sentence rhythm, not just attention patterns. Targeting only q/k/v produced outputs that had roughly correct structure but wrong vocabulary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero dropout.&lt;/strong&gt; The training data is clean (my own writing, heavily filtered) and the task is narrow. Dropout helps with generalization on noisy data; here it just slows convergence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Training dynamics
&lt;/h3&gt;

&lt;p&gt;14,127 steps across 3 epochs. Loss started around 1.4 and converged to ~0.98 by the end. The learning rate used a cosine schedule from 2e-4 down to near zero.&lt;/p&gt;

&lt;p&gt;The whole run took about 17 hours. I'd tried Qwen 2.5 7B first and that was 42 hours for the same epochs. Given that this model's only job is style transfer, the 3B was the right call: faster iteration, similar voice quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Previous failures (worth mentioning)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Qwen 3.5 9B:&lt;/strong&gt; OOM during training. Loaded fine at ~19GB in 4-bit, but the GatedDeltaNet hybrid attention layers in the newer Qwen architecture consume too much activation memory during the forward pass. The model worked for inference but couldn't train on this hardware.&lt;/p&gt;

&lt;p&gt;This is worth knowing if you're planning your own fine-tune: a model that runs fine for inference may not have enough headroom for training, since training needs to store activations for the backward pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The adapter: 115MB of voice
&lt;/h2&gt;

&lt;p&gt;The final adapter is a 115MB safetensors file. The base Qwen 2.5 3B model is about 2GB quantized. Together they're smaller than a single Llama 70B layer.&lt;/p&gt;

&lt;p&gt;The merged fp16 model (adapter baked into the base weights) is about 6GB, which is useful for GGUF conversion if you want to serve it via llama.cpp without loading the adapter separately at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation: blind A/B comparison
&lt;/h2&gt;

&lt;p&gt;I ran 12 prompts through both the fine-tuned model and the base Qwen 2.5 3B, randomized the order, and compared outputs blind. The difference is stark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt:&lt;/strong&gt; "can you review this PR when you get a chance, it's the auth middleware rewrite"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Base Qwen (no adapter):&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hey there! Absolutely, I'll squeeze in a review of the auth middleware rewrite when I get a chance. I've been meaning to check it out, especially given how significant the changes must be. Let me know if you've had a chance to test any edge cases or if there are any specific feedback points you're looking for. I'll aim to get through it soon. Cheers!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Fine-tuned (nic-voice):&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;yeah, I'm in the office today. I will review it later today when I get home&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The base model writes like a chatbot. The fine-tuned model writes like a person. Specifically, like me: terse, no greeting flourish, commits to a concrete time, moves on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt:&lt;/strong&gt; "thoughts on the new iphone"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Base:&lt;/strong&gt; A 500-character enthusiastic review touching on cameras, battery life, design philosophy, and the tech conversation at large.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fine-tuned:&lt;/strong&gt; "I have the 17"&lt;/p&gt;

&lt;p&gt;That's not a sophisticated response. It's also exactly what I'd actually text someone who asked me that question.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgbct296ajyt5lfr7eauh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgbct296ajyt5lfr7eauh.png" alt="Base Model vs QLoRA Model" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it works and where it doesn't
&lt;/h3&gt;

&lt;p&gt;The voice transfer works best on the kind of writing the training data is heaviest in: casual conversation, work chat, short email. The model nails my compression ratio, my tendency to answer questions with minimal context, my habit of skipping greetings.&lt;/p&gt;

&lt;p&gt;It's weaker on long-form writing. The training data is dominated by short messages (iMessages, chat), so the model's instinct is to be brief. For the two-tier architecture, that's actually fine: long-form content comes from the frontier model, and the voice adapter handles the rewrite. But if you trained this for standalone generation, you'd want to oversample your longer-form writing to balance the distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Oversample formal writing.&lt;/strong&gt; My corpus is 47% iMessage, which means the model's default register is very casual. Weighting emails and longer-form writing 2-3x would produce a more balanced adapter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add a style-routing prompt.&lt;/strong&gt; Instead of one adapter for all contexts, the system prompt could specify "email voice" vs. "chat voice" vs. "post voice." The training data already has source labels; I just didn't use them during training.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evaluate on the actual target task.&lt;/strong&gt; My A/B eval tests conversational responses, which is interesting but not the real use case. The real use case is rewriting AI-generated content drafts. I should have included prompts like "rewrite this paragraph in your voice" with actual frontier-model output as input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, if you want to build this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data extraction:&lt;/strong&gt; Python + psycopg2 against PostgreSQL. The hard part is handling 12 different table schemas and filtering noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Training:&lt;/strong&gt; &lt;a href="https://github.com/unslothai/unsloth" rel="noopener noreferrer"&gt;unsloth&lt;/a&gt; + trl (SFTTrainer). Unsloth handles the 4-bit quantization and gradient checkpointing; trl handles the training loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; Any GPU with 8GB+ VRAM can fine-tune a 3B model with QLoRA. I used an AMD Radeon 8060S, but an RTX 3060 12GB or even an M1 Mac would work for a 3B base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serving:&lt;/strong&gt; llama.cpp with &lt;code&gt;--lora&lt;/code&gt; flag for the GGUF adapter, or load the merged model directly. I serve mine through a local API gateway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format:&lt;/strong&gt; ShareGPT JSONL. Each record is a conversation with system/human/assistant turns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need 75K samples. You could start with a few thousand emails from your sent folder and a simple extraction script. The architecture (frontier model for content, fine-tuned small model for voice) works regardless of corpus size. The small model just gets better as you feed it more.&lt;/p&gt;

&lt;p&gt;The point isn't the scale. The point is that voice is learnable, and it's learnable separately from reasoning, and once you separate those concerns, both get better.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>I Built a Personal Knowledge Graph. Apple Had Already Built One on My Laptop.</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Thu, 07 May 2026 22:26:00 +0000</pubDate>
      <link>https://forem.com/niclydon/i-built-a-personal-knowledge-graph-apple-had-already-built-one-on-my-laptop-54l8</link>
      <guid>https://forem.com/niclydon/i-built-a-personal-knowledge-graph-apple-had-already-built-one-on-my-laptop-54l8</guid>
      <description>&lt;p&gt;I've been building a system called Nexus for the past few months. It's a personal data platform: 54 data sources, 250+ tables, 358,000 knowledge graph facts, all running in Postgres 16 on a home server. It ingests everything from iMessage to Gmail to Apple Photos to Spotify to HealthKit. The goal is biographical intelligence: a queryable model of my own life.&lt;/p&gt;

&lt;p&gt;A few weeks ago, while wiring up some of the Apple-specific data sources, I cracked open the local databases that Apple maintains on macOS. What I found was... familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Apple stores locally
&lt;/h2&gt;

&lt;p&gt;Apple maintains several overlapping SQLite databases on your Mac. The digital forensics community has mapped these fairly well, even though Apple barely documents them publicly. Sarah Edwards at &lt;a href="http://www.mac4n6.com/blog/2018/8/5/knowledge-is-power-using-the-knowledgecdb-database-on-macos-and-ios-to-determine-precise-user-and-application-usage" rel="noopener noreferrer"&gt;mac4n6&lt;/a&gt; was among the first to document knowledgeC.db in depth, and her later research on PersonalizationPortrait specifically called out that the database was "loaded with data." But that work focused on forensic investigation (what can law enforcement extract from a seized device), not architectural analysis. I'm coming at it from the other direction: I built a system that does the same thing, and I want to understand what Apple's design choices can teach me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F24px21lof4wma4sx8jxf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F24px21lof4wma4sx8jxf.png" alt="Personalization Portrait"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The databases I ended up ingesting into Nexus:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;knowledgeC.db&lt;/strong&gt; (CoreDuet framework) — this is the big one. Forensic researchers call it "pattern of life" data. App usage, device usage, location context, behavioral timelines, media activity, browsing behavior, communication metadata, charging patterns, movement patterns. On newer macOS/iOS versions, &lt;a href="https://www.magnetforensics.com/blog/bringing-it-back-with-biome-data/" rel="noopener noreferrer"&gt;much of this moved into the Biome framework&lt;/a&gt;, but the architectural concept stayed the same.&lt;/p&gt;

&lt;p&gt;After harvesting, my own &lt;code&gt;aurora_raw_apple_knowledge&lt;/code&gt; table has 13,677 records and &lt;code&gt;aurora_raw_biome&lt;/code&gt; has 249,776. Here's what the stream distribution looks like in knowledgeC:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stream&lt;/th&gt;
&lt;th&gt;Records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/app/usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8,739&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/app/intents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,078&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/app/webUsage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;758&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/display/isBacklit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;338&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/notification/usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;304&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/bluetooth/isConnected&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And in Biome:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stream&lt;/th&gt;
&lt;th&gt;Records&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GenerativeModels.GenerativeFunctions.Instrumentation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;241,138&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Autonaming.Messages.MessageIds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,221&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Messages.Read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,098&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;App.Intent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,139&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Siri.Remembers.MessageHistory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,051&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That first Biome stream alone: 241,138 records of Apple Intelligence function invocations. Every time the generative model runs on your device, that's a record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PersonalizationPortrait&lt;/strong&gt; — this is where it gets architecturally interesting. My &lt;code&gt;aurora_raw_apple_personalization&lt;/code&gt; table has 38,775 records. The schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;entity_name  text
entity_type  text
interest_score  real
decay_score  real
topic_category  text
is_significant_contact  boolean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apple is running interest scoring with temporal decay on entities it tracks about you. The entity types include &lt;code&gt;significant_contact&lt;/code&gt; (3,970 records), &lt;code&gt;topic&lt;/code&gt; (2,805 records), &lt;code&gt;loc&lt;/code&gt; (2,000 location entities), and a set of opaque &lt;code&gt;ne_*&lt;/code&gt; (named entity) categories that appear to map to different entity classes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;topic_category&lt;/code&gt; values are Wikidata QIDs. As far as I can tell from the published DFIR literature, nobody has called this out before: Apple is using the Wikidata knowledge graph as its topic ontology. &lt;code&gt;Q223563&lt;/code&gt; (Google Calendar) is the most frequent topic in my data with 587 records and an average interest score of 0.999. Apple knows I'm very interested in calendaring.&lt;/p&gt;

&lt;p&gt;The location entities have a mean &lt;code&gt;decay_score&lt;/code&gt; of -1.0, which suggests Apple uses negative decay to actively deprecate location relevance over time. Contacts don't decay. Locations do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apple Intelligence triples&lt;/strong&gt; — this one stopped me cold. &lt;code&gt;aurora_raw_apple_intelligence&lt;/code&gt; has 40,339 records with this schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subject    text
predicate  text
object     text
confidence real
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a knowledge graph. Subject-predicate-object triples with confidence scores. Apple is running entity resolution on your device:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Predicate&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nm_hasVisualIdentifier/PS1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,471&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nm_hasVisualIdentifier/nm_associationReason&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,471&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nm_personType/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2,471&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nm_entityAliasRelationship/nm_confirmationConfidence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,578&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nm_entityAliasRelationship/PS89&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,578&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;nm_hasVisualIdentifier&lt;/code&gt; links face embeddings to person entities. &lt;code&gt;nm_entityAliasRelationship&lt;/code&gt; with &lt;code&gt;nm_confirmationConfidence&lt;/code&gt; is alias resolution: "this name and this face are the same person, with this confidence score." &lt;code&gt;nm_personType&lt;/code&gt; classifies entities into person categories.&lt;/p&gt;

&lt;p&gt;This is the same architectural pattern I built in Nexus, where &lt;code&gt;aurora_social_identities&lt;/code&gt; (7,203 person entities) links to &lt;code&gt;knowledge_facts&lt;/code&gt; (358,053 facts) through entity resolution with confidence scoring and alias deduplication. Apple and I arrived at the same design, independently, for the same reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural comparison
&lt;/h2&gt;

&lt;p&gt;Here's what surprised me about the overlap:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Apple (on-device)&lt;/th&gt;
&lt;th&gt;Nexus (my system)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Entity resolution&lt;/td&gt;
&lt;td&gt;Face-to-identity linking with confidence&lt;/td&gt;
&lt;td&gt;Multi-signal identity merge with confidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relationship modeling&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;is_significant_contact&lt;/code&gt; boolean + interaction frequency&lt;/td&gt;
&lt;td&gt;Weighted edges with temporal validity windows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topic classification&lt;/td&gt;
&lt;td&gt;Wikidata QIDs with interest + decay scores&lt;/td&gt;
&lt;td&gt;Knowledge graph facts with typed predicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behavioral timeline&lt;/td&gt;
&lt;td&gt;knowledgeC + Biome streams&lt;/td&gt;
&lt;td&gt;Unified timeline across 54 sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Location context&lt;/td&gt;
&lt;td&gt;2,000 location entities with decay scoring&lt;/td&gt;
&lt;td&gt;Google Timeline + device GPS + travel records&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Where Apple is richer: the ML layer. The visual identifier linking, the interest/decay scoring algorithms, the generative model instrumentation. That's 241K records of model telemetry I can see but can't interpret because Apple's weighting logic is opaque.&lt;/p&gt;

&lt;p&gt;Where Nexus is richer: temporal depth (24 years vs. however long your Mac has been running), cross-platform fusion (Apple only sees Apple ecosystem data), and full auditability. I can &lt;code&gt;SELECT * FROM knowledge_facts WHERE subject_id = $person&lt;/code&gt; and get every fact I've ever recorded. Apple's system is a black box even to the device owner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3gce9xjxz0qpd6pscj0t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3gce9xjxz0qpd6pscj0t.png" alt="Nexus v iPhone"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody talks about
&lt;/h2&gt;

&lt;p&gt;Apple's privacy story is "we keep this on-device." That's meaningful. It's genuinely better than sending everything to a cloud. I respect the architectural decision.&lt;/p&gt;

&lt;p&gt;But "on-device" doesn't mean "small." My Mac is quietly maintaining:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;249,776 behavioral telemetry records (Biome)&lt;/li&gt;
&lt;li&gt;40,339 knowledge graph triples with confidence-scored entity resolution (Apple Intelligence)&lt;/li&gt;
&lt;li&gt;38,775 personalization records with interest scoring and temporal decay (Portrait)&lt;/li&gt;
&lt;li&gt;13,677 app/web/media usage records (knowledgeC)&lt;/li&gt;
&lt;li&gt;10,000 Siri interaction records&lt;/li&gt;
&lt;li&gt;1,153 Siri entity records&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total across the Apple-specific tables I've ingested: &lt;strong&gt;353,744 records&lt;/strong&gt;. That's not a cache. That's an intelligence platform.&lt;/p&gt;

&lt;p&gt;The forensic researchers have known this for years. DFIR investigators consider these databases some of the most valuable artifacts on an Apple device because they effectively reconstruct a complete behavioral profile: who you talk to, how often, which apps you use, what topics interest you, where you go, when you're active, and which people matter to you.&lt;/p&gt;

&lt;p&gt;Apple's system is sophisticated, privacy-respecting by design, and significantly more extensive than most users or even most security professionals realize. The &lt;code&gt;is_significant_contact&lt;/code&gt; flag alone implies Apple maintains a running model of your relationship hierarchy. The Wikidata topic ontology means it's classifying your interests against a structured knowledge base. The entity alias resolution with confidence scoring means it's doing the same identity deduplication work that takes enterprise MDM platforms months to implement.&lt;/p&gt;

&lt;p&gt;All locally. All silently. All without documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters if you build data systems
&lt;/h2&gt;

&lt;p&gt;If you're building any kind of personal data platform, user modeling system, or behavioral analytics pipeline, Apple already solved a lot of the hard design problems on every Mac and iPhone. The patterns are worth studying:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Interest scoring with decay.&lt;/strong&gt; Not just "what do you care about" but "what did you used to care about." The negative decay on locations vs. zero decay on contacts is a real design insight: people are permanent, places are transient.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wikidata as ontology.&lt;/strong&gt; Instead of inventing a topic taxonomy, Apple adopted an existing structured knowledge base. That's a build-vs-buy decision most teams get wrong.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confidence-scored entity resolution.&lt;/strong&gt; Not "this face is this person" but "this face is this person with 0.87 confidence." The alias relationship table is doing probabilistic identity merge, which is the correct architecture for a system that can't ask the user to confirm every match.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separation of behavioral streams.&lt;/strong&gt; Biome doesn't dump everything in one table. Each stream (&lt;code&gt;App.Intent&lt;/code&gt;, &lt;code&gt;Messages.Read&lt;/code&gt;, &lt;code&gt;ScreenTime.AppUsage&lt;/code&gt;) is its own event type with its own schema. That's the same architectural decision I made with Nexus's per-source bronze tables, and for the same reason: different data shapes shouldn't be forced into a single schema.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I spent six weeks a biographical intelligence system. Apple had already built the foundation of one on my laptop. The difference is: I can query mine whenever I want.&lt;/p&gt;

</description>
      <category>apple</category>
      <category>postgres</category>
      <category>privacy</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Building a Skills Updater Pipeline for AI Platforms</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Wed, 06 May 2026 21:22:20 +0000</pubDate>
      <link>https://forem.com/niclydon/building-a-skills-updater-pipeline-for-ai-platforms-2jan</link>
      <guid>https://forem.com/niclydon/building-a-skills-updater-pipeline-for-ai-platforms-2jan</guid>
      <description>&lt;p&gt;I turned 1,870 JSONL files into six new user-level skills for my AI platform in a single session. Here’s how I built a repeatable pipeline for skills-updater.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I had a one-off question: 'Look through all my Claude Code JSONL files and recommend new skills.' This meant walking through ~904 MB of data across 45 project directories, filtering down to 2,752 real user-typed prompts, and cross-referencing against an existing skill set of 56 (9 user + 47 plugin). The manual deep-dive was expensive—too expensive to redo from scratch. So, I built skills-updater to automate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pipeline
&lt;/h2&gt;

&lt;p&gt;The repo lives at the heart of my AI ecosystem, tied to Nexus and ARIA. I wrote scripts to parse the JSONL files, extract meaningful user interactions, and rank skill gaps. The synthesis returned 12 candidates; six shipped immediately:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;narrative-docs-update&lt;/em&gt;: Captures my policy of documentary-grade writing (147 hits across 30 projects).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;whats-next&lt;/em&gt;: Briefs me on session restarts (62+ hits).&lt;/p&gt;

&lt;p&gt;Four others targeting specific repetitive tasks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pipeline runs on my own server, leveraging local compute to keep costs down. I used Node.js for file parsing and Python for ranking logic, with outputs written back as actionable configs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;p&gt;Here’s a simplified snippet from the ranking script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;python&lt;/span&gt;

&lt;span class="n"&gt;skills_ranker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rank_candidates&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing_skills&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="n"&gt;gaps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;matches_existing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existing_skills&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;span class="n"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;calculate_relevance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;frequency&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Tradeoffs
&lt;/h2&gt;

&lt;p&gt;The first run missed edge cases—some prompts were misclassified as noise due to inconsistent formatting. I had to manually tweak the filter logic at 2am to catch those. Also, the pipeline isn’t real-time; it’s a batch process that assumes static data. That’s a limitation I’ll address in v2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters
&lt;/h2&gt;

&lt;p&gt;This isn’t just about skills. It’s about turning manual grunt work into a system. If you’re building AI tools, you’ve likely faced the same slog—repeating analysis that a script could do. Automating this saved me hours per session, and it scales as my project count grows. Next up: wiring this into Nexus for continuous updates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F39nhzur338sw3u3tcy1p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F39nhzur338sw3u3tcy1p.png" alt="A dimly lit workbench with an open notebook, a terminal displaying code, and six index cards fanned out — a late-night working session blending analog and digital." width="800" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What repetitive tasks are you automating in your builds? Let me know—I’m always hunting for the next pipeline.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>llm</category>
      <category>showdev</category>
    </item>
    <item>
      <title>25 Months of Waiting, 12 Hours of Work</title>
      <dc:creator>Nic Lydon</dc:creator>
      <pubDate>Mon, 04 May 2026 19:02:59 +0000</pubDate>
      <link>https://forem.com/niclydon/25-months-of-waiting-12-hours-of-work-14ch</link>
      <guid>https://forem.com/niclydon/25-months-of-waiting-12-hours-of-work-14ch</guid>
      <description>&lt;p&gt;For two years, the ring whispered to me.&lt;/p&gt;

&lt;p&gt;Not literally. But every few weeks, an email would arrive. A Kickstarter update. A shipping delay. A manufacturing setback. A promise that it was almost ready. Each one a small reminder that somewhere in South Korea, a team was trying to fit a microphone, a Bluetooth radio, and an IMA ADPCM audio codec into a titanium band that fits on your finger.&lt;/p&gt;

&lt;p&gt;This is the story of the WIZPR Ring: how I found it, how I waited for it, and how I reverse-engineered its entire undocumented BLE protocol the night it arrived.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Whisper
&lt;/h2&gt;

&lt;p&gt;In February 2024, I put down a $5 deposit on a pre-launch page for something called the WHSP Ring. A voice-interaction wearable. Press a button on your finger, whisper a command, and an AI assistant on your phone processes it. The form factor was the thing that caught me. Not another watch, not another earbud. A ring.&lt;/p&gt;

&lt;p&gt;A month later, they renamed it. "WHSP RING is becoming WIZPR RING," the email said. The Kickstarter launched March 20th. I backed it March 21st.&lt;/p&gt;

&lt;p&gt;The campaign funded successfully. 1,084 backers, $163K raised. Surveys went out. I picked my size, my color, my shipping address.&lt;/p&gt;

&lt;p&gt;And then the waiting began.&lt;/p&gt;

&lt;h2&gt;
  
  
  43 Updates
&lt;/h2&gt;

&lt;p&gt;If you've ever backed hardware on Kickstarter, you know the arc. The early updates are optimistic. Tooling has begun. CNC machining looks great. The app is coming along.&lt;/p&gt;

&lt;p&gt;Then reality sets in.&lt;/p&gt;

&lt;p&gt;Update #10 (August 2024): "We regret to inform you that the promised delivery date has arrived, but we have not yet shipped your orders."&lt;/p&gt;

&lt;p&gt;Update #12 (September 2024): Antenna redesign required. Hardware changes.&lt;/p&gt;

&lt;p&gt;Update #15 (November 2024): "Important Update on Shipping Delays and Our Sincere Apology."&lt;/p&gt;

&lt;p&gt;Update #24 (August 2025): "Shipping was promised for Q3 2024, yet we are now almost a full year late."&lt;/p&gt;

&lt;p&gt;Update #36 (December 2025): "It breaks our hearts and fills us with a deep sense of regret to think that many of you supported our project with the hope of receiving the Wizpr Ring as a gift."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdo4104xo52liolgapmpp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdo4104xo52liolgapmpp.png" alt="Kickstarter Notifications" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Forty-three updates over twenty-five months. Antenna issues, titanium PVD coating problems, a charging pin redesign, a full manufacturing partner switch. Each email another whisper. Still here. Still coming. Not yet.&lt;/p&gt;

&lt;p&gt;I never asked for a refund. The ring had its hooks in me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saturday, 3:53 PM
&lt;/h2&gt;

&lt;p&gt;On May 2nd, 2026, my building's package notification system sent me an email: "A package for Nicholas has arrived to the package room."&lt;/p&gt;

&lt;p&gt;The carrier was YunTrack, with a last-mile handoff to GOFO. Twenty-five months and eleven days after I backed it on Kickstarter, the ring was in my hands.&lt;/p&gt;

&lt;p&gt;I charged the case. I paired it to my phone. I opened the official WIZPR app, pressed the button, spoke a command, and watched it work.&lt;/p&gt;

&lt;p&gt;And then I did what any reasonable person would do.&lt;/p&gt;

&lt;p&gt;I opened my laptop and started taking the ring apart, digitally.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Known
&lt;/h2&gt;

&lt;p&gt;The first thing I did was search. Someone, somewhere, had to have looked at this thing over BLE already.&lt;/p&gt;

&lt;p&gt;I found &lt;a href="https://github.com/R-D-BioTech-Alaska/Wizpr-Suite" rel="noopener noreferrer"&gt;R-D-BioTech-Alaska/Wizpr-Suite&lt;/a&gt; on GitHub. A small project that had done the genuinely hard first step: figuring out how to connect to the ring over BLE at all, building a GATT inspector, wiring up the &lt;code&gt;bleak&lt;/code&gt; Python library on macOS, and recognizing that the ring's protocol was completely undocumented. Their framing was clear: the path forward is user-controlled reverse engineering.&lt;/p&gt;

&lt;p&gt;I forked it, cloned it, and started reading.&lt;/p&gt;

&lt;p&gt;The ring uses a single BLE service with seven characteristics. Some notify. Some accept writes. None of them are documented by the manufacturer. The official iOS app connects, does its thing, and doesn't explain how. The repo had the scaffolding to connect and listen. What it didn't have was a map of what the ring was actually saying.&lt;/p&gt;

&lt;p&gt;That became the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overnight
&lt;/h2&gt;

&lt;p&gt;By 3:34 PM, I had my first commit: fixing dataclass decorators in the forked code so it would actually run. By 3:56 PM, the BLE scanner was filtering for WIZPR RING devices, connecting, and dumping GATT characteristics to the console.&lt;/p&gt;

&lt;p&gt;What I found was surprisingly clean. The ring speaks plain ASCII text on characteristic &lt;code&gt;00000007&lt;/code&gt;. Press the button, and it sends &lt;code&gt;CLICK&lt;/code&gt;. Raise your hand to your mouth, and it sends &lt;code&gt;MIC_PRE_ON&lt;/code&gt;, then &lt;code&gt;MIC_ON&lt;/code&gt;. Lower your hand, &lt;code&gt;MIC_OFF&lt;/code&gt;. Send it &lt;code&gt;BATTERY&lt;/code&gt; and it replies with the voltage. Send &lt;code&gt;GET_VERSION&lt;/code&gt; and it tells you its firmware. Four commands in, four commands out. No binary protocol, no handshake, no session negotiation. Connect, subscribe, listen.&lt;/p&gt;

&lt;p&gt;The audio was the interesting part. While the mic is on, characteristic &lt;code&gt;00000001&lt;/code&gt; streams a steady 35.4 packets per second, each one 224 bytes. The question was: what codec?&lt;/p&gt;

&lt;p&gt;I wrote a hypothesis tester. Captured a session of myself speaking, saved every packet timestamped in a JSON file, then ran the same data through every plausible decoder: Opus, mu-law, A-law, raw PCM at various rates, and IMA ADPCM at 8 kHz and 16 kHz. Most produced noise. One produced my voice.&lt;/p&gt;

&lt;p&gt;IMA ADPCM, 16 kHz, mono, continuous state across packets. 224 bytes per packet gives you 448 samples at 4-bit depth, which is exactly 28 milliseconds of audio per BLE notification. The key detail that cost me an hour: the decoder state has to carry across packets. Reset it per-notification and you get static. Keep it running and you get clean speech.&lt;/p&gt;

&lt;p&gt;By 10:30 PM, the audio codec was identified and documented. By midnight, I'd built a guided capture tool with a PySide6 UI, a standalone probing script for interactive characteristic exploration, and a ring daemon that holds a persistent BLE connection and accepts commands over a named pipe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2qgu08dc2yr3nxbbcar.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2qgu08dc2yr3nxbbcar.png" alt="Dark living room with laptop in foreground, TV on in background and dark grey cat nearby" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Between midnight and 4 AM, I ran a systematic probe campaign on the unmapped write-only characteristics. Four of them silently accept arbitrary data with no observable effect. One controls the ring's purple LED, but only indirectly: the LED fires on BLE connection and can't be triggered independently. The ring has no vibration motor. No haptic feedback channel. No way to signal the wearer from software.&lt;/p&gt;

&lt;p&gt;At 4:05 AM, I closed the probe campaign and wrote the documentation. The protocol was fully mapped.&lt;/p&gt;

&lt;p&gt;At 5:09 AM, I started a new repo. A native macOS menubar app in Swift, consuming the protocol I'd just reverse-engineered. Hand-rolled IMA ADPCM decoder (Apple's built-in AudioToolbox does ADPCM, but it expects Apple's variant with 34-byte frames, not the ring's 224-byte continuous stream). By mid-morning, the Mac app had a working BLE client with auto-reconnect, tested ADPCM decoder, and an audio pipeline design spec.&lt;/p&gt;

&lt;p&gt;I went to a two-year-old's Cars-themed birthday party that afternoon. There were checkered flags and Lightning McQueen balloons. I sang happy birthday. I had not slept.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh08op9jwpoip75j2dqzf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh08op9jwpoip75j2dqzf.png" alt="POV - Sitting at table eating cake at Lighning McQueen themed birthday" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Ring Actually Is
&lt;/h2&gt;

&lt;p&gt;Here's the complete protocol, because someone searching for this in eighteen months deserves to find it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ring → Phone (notifications on char 00000007)
CLICK           button pressed
MIC_PRE_ON      raise-to-speak gesture detected
MIC_ON          mic active, audio streaming on char 00000001
MIC_OFF         mic deactivated
BATTERY N(V)    battery voltage response
VER XXXX        firmware version response

Phone → Ring (writes to char 00000007)
LOCK            disable ring input (hard mute)
BATTERY         query battery level
GET_VERSION     query firmware version
RESET           reboot ring (kills BLE connection)

Audio (char 00000001, while MIC_ON)
Codec:    IMA ADPCM, 4-bit, 16 kHz mono
Frame:    224 bytes = 448 samples = 28 ms
Rate:     ~35.4 packets/second
State:    continuous across packets (do NOT reset per notification)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No pairing required. No authentication. No session handshake. If you can see it, you can talk to it.&lt;/p&gt;

&lt;p&gt;The ring accepts exactly one BLE connection at a time. If your iPhone has the WIZPR app running, the ring is connected to it and won't advertise. Disconnect the phone first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;The WIZPR Ring ships with an app that routes your voice through their cloud for processing. That's fine. It works. But the ring itself is just a microphone and a button on your finger with a BLE radio. There's no reason the audio has to go through their servers.&lt;/p&gt;

&lt;p&gt;With the protocol mapped, the ring becomes a general-purpose voice input device. A tactile, always-on-your-hand trigger for anything that can listen to a BLE characteristic and decode ADPCM audio. For me, that means feeding it into my own local AI stack. For someone else, it might mean accessibility tooling, or voice-triggered home automation, or a wearable dictation device that never touches the cloud.&lt;/p&gt;

&lt;p&gt;The official app is one client. Now anyone can write another.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Repo
&lt;/h2&gt;

&lt;p&gt;Everything is at &lt;a href="https://github.com/niclydon/wizpr-tools" rel="noopener noreferrer"&gt;niclydon/wizpr-tools&lt;/a&gt;. The protocol reference, the audio codec documentation, the capture tool, the probing scripts, and a 50-line quickstart that connects to the ring and records a WAV file.&lt;/p&gt;

&lt;p&gt;It wouldn't exist without the upstream work from &lt;a href="https://github.com/R-D-BioTech-Alaska/Wizpr-Suite" rel="noopener noreferrer"&gt;R-D-BioTech-Alaska/Wizpr-Suite&lt;/a&gt;. They did the hard part. I mapped what they found.&lt;/p&gt;




&lt;p&gt;The ring is on my desk right now, sitting on its charging cradle, its purple LED dark. It's not connected to anything. But I know it's listening for a connection, cycling through its advertisement packets every few seconds, waiting for someone to subscribe.&lt;/p&gt;

&lt;p&gt;It waited twenty-five months to reach me. I couldn't put it down.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>iot</category>
      <category>wearables</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
