<?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>DEV Community: Donald Cruver</title>
    <description>The latest articles on DEV Community by Donald Cruver (@dcruver).</description>
    <link>https://dev.to/dcruver</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%2F3743028%2F1a0b4824-e43b-40cc-a2ef-c1b72faf4bdf.png</url>
      <title>DEV Community: Donald Cruver</title>
      <link>https://dev.to/dcruver</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dcruver"/>
    <language>en</language>
    <item>
      <title>The FCC Just Validated What Homelabbers Have Known for Years</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Tue, 24 Mar 2026 00:38:06 +0000</pubDate>
      <link>https://dev.to/dcruver/the-fcc-just-validated-what-homelabbers-have-known-for-years-3gn5</link>
      <guid>https://dev.to/dcruver/the-fcc-just-validated-what-homelabbers-have-known-for-years-3gn5</guid>
      <description>&lt;p&gt;The FCC this week designated all consumer routers manufactured outside the US as a national security risk, banning new foreign-made models from sale. The rule catches not just TP-Link but Eero, NetGear, and Google Nest too, since they all manufacture overseas.&lt;/p&gt;

&lt;p&gt;The move has a concrete backstory. Three Chinese nation-state groups, Volt Typhoon, Flax Typhoon, and Salt Typhoon, used compromised SOHO routers to build botnets targeting US critical infrastructure over several years. The FBI and DOJ shut down one of these botnets in 2024. The FCC's National Security Determination names all three operations explicitly as justification for the ban.&lt;/p&gt;

&lt;p&gt;I've been running OPNsense on a fanless mini-PC as my home router since 2022. It runs on an Intel N5105 with dual NICs, 8GB RAM, and a 250GB NVMe drive. The hardware cost about $200. The software is open source and BSD-licensed. There is no cloud account in the chain, no manufacturer firmware to trust or distrust, and no update process I didn't initiate. It routes packets and does exactly what the configuration specifies.&lt;/p&gt;

&lt;p&gt;The FCC's concern is about foreign firmware as an attack surface for nation-state actors. My concern in 2022 was simpler: I wanted to know what was on my own network without a consumer device deciding that for me. The concerns are identical, and so is the fix.&lt;/p&gt;

&lt;p&gt;Full writeup: &lt;a href="https://hullabalooing.cruver.ai/homelab/posts/it-turns-out-my-router-was-a-national-security-decision-1774311445/" rel="noopener noreferrer"&gt;It Turns Out My Router Was a National Security Decision&lt;/a&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>networking</category>
      <category>opnsense</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Three Production Apps, Zero Code: What Keip Actually Looks Like in Practice</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Fri, 13 Mar 2026 21:43:43 +0000</pubDate>
      <link>https://dev.to/dcruver/three-production-apps-zero-code-what-keip-actually-looks-like-in-practice-3iic</link>
      <guid>https://dev.to/dcruver/three-production-apps-zero-code-what-keip-actually-looks-like-in-practice-3iic</guid>
      <description>&lt;h2&gt;
  
  
  Three Production Apps, Zero Code: What Keip Actually Looks Like in Practice
&lt;/h2&gt;

&lt;p&gt;In the past month I shipped three integration apps to my home cluster: a Spanish translator that replies in audio, a health tracking bot I can query from any room in my house, and a camera alert system that decides for itself what's worth telling me about. Each one took under an hour to have running. None of them required me to write a single line of application code.&lt;/p&gt;

&lt;p&gt;That last part is the actual story. The apps are configuration, end to end. There is no glue script. An AI assistant produced most of that configuration in the time it would have taken me to scaffold a project and write the first class.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Recap
&lt;/h2&gt;

&lt;p&gt;I wrote about &lt;a href="https://hullabalooing.cruver.ai/writing/posts/keip-eip-ai-kubernetes" rel="noopener noreferrer"&gt;Keip and Enterprise Integration Patterns&lt;/a&gt; a few weeks ago. The short version: Keip is an open source platform built on &lt;a href="https://spring.io/projects/spring-integration" rel="noopener noreferrer"&gt;Spring Integration&lt;/a&gt; that turns &lt;a href="https://www.enterpriseintegrationpatterns.com/" rel="noopener noreferrer"&gt;Enterprise Integration Patterns&lt;/a&gt; into Kubernetes resources. Instead of writing a Spring Boot application to wire services together, I write an XML route config and Keip deploys it as a pod.&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keip.codice.org/v1alpha2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IntegrationRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;my-route&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keip&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;routeConfigMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-route-xml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route config lives in a ConfigMap. There is no Dockerfile, no application entrypoint, and no build step. Keip handles all of that.&lt;/p&gt;

&lt;p&gt;Installing Keip on an existing cluster is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/codice/keip/releases/latest/download/install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That installs the operator, CRDs, and controller. From there, deploying a route is just a &lt;code&gt;kubectl apply&lt;/code&gt; on a ConfigMap and an &lt;code&gt;IntegrationRoute&lt;/code&gt; resource.&lt;/p&gt;

&lt;p&gt;What I've been building on top of this is keip-connect, a library of protocol adapters that connect integration routes to the services I actually use: Matrix chat, ntfy push notifications, and an Anthropic Claude chat model. Each adapter follows the same Spring Integration channel adapter pattern: an inbound adapter that produces messages onto a channel, or an outbound gateway that sends them somewhere and optionally waits for a reply. (keip-connect is not yet publicly released.)&lt;/p&gt;

&lt;p&gt;With those pieces in place, here's what I built.&lt;/p&gt;

&lt;h2&gt;
  
  
  App 1: Personal Translator App
&lt;/h2&gt;

&lt;p&gt;My wife and I are visiting Mexico soon. My Spanish is nearly-nonexistent, and I wanted something faster than stopping to open a separate app. I also wanted it to respond in audio when translating to Spanish, so I could hear how things should sound rather than just reading them.&lt;/p&gt;

&lt;p&gt;The obvious alternative is Google Translate. The reason I didn't use it is the same one that runs through everything else in this stack: I don't want my conversations in someone else's logs. A translation request is a conversation fragment, and sending it to a third-party API means it leaves my network. The local model is fast and the text stays on my hardware.&lt;/p&gt;

&lt;p&gt;There's a practical reason too. Matrix is already the interface I use for everything: talking to my AI assistant, checking health data, getting camera alerts. The translator is just another room in the same app I already have open. There is no separate tool to install and no context switching.&lt;/p&gt;

&lt;p&gt;The route listens on a private Matrix room. Messages arrive as text or voice. It detects the language, translates in the appropriate direction, and when the output is Spanish, generates a voice reply using a local TTS model running on my own hardware. English output stays as text.&lt;/p&gt;

&lt;p&gt;The XML that does all of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;beans&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/beans"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:int=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/integration"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:int-http=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/integration/http"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:matrix=&lt;/span&gt;&lt;span class="s"&gt;"http://cruver.ai/schema/keip-connect/matrix"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Receive messages from the translation room --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;matrix:inbound-channel-adapter&lt;/span&gt;
      &lt;span class="na"&gt;client-factory-ref=&lt;/span&gt;&lt;span class="s"&gt;"matrixClient"&lt;/span&gt;
      &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"rawInput"&lt;/span&gt;
      &lt;span class="na"&gt;room-ids=&lt;/span&gt;&lt;span class="s"&gt;"!MpzjvTEceOoscPvapJ:matrix.cruver.network"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Audio: transcribe via Whisper before translating --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:filter&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"rawInput"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"audioInput"&lt;/span&gt;
      &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"headers['matrix_content_type'] == 'm.audio'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"audioInput"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"textInput"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${whisper.url}/v1/audio/transcriptions"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"java.lang.String"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Text input arrives directly --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:channel&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"textInput"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Call LLM: detect language and translate; returns JSON
       {"direction":"en→es|es→en","translation":"..."} --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:header-enricher&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"textInput"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"llmCall"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;int:header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/int:header-enricher&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"llmCall"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"llmResponse"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${llm.url}/v1/chat/completions"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"java.lang.String"&lt;/span&gt;
      &lt;span class="na"&gt;mapped-request-headers=&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Parse direction from LLM response --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:router&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"llmResponse"&lt;/span&gt;
      &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"new com.fasterxml.jackson.databind.ObjectMapper()
                      .readTree(payload).get('direction').asText()
                      .startsWith('en') ? 'toSpanishAudio' : 'textReply'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- ES→EN: return translated text directly --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:channel&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"textReply"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;matrix:outbound-gateway&lt;/span&gt; &lt;span class="na"&gt;client-factory-ref=&lt;/span&gt;&lt;span class="s"&gt;"matrixClient"&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"textReply"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- EN→ES: call XTTS for audio, then send WAV to room --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:channel&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"toSpanishAudio"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"toSpanishAudio"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"audioReply"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${xtts.url}/v1/audio/speech"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"byte[]"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;matrix:outbound-gateway&lt;/span&gt; &lt;span class="na"&gt;client-factory-ref=&lt;/span&gt;&lt;span class="s"&gt;"matrixClient"&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"audioReply"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/beans&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The services it talks to, Whisper for transcription, a local LLM for translation, and XTTS for speech, all run on my own hardware. Nothing leaves my network. The route is the plumbing, and the services are the intelligence.&lt;/p&gt;

&lt;p&gt;From "I want a translator bot" to a working app in the Matrix room took about 45 minutes. Most of that was getting the Matrix room configured and E2E encryption keys sorted. The route itself was maybe 15 minutes, most of which was an AI assistant drafting the XML while I reviewed it.&lt;/p&gt;

&lt;p&gt;The translator isn't the point. It's just one thing the pattern makes easy. The next two examples are completely different problems, and the approach is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  App 2: Health Tracker
&lt;/h2&gt;

&lt;p&gt;I track glucose, ketones, weight, and blood pressure, among other things, in InfluxDB. The data is useful, but querying it has always required either a Grafana dashboard or writing a Flux query by hand. I wanted to ask questions in plain language and get answers.&lt;/p&gt;

&lt;p&gt;The route listens on a dedicated Matrix room and forwards messages to a local LLM with context about what data is available. The LLM generates a Flux query, the route executes it against InfluxDB, and the result comes back as a natural language summary.&lt;/p&gt;

&lt;p&gt;I can send messages like "How have my ketones been this week?" or "What was my average glucose yesterday?" and get a direct answer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;beans&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/beans"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:int=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/integration"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:int-http=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/integration/http"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:matrix=&lt;/span&gt;&lt;span class="s"&gt;"http://cruver.ai/schema/keip-connect/matrix"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;matrix:inbound-channel-adapter&lt;/span&gt;
      &lt;span class="na"&gt;client-factory-ref=&lt;/span&gt;&lt;span class="s"&gt;"matrixClient"&lt;/span&gt;
      &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"healthInput"&lt;/span&gt;
      &lt;span class="na"&gt;room-ids=&lt;/span&gt;&lt;span class="s"&gt;"!yZRhRvDISqwNnBokVM:matrix.cruver.network"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Route: slash commands log data; plain questions query it --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:router&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"healthInput"&lt;/span&gt;
      &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"payload.startsWith('/') ? 'logChannel' : 'queryChannel'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Log path: LLM converts slash command to InfluxDB line protocol --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:header-enricher&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"logChannel"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"llmLogCall"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;int:header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/int:header-enricher&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"llmLogCall"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"lineProtocol"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${llm.url}/v1/chat/completions"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"java.lang.String"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Write line protocol to InfluxDB --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:header-enricher&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"lineProtocol"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"influxWrite"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;int:header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt; &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"'Token ' + '${influx.token}'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/int:header-enricher&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-channel-adapter&lt;/span&gt;
      &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"influxWrite"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${influx.url}/api/v2/write?org=${influx.org}&amp;amp;amp;bucket=${influx.bucket}"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;mapped-request-headers=&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Query path: fetch last 7 days from InfluxDB, summarize with LLM --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:header-enricher&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"queryChannel"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"influxQuery"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;int:header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt; &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"'Token ' + '${influx.token}'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/int:header-enricher&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"influxQuery"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"rawData"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${influx.url}/api/v2/query?org=${influx.org}"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"java.lang.String"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- LLM summarizes raw CSV into plain language --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"rawData"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"replies"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${llm.url}/v1/chat/completions"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"java.lang.String"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;matrix:outbound-gateway&lt;/span&gt; &lt;span class="na"&gt;client-factory-ref=&lt;/span&gt;&lt;span class="s"&gt;"matrixClient"&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"replies"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/beans&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one took about 40 minutes end to end. The hardest part was writing a clear system prompt that reliably produces valid Flux syntax. The route itself was straightforward, and again, AI drafted it.&lt;/p&gt;

&lt;h2&gt;
  
  
  App 3: Intelligent Camera Alerts
&lt;/h2&gt;

&lt;p&gt;I have six cameras running through Frigate, my local NVR. Frigate does motion detection and object recognition with a Google Coral TPU. What it doesn't do is decide whether a detection is actually interesting.&lt;/p&gt;

&lt;p&gt;A car parked in the street shouldn't page me. A delivery truck at the door should. A person I don't recognize in the backyard at 2 AM definitely should. Making that distinction requires judgment, and that's what the integration layer handles.&lt;/p&gt;

&lt;p&gt;The route subscribes to Frigate's MQTT event stream. When a detection comes in, it grabs the camera snapshot, sends it to a local vision model for analysis, and acts on the verdict. Routine activity is dropped silently. Anything worth knowing about triggers a push notification through ntfy with a description of what the model saw.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;beans&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/beans"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:int=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/integration"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:int-mqtt=&lt;/span&gt;&lt;span class="s"&gt;"http://www.springframework.org/schema/integration/mqtt"&lt;/span&gt;
       &lt;span class="na"&gt;xmlns:ntfy=&lt;/span&gt;&lt;span class="s"&gt;"http://cruver.ai/schema/keip-connect/ntfy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Subscribe to Frigate detection events --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-mqtt:message-driven-channel-adapter&lt;/span&gt;
      &lt;span class="na"&gt;client-ref=&lt;/span&gt;&lt;span class="s"&gt;"mqttClient"&lt;/span&gt;
      &lt;span class="na"&gt;topics=&lt;/span&gt;&lt;span class="s"&gt;"frigate/events"&lt;/span&gt;
      &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"rawEvents"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Filter to high-confidence detections only --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:filter&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"rawEvents"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"significantEvents"&lt;/span&gt;
      &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"payload.score &amp;gt;= 0.65"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Fetch camera snapshot from Frigate API --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"significantEvents"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"withSnapshot"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${frigate.url}/api/{camera}/latest.jpg?h=720"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"GET"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"byte[]"&lt;/span&gt;
      &lt;span class="na"&gt;uri-variables-expression=&lt;/span&gt;&lt;span class="s"&gt;"headers"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Ask vision model: is this worth reporting?
       Returns JSON {"verdict":"ALERT|ROUTINE","description":"..."} --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:header-enricher&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"withSnapshot"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"visionCall"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;int:header&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/int:header-enricher&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int-http:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"visionCall"&lt;/span&gt; &lt;span class="na"&gt;reply-channel=&lt;/span&gt;&lt;span class="s"&gt;"analyzed"&lt;/span&gt;
      &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${vision.url}/v1/chat/completions"&lt;/span&gt;
      &lt;span class="na"&gt;http-method=&lt;/span&gt;&lt;span class="s"&gt;"POST"&lt;/span&gt; &lt;span class="na"&gt;expected-response-type=&lt;/span&gt;&lt;span class="s"&gt;"java.lang.String"&lt;/span&gt;
      &lt;span class="na"&gt;mapped-request-headers=&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Filter out ROUTINE judgments --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;int:filter&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"analyzed"&lt;/span&gt; &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"alerts"&lt;/span&gt;
      &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"payload.verdict == 'ALERT'"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- Send push notification --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ntfy:outbound-gateway&lt;/span&gt;
      &lt;span class="na"&gt;client-factory-ref=&lt;/span&gt;&lt;span class="s"&gt;"ntfyClient"&lt;/span&gt;
      &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"alerts"&lt;/span&gt;
      &lt;span class="na"&gt;default-topic=&lt;/span&gt;&lt;span class="s"&gt;"camera-analysis"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/beans&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vision model runs locally on the same GPU stack as everything else. The push notification goes to my phone via a self-hosted ntfy server. The route has no idea what's in the images; it moves them to the right place and acts on the verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means in Practice
&lt;/h2&gt;

&lt;p&gt;Each of these apps has real logic: language detection, LLM prompting, vision model inference, database queries. None of that logic lives in the route. It lives in the services the route calls: a Whisper server, a local LLM, a vision model endpoint, an InfluxDB instance. The integration layer connects those services to each other and to the world, deciding what triggers what, what data flows where, and what happens when something fails.&lt;/p&gt;

&lt;p&gt;The routes are readable. Looking at any of those XML configs, the structure is clear in thirty seconds, because the primitives are named for what they do: filter, transform, route, split, aggregate. There's no application framework to understand, no dependency injection container to trace through, no build system to run.&lt;/p&gt;

&lt;p&gt;Every service those routes call runs on my hardware. The translations, the health queries, the camera analysis; none of it touches an external API. When the internet goes down, the translator still works. When a provider changes its terms or pricing, nothing breaks. That's the practical side of building on infrastructure I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flywheel Effect
&lt;/h2&gt;

&lt;p&gt;All of this has produced a flywheel effect. Camera alerts go to ntfy, and that's useful by itself. But ntfy topics are just another event source, and any other route can subscribe to the same topics and build on them. It would be trivial to create a route that logs alert frequency to InfluxDB, one that aggregates overnight detections into a morning digest delivered to Matrix, or one that silences notifications during a window set from a Matrix message. None of those exist yet, but every piece needed to build them is already running. Adding them is just a matter of creating the IntegrationRoutes in k8s.&lt;/p&gt;

&lt;p&gt;The same pattern holds across all three apps. The health tracker produces InfluxDB data. The translator produces Matrix messages. The camera watcher produces ntfy events. Each output is something another route can consume. The routes compound on each other.&lt;/p&gt;

&lt;p&gt;What this means in practice is that the ceiling is not complexity or integration friction, it's compute. Adding a new route costs nothing in subscription fees, nothing in API quotas, and nothing in terms of data sovereignty. On a cluster with local GPU inference, the only real question becomes whether I want the thing, not whether building it is worth the overhead. And the more I build, the easier it gets to add something new. The Matrix client, the LLM endpoint, and the ntfy connection are already running. A new route potentially inherits all of it.&lt;/p&gt;

&lt;p&gt;The next step is making the routes themselves even easier to create. That's a topic for a future post.&lt;/p&gt;

</description>
      <category>keip</category>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Data, My Stack</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Tue, 10 Mar 2026 04:22:31 +0000</pubDate>
      <link>https://dev.to/dcruver/my-data-my-stack-f9f</link>
      <guid>https://dev.to/dcruver/my-data-my-stack-f9f</guid>
      <description>&lt;p&gt;I want to introduce a concept I'll be coming back to throughout this blog: Personal Data Sovereignty, or PDS. It's the idea that individuals can and should have meaningful control over where their data lives, who can access it, and what happens to it. Not as a legal right, as something you actually build.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've been reading this blog for a while, some of what follows will look familiar. I've written about the network setup, the GPU server, the second brain, the AI inference stack. This post isn't about new technical ground, it's more philosophical. PDS is the concept I've been building toward without naming it directly, and I wanted to put it in one place before the series goes any further.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Personal Data Sovereignty
&lt;/h1&gt;

&lt;p&gt;PDS isn't a legal framework, or a political position. It's a simple idea: that individuals can and arguably should have meaningful control over where their data lives, how it's processed, and who can access it.&lt;/p&gt;

&lt;p&gt;The challenge is that "data sovereignty" usually stops at the level of rights and policy. You have the &lt;em&gt;right&lt;/em&gt; to your data, but a right you can only exercise by asking nicely isn't sovereignty. If your data lives on someone else's servers, runs through someone else's software, and is governed by someone else's terms, then their decisions are your reality. A privacy policy doesn't change that. At best it creates a new chore: find time to read it, parse the legal language, figure out what it actually permits, and trust that they'll follow it. Most people don't, which is a rational response to being handed a 5-page document before they can use a service. That's not sovereignty, i's asking permission, and then doing homework about it.&lt;/p&gt;

&lt;p&gt;There's a common observation about the internet: &lt;em&gt;if you aren't paying for the product, you are the product.&lt;/em&gt; It's a useful heuristic for understanding ad-supported services, search engines, social media, free email. Your attention and your data are what's being sold.&lt;/p&gt;

&lt;p&gt;But the real principle isn't about who's paying, it's about who's in control. If the infrastructure belongs to someone else, then the rules belong to someone else. Here are some examples of what I mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Amazon Ring had a webpage where law enforcement could fill out a form, claim a life-threatening emergency, and access your footage without your consent, a court order, or a warrant. Ring customers paid for their hardware. They paid a subscription for cloud storage. They were, by any normal definition, paying for the product. It didn't matter. The footage still flowed to law enforcement on request. Amazon has since updated their policy to require warrants in most cases, but an emergency exception remains, and the infrastructure that made warrantless access possible in the first place hasn't changed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Flock Safety runs automated license plate reader cameras mounted on street lights, utility poles, HOA entrances, apartment complexes, and private businesses across the country. Their network aggregates vehicle movement data into a shared system where any subscribed party gets alerts when a tracked vehicle is spotted. Law enforcement, cities, and private organizations are all customers. You don't opt into it. You don't know which intersections, driveways, or parking lots have cameras. Your movements, when you leave, when you come home, where you go, are being catalogued and made available to whoever has a subscription.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The surveillance angle isn't the only one. In April 2025, Google announced it was ending support for first and second generation Nest Learning Thermostats, effective October 25, 2025. The thermostats hadn't broken. The hardware hadn't changed. Google had simply decided they were done with them, and because the devices depended on Google's cloud to function, that decision was theirs to make. Backlash was significant enough that Google offered compensation toward a replacement, but the end of support went ahead as planned. There is now a class action arbitration being organized against them. The device was in your home. The kill switch belonged to someone else.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Nest situation isn't unique, any cloud-dependent device carries the same risk. But for most categories of home automation, there are alternatives. Home Assistant runs locally and supports thousands of devices without any vendor cloud. Thermostats like the ecobee or Z-wave units can be controlled entirely on your own network. Cameras can run Frigate, which does all its processing on your hardware. There's usually a community-maintained solution, and where there isn't, there's often a commercial option that can be isolated on a separate VLAN and prevented from phoning home. The ecosystem isn't perfect, but it's far more capable than it was five years ago.&lt;/p&gt;

&lt;p&gt;These are examples, not the whole story. Your search history, your email, your files, your health data, all of it is subject to the same dynamic. Every service you don't control is a service that can change its terms, respond to a subpoena, get acquired, or decide that your data is useful for something you didn't anticipate.&lt;/p&gt;

&lt;p&gt;What actually gets you to PDS is owning the infrastructure. Not necessarily the same infrastructure I own, but infrastructure you control. &lt;/p&gt;

&lt;h1&gt;
  
  
  A Note on Isolation
&lt;/h1&gt;

&lt;p&gt;Complete isolation isn't the goal, and it wouldn't be very useful if it were. The internet is built on connecting to other systems. Email crosses networks by design. Search requires access to an index that would be expensive and difficult maintain yourself.&lt;/p&gt;

&lt;p&gt;What matters is &lt;em&gt;how&lt;/em&gt; those connections happen. SearXNG still reaches out to Google, but the query is anonymized. Google sees a request, not a person. Proton handles email that travels across the open internet, but end-to-end encryption means the content stays private. I'm still in the middle of moving my email there from Gmail, which is exactly how these transitions work: gradually, service by service.&lt;/p&gt;

&lt;p&gt;The goal isn't a sealed box, it's external connections that respect your privacy rather than exploit it.&lt;/p&gt;

&lt;h1&gt;
  
  
  What I Actually Run
&lt;/h1&gt;

&lt;p&gt;Before getting into the deep dives, I want to give a plain description of what I'm actually running and why each layer is there.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Network:&lt;/em&gt; An OPNsense firewall running on a fanless mini-PC, with VLANs for network segmentation and internal DNS so every service gets a proper hostname instead of an IP address. I covered this in more detail &lt;a href="https://hullabalooing.cruver.ai/homelab/posts/data-sovereignty-and-my-network-router" rel="noopener noreferrer"&gt;in a separate post&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Compute:&lt;/em&gt; A Proxmox hypervisor running LXC containers and VMs for most services, and a separate workstation with a pair of AMD MI60 GPUs for running local AI workloads. The MI60s have 32GB of HBM2 memory each, which is enough to run larger language models comfortably.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Storage:&lt;/em&gt; ZFS across the storage nodes, which gives me snapshots, replication, and data integrity checksums. I've had drives fail without losing data, which is the point.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI:&lt;/em&gt; I run my own LLM inference server using vLLM, and a local AI-powered search tool called Perplexica. Perplexica uses my own models and runs entirely on my hardware, so queries don't leave my network. I've also been running Nabu, an AI assistant built on top of this stack, which is a topic for its own post.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Services:&lt;/em&gt; Home Assistant for home automation, Frigate for local camera recording, Syncthing for file sync across devices, n8n for workflow automation, and an org-roam knowledge base that I use as a second brain. These are things I use every day, and I'd be reaching for them even if PDS wasn't a consideration.&lt;/p&gt;

&lt;h1&gt;
  
  
  The Route I Chose
&lt;/h1&gt;

&lt;p&gt;I want to be clear that my hardware choices aren't the point.&lt;/p&gt;

&lt;p&gt;MI60 GPUs are not the only way to run local AI. Proxmox is not the only hypervisor. OPNsense is not the only firewall. I landed on these things through a combination of research, opportunity, and the particular shape of my needs. Someone else pursuing PDS might do it on a Raspberry Pi cluster, or a single NUC, or a secondhand server from eBay. The principle scales. The hardware doesn't have to match.&lt;/p&gt;

&lt;p&gt;What I'm documenting here is my Sovereign Stack. The decisions that led to it, the trade-offs I made, and what it actually looks like to maintain it. Take what's useful. Ignore what isn't.&lt;/p&gt;

&lt;h1&gt;
  
  
  What It Costs
&lt;/h1&gt;

&lt;p&gt;The main cost is time. Most of this runs without much attention, but things break occasionally and it's on me to fix them. There's a real learning curve early on that takes a while to get through, and it never fully disappears.&lt;/p&gt;

&lt;p&gt;The other cost is hardware. I've spent real money building this out over several years, a mix of new and used equipment. The argument that self-hosting pays for itself in avoided subscriptions is true over the (very) long run, but it requires upfront investment that not everyone can make.&lt;/p&gt;

&lt;h1&gt;
  
  
  What You Get Back
&lt;/h1&gt;

&lt;p&gt;I know where my data is, and what software is processing it. When I search for something using Perplexica, the query goes to my hardware and nowhere else. That's the practical side of PDS, and it's not abstract.&lt;/p&gt;

&lt;p&gt;The less obvious benefit is that running your own infrastructure teaches you things. I understand networking better because I had to configure it. I understand LLM inference better because I had to get it working. That knowledge builds up over time in a way that just using a service doesn't.&lt;/p&gt;

&lt;h1&gt;
  
  
  What's Coming
&lt;/h1&gt;

&lt;p&gt;This post is the overview. The deep dives are coming, one layer at a time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;The network layer&lt;/em&gt; -- OPNsense, VLANs, internal DNS, and how I expose services safely (already published)&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Compute&lt;/em&gt; -- Proxmox, the GPU server, and why bare metal still matters&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Storage&lt;/em&gt; -- ZFS and what it means to actually trust your storage&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;AI&lt;/em&gt; -- Local inference, Perplexica, and what PDS looks like when your assistant doesn't phone home&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Services&lt;/em&gt; -- The glue layer: everything I run and why&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each post stands alone. Read them in order or jump to whatever layer interests you. The goal isn't to convince you to replicate my stack. It's to show you what's possible when you start treating your infrastructure as something you own.&lt;/p&gt;

&lt;p&gt;The stack will also keep changing. Hardware gets replaced, better software comes along, requirements shift. I'll document that as it happens.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>privacy</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Pushed Local LLMs Harder. Here's What Two Models Actually Did.</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Mon, 02 Mar 2026 21:51:26 +0000</pubDate>
      <link>https://dev.to/dcruver/i-pushed-local-llms-harder-heres-what-two-models-actually-did-3dlp</link>
      <guid>https://dev.to/dcruver/i-pushed-local-llms-harder-heres-what-two-models-actually-did-3dlp</guid>
      <description>&lt;p&gt;In Part 1 of this series, I set up Claude Code against local LLMs on dual MI60 GPUs and watched it scaffold a Flask application from scratch. Small tasks worked. Complex ones did not. I ended with three ideas I wanted to test: running a dense model, trying Claude Code's agent teams feature, and building a persistent memory layer for coding sessions.&lt;/p&gt;

&lt;p&gt;I started the experiment that mattered most: giving a local LLM a project with real scope and seeing what happened. I ran the same project against two different models. The results were instructive, and not in the direction I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Project
&lt;/h2&gt;

&lt;p&gt;The model's goal was to build a Python CLI tool called health-correlate. It would connect to my InfluxDB health database, retrieve time-series data for metrics like glucose readings, blood ketone levels, blood pressure, body weight, and subjective wellbeing scores, resample everything to daily aggregates, and run Pearson correlation analysis with configurable time-lag support. The output: a ranked table of correlations with p-values. A weather bucket fed from a Weatherflow sensor extended the scope further. The tool could also correlate outdoor conditions against health metrics.&lt;/p&gt;

&lt;p&gt;That scope requires several interdependent modules: a data layer for InfluxDB Flux queries, a statistics layer for the correlation math, a CLI layer with multiple subcommands, and a visualization layer. Enough moving parts that the model would need to maintain architectural coherence across files and across iterations.&lt;/p&gt;

&lt;p&gt;I structured each session using the subagent pattern. Three Claude Code subagent prompt files handled the phases in sequence: data layer first, then the correlation engine, then the CLI and visualization. Each subagent prompt included a mandatory test-fix loop: write, run, fail, fix, repeat until tests pass before declaring the phase done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt
&lt;/h2&gt;

&lt;p&gt;The top-level prompt I gave Claude Code was short. Its job was to delegate work to the subagents, not to do it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Build health-correlate using Subagents&lt;/span&gt;

Build a Python CLI tool that finds correlations between health metrics in InfluxDB.

&lt;span class="gu"&gt;## Strategy&lt;/span&gt;
Use subagents to keep each phase in its own context window:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**Phase 1**&lt;/span&gt;: Delegate to @phase1-influx to build the InfluxDB data layer
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Phase 2**&lt;/span&gt;: Delegate to @phase2-correlation to add statistical analysis
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Phase 3**&lt;/span&gt;: Delegate to @phase3-cli to create CLI and visualization

&lt;span class="gu"&gt;## Critical Requirement: Test-Fix Loop&lt;/span&gt;

Each subagent MUST:
&lt;span class="p"&gt;1.&lt;/span&gt; Write tests for their code
&lt;span class="p"&gt;2.&lt;/span&gt; Run tests after each function/module
&lt;span class="p"&gt;3.&lt;/span&gt; If tests fail → debug and fix → re-test
&lt;span class="p"&gt;4.&lt;/span&gt; Only report "Done" when ALL tests pass

Do NOT accept a phase as complete if tests are failing.

&lt;span class="gu"&gt;## Coordination&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Each subagent writes/updates PROGRESS.md when finished
&lt;span class="p"&gt;-&lt;/span&gt; Wait for each phase to complete before starting the next
&lt;span class="p"&gt;-&lt;/span&gt; Verify tests pass before proceeding: &lt;span class="sb"&gt;`python -m pytest tests/ -v`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Do not implement phases yourself - delegate to the subagents

&lt;span class="gu"&gt;## Environment&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; INFLUXDB_TOKEN is set in the environment
&lt;span class="p"&gt;-&lt;/span&gt; InfluxDB at influxdb.cruver.network:30086
&lt;span class="p"&gt;-&lt;/span&gt; Virtual environment at ./venv (activate with &lt;span class="sb"&gt;`source venv/bin/activate`&lt;/span&gt;)

&lt;span class="gu"&gt;## Begin&lt;/span&gt;
Start by delegating Phase 1 to @phase1-influx.
After it completes, verify tests pass, then delegate Phase 2.
After Phase 2 completes, verify tests pass, then delegate Phase 3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each subagent lived in &lt;code&gt;.claude/agents/&lt;/code&gt; as a markdown file. The files defined the tools available to the subagent, the specific deliverables, and critically, the test-fix loop requirement. The Phase 1 subagent prompt looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&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;phase1-influx&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Build&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;InfluxDB&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;layer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health-correlate."&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Write, Edit, Bash, Glob, Grep&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Phase 1: InfluxDB Data Layer&lt;/span&gt;

...

&lt;span class="gu"&gt;## Test-Fix Loop (MANDATORY)&lt;/span&gt;

After writing each function:
&lt;span class="p"&gt;1.&lt;/span&gt; Create a test in tests/test_influx.py
&lt;span class="p"&gt;2.&lt;/span&gt; Run: &lt;span class="sb"&gt;`python -m pytest tests/test_influx.py -v`&lt;/span&gt;
&lt;span class="p"&gt;3.&lt;/span&gt; If test fails:
&lt;span class="p"&gt;   -&lt;/span&gt; Read the error message carefully
&lt;span class="p"&gt;   -&lt;/span&gt; Fix the code
&lt;span class="p"&gt;   -&lt;/span&gt; Re-run the test
&lt;span class="p"&gt;   -&lt;/span&gt; Repeat until ALL tests pass
&lt;span class="p"&gt;4.&lt;/span&gt; Do NOT proceed to the next function until current tests pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern repeats across all three phases. The subagent does not consider its work done until tests pass. This is the mechanism that produced the automated bug-fixing: the model was not doing anything clever; it was following the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model One: Qwen3-Coder-Next 80B
&lt;/h2&gt;

&lt;p&gt;The first model was Qwen3-Coder-Next: 80 billion total parameters, 3 billion active per forward pass, 512 experts with 10 activated per token. I ran an AWQ-quantized version under vLLM across both MI60s in tensor-parallel mode.&lt;/p&gt;

&lt;p&gt;The vLLM container command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--model cyankiwi/Qwen3-Coder-Next-AWQ-4bit
--tensor-parallel-size 2
--max-model-len 65536
--gpu-memory-utilization 0.95
--enable-auto-tool-choice
--tool-call-parser qwen3_coder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LiteLLM translated Claude API calls to OpenAI format. Pointing Claude Code at the local stack:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://feynman.cruver.network:4000"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sk-litellm-local-dev"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first attempt hit a hard wall at 32,768 tokens. Agentic sessions accumulate context fast: every file read, every tool call result, every test output appends to the history. By the time the model was writing the CLI module, the session had consumed 33,839 tokens and vLLM returned a context overflow error.&lt;/p&gt;

&lt;p&gt;I bumped the window to 65,536 and ran again. The data layer completed. The correlation engine completed. The test-fix loop worked as designed: the model generated code, ran the tests, read the failures, and corrected them. Five bugs were caught and fixed automatically before either module was signed off.&lt;/p&gt;

&lt;p&gt;The CLI phase did not finish. Not from another hard limit, but from what I started calling the context snowball problem. At 65,000 tokens, processing the incoming prompt takes several minutes per turn. The GPUs peg at 100%, but no generation is happening. Local inference does not have attention caching the way cloud providers do. Every turn reprocesses the entire conversation history from scratch. The model was not thinking; it was rereading.&lt;/p&gt;

&lt;p&gt;The results from Qwen3-Coder-Next: the architecture was sound, the correlation math was correct, the Flux queries for InfluxDB were valid. The project just ran out of runway before finishing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Tool-Calling Flag That Is Not Optional
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;--tool-call-parser qwen3_coder&lt;/code&gt; flag in vLLM matters. Without it, Qwen3-Coder-Next generates tool calls in a format Claude Code cannot parse. The model produces output that looks like a valid tool call, Claude Code processes it, and nothing happens. No error, no message; the tool call evaporates. Adding the flag fixed it immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Model Switch
&lt;/h2&gt;

&lt;p&gt;Qwen3.5-35B-A3B was released while the Qwen3-Coder-Next experiment was still in progress. The naming is confusing, but it is a 35 billion parameter MoE with 3 billion active per token. Smaller headline count than Coder-Next, but one difference mattered more for this use case: it runs in GGUF format under llama.cpp, which made 131,072 tokens of context per instance practical.&lt;/p&gt;

&lt;p&gt;The bigger change was the GPU architecture. Instead of tensor-parallel across both GPUs for one model instance, the new configuration runs two independent llama-server instances, one per GPU:&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="na"&gt;llama-server-0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="c1"&gt;# GPU0: Sonnet tier, no thinking&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="s"&gt;HIP_VISIBLE_DEVICES=0&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="s"&gt;--ctx-size &lt;/span&gt;&lt;span class="m"&gt;131072&lt;/span&gt;
    &lt;span class="s"&gt;--flash-attn on&lt;/span&gt;
    &lt;span class="s"&gt;--jinja&lt;/span&gt;
    &lt;span class="s"&gt;--reasoning-budget &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;

&lt;span class="na"&gt;llama-server-1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;           &lt;span class="c1"&gt;# GPU1: Opus tier, extended thinking&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="s"&gt;HIP_VISIBLE_DEVICES=1&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="s"&gt;--ctx-size &lt;/span&gt;&lt;span class="m"&gt;131072&lt;/span&gt;
    &lt;span class="s"&gt;--flash-attn on&lt;/span&gt;
    &lt;span class="s"&gt;--jinja&lt;/span&gt;
    &lt;span class="s"&gt;--reasoning-budget -1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LiteLLM routes &lt;code&gt;claude-opus-*&lt;/code&gt; requests to GPU1 and everything else to GPU0. Both instances serve the same GGUF file from a shared volume. For agentic coding sessions, which are mostly sequential, one instance handles the full session while the other sits idle for chat.&lt;/p&gt;

&lt;p&gt;Two flags turned out to be essential discoveries.&lt;/p&gt;

&lt;p&gt;The first is &lt;code&gt;--jinja&lt;/code&gt;. Without it, Qwen3.5 tool calls fail silently, the same failure mode as Qwen3-Coder-Next without &lt;code&gt;--tool-call-parser&lt;/code&gt;. The Jinja2-based chat template handles the tool call formatting that Claude Code expects. This is the llama.cpp equivalent of vLLM's &lt;code&gt;--tool-call-parser&lt;/code&gt; flag. It is not documented prominently.&lt;/p&gt;

&lt;p&gt;The second is &lt;code&gt;--dangerously-skip-permissions&lt;/code&gt;. For unattended agentic sessions through a remote connection, Claude Code's permission prompts appear in a TUI that is not visible over SSH. The first Qwen3.5 session stalled for sixteen minutes while Claude Code waited for bash approval input. The code was being created, the tests were queued, and the process was sleeping. The flag eliminates the prompts entirely.&lt;/p&gt;

&lt;p&gt;One other finding from monitoring the running sessions: Claude Code makes outbound HTTPS connections to Anthropic infrastructure even when &lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt; points to a local endpoint. Model API calls route correctly to the local LiteLLM proxy. Process authentication and telemetry go to Anthropic's servers directly. A valid Anthropic account is still required; fully offline deployments are not possible without network-level blocking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model Two: Qwen3.5-35B-A3B Results
&lt;/h2&gt;

&lt;p&gt;Same project, same subagent prompt files, same InfluxDB target.&lt;/p&gt;

&lt;p&gt;Phase 1 (data layer): complete. The model wrote valid Flux queries for the InfluxDB client library on the first attempt. Flux syntax is unusual enough that I expected at least one hallucinated API call. There were none.&lt;/p&gt;

&lt;p&gt;Phase 2 (correlation engine): complete. 25 tests passing. Four bugs were caught and fixed by the test-fix loop: a numpy bool versus Python bool type mismatch, a NaN handling order issue before correlation, a sorting column reference error, and a list length mismatch in the test suite. The model found them, diagnosed them, and fixed them without any intervention.&lt;/p&gt;

&lt;p&gt;Phase 3 (CLI and visualization): incomplete. The test suite had a structural mock issue: the patch decorators targeted &lt;code&gt;fetch_weather&lt;/code&gt; and &lt;code&gt;fetch_metric&lt;/code&gt;, but &lt;code&gt;get_influx_client&lt;/code&gt; was called first in the execution path, so the mocks never fired. The production code itself was correct. Running the CLI against the live InfluxDB database: &lt;code&gt;list-metrics&lt;/code&gt; returned real metric types, &lt;code&gt;correlate glucose ketone&lt;/code&gt; computed a real Pearson coefficient, &lt;code&gt;correlate-all&lt;/code&gt; ranked glucose against all available weather metrics. The visualization module was not written before the session ended.&lt;/p&gt;

&lt;p&gt;One hallucination appeared in Phase 3: &lt;code&gt;df.to_json(default=str)&lt;/code&gt;. The &lt;code&gt;default&lt;/code&gt; parameter belongs to &lt;code&gt;json.dumps&lt;/code&gt;, not &lt;code&gt;DataFrame.to_json&lt;/code&gt;. The model conflated two similar APIs. It surfaced immediately under test and would not have survived a type checker either.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Configuration Changes Actually Mean
&lt;/h2&gt;

&lt;p&gt;Context window size was the single most significant variable between the two runs. 65,536 tokens is not enough runway for a multi-module Python project built in a single agentic session. 131,072 is enough to mostly finish one, with the subagent pattern distributing the load across isolated context windows.&lt;/p&gt;

&lt;p&gt;The GPU split architecture change is also meaningful beyond raw numbers. Tensor-parallel across two GPUs keeps both GPUs active for every request, but introduces inter-GPU communication overhead for each forward pass. Two independent instances run each GPU at full throughput for its own requests, with LiteLLM load-balancing between them. For sequential agentic work, this is a better fit than tensor-parallel.&lt;/p&gt;

&lt;p&gt;The context snowball problem does not disappear with a wider window or a different model. It is structural: local inference without attention caching reprocesses the entire history on every turn. The mitigations are a wide enough context ceiling that the snowball does not hit it, and Claude Code's &lt;code&gt;/compact&lt;/code&gt; command to compress history mid-session when it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Things Stand
&lt;/h2&gt;

&lt;p&gt;Qwen3-Coder-Next showed that the agentic loop produces correct code and the subagent architecture handles multi-phase projects. It ran out of context before finishing.&lt;/p&gt;

&lt;p&gt;Qwen3.5 showed that with more headroom and the right flags, the same approach produces a mostly functional tool from a clean cold start: valid database queries, correct statistical analysis, a working CLI with real data, and only one meaningful hallucination across three phases.&lt;/p&gt;

&lt;p&gt;"Mostly functional" is an honest description. Phase 3 was incomplete. The test suite had fixable issues I had to understand to fix. The visualization module was not written. This is not a one-shot codebase generator. It is a capable assistant that gets significant work done and then needs a human to close the gap.&lt;/p&gt;

&lt;p&gt;That is better than where Part 1 left things. For a tool built mostly autonomously from a cold start, it is a more useful outcome than I expected.&lt;/p&gt;

&lt;p&gt;The same Qwen3.5 stack turned out to be useful beyond coding sessions. I have been running it as the backend for a fully local AI assistant, handling everything from daily digests to email triage to camera alerts. That is a different kind of test, and it deserves its own post.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>vllm</category>
      <category>selfhosted</category>
      <category>amd</category>
    </item>
    <item>
      <title>Enterprise Integration Patterns Aren't Dead; They're Running on Kubernetes and Orchestrating AI</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Tue, 24 Feb 2026 22:16:13 +0000</pubDate>
      <link>https://dev.to/dcruver/enterprise-integration-patterns-arent-dead-theyre-running-on-kubernetes-and-orchestrating-ai-30ol</link>
      <guid>https://dev.to/dcruver/enterprise-integration-patterns-arent-dead-theyre-running-on-kubernetes-and-orchestrating-ai-30ol</guid>
      <description>&lt;p&gt;Keip is a Kubernetes operator for Enterprise Integration Patterns. Gregor Hohpe and Bobby Woolf documented these patterns in 2003: content-based routers, message transformers, splitters, aggregators, dead letter channels. Spring Integration has implemented them for years, but deploying Spring Integration on Kubernetes has always been harder than it should be. Keip fixes that. It turns integration routes into native Kubernetes resources, and I use it to run an LLM-powered media analysis pipeline on my home cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why I Built It
&lt;/h3&gt;

&lt;p&gt;The problem is deploying Spring Integration in Kubernetes. The traditional workflow is: write Java, compile, build a container image, push to a registry, deploy. For every route change, the whole cycle repeats. Adding a filter step, restructuring routing logic, or wiring in a new output channel was indistinguishable from a logic change: same build cycle, same container push, same deployment. The integration logic is buried inside a Java application, and the deployment artifact tells you nothing about what the route does or why.&lt;/p&gt;

&lt;p&gt;Keip separates the integration logic from the application lifecycle. The route XML is the source of truth. The operator handles everything else. The route definition is the documentation, because there's nothing else to read.&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Does
&lt;/h3&gt;

&lt;p&gt;An integration route in Keip is an XML definition inside a Kubernetes custom resource. The operator reads it, creates a Spring Boot application, deploys it as a pod, and manages its lifecycle. Here's what a route resource looks like:&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keip.codice.org/v1alpha2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IntegrationRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;npr-world-news&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea.cruver.network/dcruver/signal-scope/keip-custom:dev&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;routeConfigMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npr-world-news-xml&lt;/span&gt;
  &lt;span class="na"&gt;propSources&lt;/span&gt;&lt;span class="pi"&gt;:&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;npr-world-news-props&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route logic itself is Spring Integration XML. This one ingests an RSS feed, normalizes the content, deduplicates against the database, classifies topics, and persists to PostgreSQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- RSS Inbound Channel Adapter --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;int-feed:inbound-channel-adapter&lt;/span&gt;
    &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"nprWorldNewsFeedAdapter"&lt;/span&gt;
    &lt;span class="na"&gt;url=&lt;/span&gt;&lt;span class="s"&gt;"${rss.feed.url:https://feeds.npr.org/1004/rss.xml}"&lt;/span&gt;
    &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"rawArticlesChannel"&lt;/span&gt;
    &lt;span class="na"&gt;auto-startup=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;int:poller&lt;/span&gt; &lt;span class="na"&gt;fixed-delay=&lt;/span&gt;&lt;span class="s"&gt;"${rss.poll.rate:300000}"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/int-feed:inbound-channel-adapter&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Transform SyndEntry to Map --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;int:transformer&lt;/span&gt;
    &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"syndEntryTransformer"&lt;/span&gt;
    &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"rawArticlesChannel"&lt;/span&gt;
    &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"transformedRssChannel"&lt;/span&gt;
    &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"{ 'id': T(java.util.UUID).randomUUID().toString(),
                  'sourceUrl': payload.link,
                  'title': payload.title ?: 'Untitled',
                  'body': payload.description?.value ?: '',
                  'feedSource': 'npr-world-news' }"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Deduplication Filter --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;int:filter&lt;/span&gt;
    &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"deduplicationFilter"&lt;/span&gt;
    &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"normalizedArticlesChannel"&lt;/span&gt;
    &lt;span class="na"&gt;output-channel=&lt;/span&gt;&lt;span class="s"&gt;"dedupedArticlesChannel"&lt;/span&gt;
    &lt;span class="na"&gt;discard-channel=&lt;/span&gt;&lt;span class="s"&gt;"duplicateArticlesChannel"&lt;/span&gt;
    &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"@deduplicationService.isNew(payload)"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- JDBC: Insert into database --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;int-jdbc:outbound-gateway&lt;/span&gt;
    &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"articlePersister"&lt;/span&gt;
    &lt;span class="na"&gt;request-channel=&lt;/span&gt;&lt;span class="s"&gt;"transformedArticlesChannel"&lt;/span&gt;
    &lt;span class="na"&gt;data-source=&lt;/span&gt;&lt;span class="s"&gt;"dataSource"&lt;/span&gt;
    &lt;span class="na"&gt;update=&lt;/span&gt;&lt;span class="s"&gt;"INSERT INTO articles
            (article_guid, source_url, title, body, feed_source)
            VALUES
            (:payload[id], :payload[sourceUrl], :payload[title],
             :payload[body], :payload[feedSource])"&lt;/span&gt;
    &lt;span class="na"&gt;keys-generated=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real route from SignalScope, my media analysis system. RSS ingestion, transformation, deduplication, topic classification via an HTTP call to a separate service, and persistence to Postgres. All defined in XML, deployed as a Kubernetes resource.&lt;/p&gt;

&lt;p&gt;Changing the poll rate, adding a new feed source, or swapping the classification endpoint is a config edit. No Java compilation, no container rebuild. Update the ConfigMap, the operator reconciles, and the new behavior is live.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Spring Integration Brings
&lt;/h3&gt;

&lt;p&gt;Spring Integration has been around since 2007 and implements every pattern in the EIP book. But the part that matters for Keip is the connector library. Out of the box, Spring Integration provides adapters for HTTP, JMS, Kafka, AMQP, FTP, JDBC, RSS/Atom feeds, file systems, MQTT, TCP/UDP, mail, and many more. Each adapter is a few lines of XML.&lt;/p&gt;

&lt;p&gt;The RSS feed route above is a good example. The &lt;code&gt;int-feed:inbound-channel-adapter&lt;/code&gt; handles polling, parsing Atom and RSS formats, and tracking which entries have already been seen. The &lt;code&gt;int-jdbc:outbound-gateway&lt;/code&gt; handles connection pooling, parameterized queries, and transaction management. None of that is custom code. It's configuration pointing at well-tested library components.&lt;/p&gt;

&lt;p&gt;The pattern library is equally important. Content-based routers send messages down different paths based on payload or header inspection. Filters drop messages that don't meet criteria. Splitters break a single message into many; aggregators collect them back. Wire taps copy messages to a secondary channel for monitoring without affecting the main flow. Dead letter channels catch failures. All of these are declarative XML elements.&lt;/p&gt;

&lt;p&gt;The error handling deserves its own mention. Every channel in Spring Integration can have an error channel. Failed messages route automatically to error handlers, retry policies, or dead letter queues. In most hand-rolled LLM pipelines, error handling is an afterthought bolted on after the first production incident. With Spring Integration, it's built into the messaging model.&lt;/p&gt;

&lt;p&gt;Because each Keip container is a Spring Boot application, the entire Spring ecosystem is available inside it. Spring Data repositories, Spring Security, Micrometer metrics, Spring AI: anything that can be wired as a bean works here. There is no separate plugin system or adapter API to learn. The integration infrastructure is the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Kubernetes Brings
&lt;/h3&gt;

&lt;p&gt;Keip deploys each integration route as its own Kubernetes deployment. This is the fundamental scaling advantage.&lt;/p&gt;

&lt;p&gt;A route polling an RSS feed every five minutes needs minimal resources. A scoring worker running LLM inference on GPU hardware needs a completely different resource profile. In a traditional integration platform, both routes run inside the same application process and scale together. If the scoring worker needs more capacity, you scale the whole application, feed pollers included. If the scoring worker crashes, it can take everything with it. With Keip, they're independent deployments with their own resource limits, health checks, and scaling policies.&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keip.codice.org/v1alpha2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;IntegrationRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;scoring-worker&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitea.cruver.network/dcruver/signal-scope/keip-custom:dev&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;routeConfigMap&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scoring-worker-xml&lt;/span&gt;
  &lt;span class="na"&gt;propSources&lt;/span&gt;&lt;span class="pi"&gt;:&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;scoring-worker-props&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scaling a route means changing the replica count or attaching a Horizontal Pod Autoscaler. Three scoring worker replicas means three articles pulled and scored in parallel without touching the feed pollers. Rolling updates to one route don't touch the others. If a scoring worker crashes, Kubernetes restarts it. If a feed poller falls behind, it can scale out independently. Standard Kubernetes primitives handle all of this without any custom orchestration layer.&lt;/p&gt;

&lt;p&gt;Observability comes free. Pod logs, Prometheus metrics, liveness and readiness probes all work through standard K8s tooling. No separate monitoring stack for the integration layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  SignalScope: The Real Test
&lt;/h3&gt;

&lt;p&gt;SignalScope processes content from 11 RSS feeds and 10 YouTube channels through LLM-powered scoring pipelines. Each feed source has its own integration route running as a separate Kubernetes deployment. The scoring workers are separate routes that pull unscored articles from the database and send them through a local vLLM instance.&lt;/p&gt;

&lt;p&gt;The scheduling is the part I like most. My GPUs serve other workloads during the day, so LLM scoring needs to run between 1 and 5 AM. I use the ControlBus pattern for this. ControlBus is one of the less well-known EIP patterns; it lets a system inspect and modify its own integration routes at runtime. The implementation is two channel adapters wired to a control bus: one starts the scoring adapter at 1 AM, the other stops it at 5 AM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;int:control-bus&lt;/span&gt; &lt;span class="na"&gt;input-channel=&lt;/span&gt;&lt;span class="s"&gt;"controlBusChannel"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;int:inbound-channel-adapter&lt;/span&gt;
    &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"controlBusChannel"&lt;/span&gt;
    &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"'@scoringInboundAdapter.start()'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;int:poller&lt;/span&gt; &lt;span class="na"&gt;cron=&lt;/span&gt;&lt;span class="s"&gt;"0 0 1 * * *"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/int:inbound-channel-adapter&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;int:inbound-channel-adapter&lt;/span&gt;
    &lt;span class="na"&gt;channel=&lt;/span&gt;&lt;span class="s"&gt;"controlBusChannel"&lt;/span&gt;
    &lt;span class="na"&gt;expression=&lt;/span&gt;&lt;span class="s"&gt;"'@scoringInboundAdapter.stop()'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;int:poller&lt;/span&gt; &lt;span class="na"&gt;cron=&lt;/span&gt;&lt;span class="s"&gt;"0 0 5 * * *"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/int:inbound-channel-adapter&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No external cron jobs. No schedulers. The integration infrastructure manages itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Argument for EIP in AI Pipelines
&lt;/h3&gt;

&lt;p&gt;The LLM pipelines I've built follow the same basic shape. Content arrives, gets routed somewhere based on its characteristics, passes through transformations between stages, and lands in different places depending on the outcome. Failures need to go somewhere for retry or human review. The instinct is to wire all of this together with custom scripts and ad hoc error handling.&lt;/p&gt;

&lt;p&gt;These are all patterns that Hohpe and Woolf named in 2003. The mapping is direct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;An intent classifier that selects different logical branches based on the user's prompt is a &lt;strong&gt;content-based router&lt;/strong&gt;. Spring Integration's &lt;code&gt;router&lt;/code&gt; element handles the inspection and branching declaratively, routing to different channels based on payload content or message headers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An agent system that distributes subtasks to specialized workers and combines results is a &lt;strong&gt;splitter-aggregator&lt;/strong&gt;. The splitter breaks a message into parts, each part flows independently through the pipeline, and the aggregator collects them based on a correlation strategy. Spring Integration handles the correlation, timeout, and partial-result logic that most hand-rolled implementations get wrong.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A pipeline stage that reshapes data between an API response and the next model's expected input format is a &lt;strong&gt;message transformer&lt;/strong&gt;. Instead of writing conversion code inline, the transformation is a declared step in the route with its own error handling.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A model endpoint that stops responding or starts returning errors triggers a &lt;strong&gt;circuit breaker&lt;/strong&gt;. After a threshold of failures, the circuit opens and requests route to a fallback path automatically. When the endpoint recovers, the circuit closes. Spring Integration's &lt;code&gt;request-handler-advice-chain&lt;/code&gt; provides this out of the box.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Failed inference calls that need retry logic land in a &lt;strong&gt;dead letter channel&lt;/strong&gt;. The message is preserved, the failure is logged, and a separate route can attempt reprocessing on a schedule or with different parameters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Monitoring an AI pipeline without modifying it uses a &lt;strong&gt;wire tap&lt;/strong&gt;. Messages are copied to a secondary channel for logging, metrics, or debugging while the primary flow continues unaffected.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difference with Keip is that all of this runs as Kubernetes-native resources. The patterns come from Spring Integration, in production for over fifteen years. The scaling and lifecycle management come from Kubernetes. The operator connects them so that an AI pipeline is a set of declarative route definitions, not a pile of glue code.&lt;/p&gt;

&lt;p&gt;The patterns are already everywhere. Every product-specific node in n8n, every trigger in Zapier, every action in Make is a Channel Adapter, translating the source protocol into a message the rest of the integration can work with. The no-code integration industry built product businesses by packaging EIP patterns and giving them brand names.&lt;/p&gt;

&lt;p&gt;What's changed is that generating a Channel Adapter for anything is now trivial. An HTTP endpoint with no library support, a proprietary data format, a legacy system nobody has written an adapter for: describe it to an LLM and get back a working Spring Integration adapter. Keip's custom container support means those generated adapters plug directly into the same infrastructure managing scaling, health checks, and routing. The catalog goes from "whatever's in the library" to "whatever you can describe."&lt;/p&gt;

&lt;h3&gt;
  
  
  Getting Started
&lt;/h3&gt;

&lt;p&gt;Keip is open source under Apache 2.0. The operator and framework are at &lt;a href="https://github.com/codice/keip" rel="noopener noreferrer"&gt;codice/keip&lt;/a&gt; on GitHub. Issues and contributions are welcome. If the ControlBus scheduling pattern or the EIP-to-AI mappings above look useful for something you're building, I'd like to hear about it.&lt;/p&gt;

&lt;p&gt;In a follow-up post on Kairos, I'll cover the AI-native components for Spring Integration that bring LLM-powered routing, transformation, and orchestration directly into integration routes.&lt;/p&gt;

</description>
      <category>java</category>
      <category>kubernetes</category>
      <category>ai</category>
      <category>architecture</category>
    </item>
    <item>
      <title>I Ran Claude Code on Local LLMs for a Month. Here's What Worked for Me.</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Tue, 17 Feb 2026 04:02:12 +0000</pubDate>
      <link>https://dev.to/dcruver/i-ran-claude-code-on-local-llms-for-a-month-heres-what-worked-for-me-520h</link>
      <guid>https://dev.to/dcruver/i-ran-claude-code-on-local-llms-for-a-month-heres-what-worked-for-me-520h</guid>
      <description>&lt;p&gt;I have been running local LLMs on dual AMD MI60 GPUs for over a year, and recently pointed Claude Code at them to see how close I could get to frontier-quality agentic coding on my own hardware. I wanted to know: how close can local models actually get to Anthropic's Claude for building real software?&lt;/p&gt;

&lt;p&gt;This is the first post in a series documenting that experiment. Future posts will cover specific agentic coding sessions, hardware and model setup, and the tools I am building to close the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My setup:&lt;/strong&gt; Dual AMD MI60 GPUs with 64GB of total VRAM, running models through vLLM and LiteLLM. If that sounds unfamiliar, I have written about &lt;a href="https://hullabalooing.cruver.ai/gpu-ai/posts/an-affordable-ai-server-1768704467/" rel="noopener noreferrer"&gt;the hardware&lt;/a&gt;, &lt;a href="https://hullabalooing.cruver.ai/gpu-ai/posts/mi60-hardware-setup" rel="noopener noreferrer"&gt;the MI60 setup&lt;/a&gt;, and &lt;a href="https://hullabalooing.cruver.ai/gpu-ai/posts/claude-code-local-llms-1768966311/" rel="noopener noreferrer"&gt;getting Claude Code working with local models&lt;/a&gt;. The short version: about $1,000 in used enterprise GPUs (two MI60s at $500 each on eBay), enough VRAM to run 70-80B parameter models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small Tasks Work. Complex Ones Do Not.
&lt;/h2&gt;

&lt;p&gt;I got Claude Code working against a local Qwen3-Coder-Next:80B MoE model. It successfully scaffolded a complete Flask application with SQLite, modern CSS, and responsive design. The agentic workflow created files, ran commands, iterated on errors. For small, well-scoped tasks it is genuinely usable.&lt;/p&gt;

&lt;p&gt;The key phrase there is "small, well-scoped."&lt;/p&gt;

&lt;p&gt;Building a large codebase with interdependent modules, maintaining consistency across dozens of files, recovering from errors mid-task; this is where local models break down for me. The individual completions are often fine. But the agentic loop, where the model plans, executes, evaluates, and iterates, requires a level of sustained coherence that I have not been able to get from local models yet.&lt;/p&gt;

&lt;p&gt;What surprised me is that scaling up parameters does not straightforwardly fix this. A 70B model produces fewer bugs than a 7B model, certainly. But the jump from "can scaffold a small app" to "can architect and build a complex multi-module project" has not materialized for me yet, even with the largest models I can run locally. The gap between local and frontier for agentic coding is not incremental. It is qualitative.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Gap Exists
&lt;/h2&gt;

&lt;p&gt;The gap stems from several compounding technical factors, none of which explain it alone.&lt;/p&gt;

&lt;p&gt;That 80B model is a Mixture of Experts architecture. It has 80 billion total parameters, but only about 3 billion are active for any given token; the rest sit idle. It is 80B of storage, not 80B of thinking. I have a dense Llama 3.3 70B running on the same hardware doing about 26 tokens per second, compared to about 35 for the MoE. The MoE is faster because it is doing less work per token, but the dense model uses all 70 billion parameters on every forward pass. That is roughly 23 times more compute per token. I have not yet run the dense 70B through Claude Code on the same agentic tasks. That experiment is next on the list. If the results are dramatically better, it confirms that active parameter count matters more than headline size. If they are not, it tells me the gap is more about training and alignment than raw compute. I will cover the hardware setup and benchmarks in detail in an upcoming post.&lt;/p&gt;

&lt;p&gt;Training investment widens the gap further. Frontier labs invest orders of magnitude more compute into training than open-source model creators. More compute means more passes over more data, which translates directly into better generalization, fewer blind spots, and stronger reasoning chains. The data matters as much as the compute; frontier labs invest heavily in data curation, filtering, decontamination, and synthetic data generation with proprietary pipelines that smaller labs cannot replicate.&lt;/p&gt;

&lt;p&gt;Post-training alignment is where reasoning quality really separates. Frontier labs do extensive reinforcement learning from human feedback, constitutional AI training, and iterative red-teaming. This is not just safety work. It is what teaches the model to reason carefully, follow complex multi-step instructions, and maintain coherence over long outputs. Open-source models get some of this, but not at the same depth or investment level.&lt;/p&gt;

&lt;p&gt;Then there is quantization. My local MoE runs at Q4_0 precision via llama.cpp; my dense Llama 3.3 70B uses AWQ 4-bit via vLLM. Both are lossy compressions of the original weights. On simple tasks the loss is negligible. On complex reasoning where subtle weight differences compound across layers, quantization degrades output quality in ways that are difficult to measure but easy to feel. The model becomes slightly less precise at every step, and over a long chain of reasoning those small losses accumulate.&lt;/p&gt;

&lt;p&gt;Context window is another moving target. The Qwen3-Coder-Next supports 256K tokens natively; my dense Llama 3.3 70B supports 128K. But the ceiling keeps rising; the latest generation has jumped to 1M tokens. Longer context means the model can hold an entire codebase in memory at once.&lt;/p&gt;

&lt;p&gt;No single one of these factors explains the gap on its own. Less active compute per token, less training investment, less alignment work, lossy weight compression, and smaller context windows all stack on top of each other. Each one costs a few percentage points of quality. Together they produce that qualitative difference between a local model that works 70% of the time and a frontier model that works 95% of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the Gap
&lt;/h2&gt;

&lt;p&gt;I am not done experimenting. The most immediate test is running my dense Llama 3.3 70B through Claude Code on a real agentic task, the same kind of multi-module project that the MoE struggled with.&lt;/p&gt;

&lt;p&gt;Beyond that, Claude Code recently introduced agent teams, where a lead agent decomposes a task and delegates sub-tasks to specialist agents that work in parallel. If teams decompose complex projects into smaller, focused units, each individual agent stays within what a 70B model handles well. The lead agent coordinates, but each worker has a narrower scope and a shorter context. I have not tested this with local models yet, but the architecture is promising because it plays to local model strengths rather than fighting their weaknesses.&lt;/p&gt;

&lt;p&gt;There is also the Ralph Wiggum technique: running an AI coding agent in an autonomous loop against a specification until all tasks pass. A model that succeeds 70% of the time on a given task might seem unreliable. But a model that gets three attempts at the same task, with the ability to see its own errors, has a much higher effective success rate. The loop compensates for inconsistency with persistence. Combined with agent teams, the approach becomes: decompose the project into small tasks, let agents loop on each one until it passes, and coordinate the results. I plan to write about this experiment in a future post.&lt;/p&gt;

&lt;p&gt;The third angle is giving local models better memory. Right now, Claude Code starts every session cold. It reads the codebase but has no record of past decisions, failed approaches, or architectural reasoning from previous sessions. An agent that remembers "we tried approach X and it failed because of Y" is dramatically more useful than one that suggests approach X again. I have started building a tool called project-brain that gives coding agents persistent project context: searchable documentation from codebases using local embeddings, session memory, decision logs, and pre-session context assembly. A local model that can search its own project history is working with more information than one that starts fresh every time. Memory does not make the model smarter, but it might make it effective enough to close part of the gap.&lt;/p&gt;

&lt;p&gt;These three ideas, teams, loops, and memory, are all attempts to solve the same underlying problem from different angles. I do not know yet which combination will work, or whether any of them will close the gap enough to matter. But that is the experiment I am running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I Am Now
&lt;/h2&gt;

&lt;p&gt;For agentic coding, I still use frontier Claude for anything complex. Local models handle small, well-scoped tasks. That is the honest answer today.&lt;/p&gt;

&lt;p&gt;But every month the local options get a little better. New models drop, quantization methods improve, context windows grow, and the scaffolding tools get more sophisticated. I plan to be there when the gap closes, because closing it is the whole point of this series.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>vllm</category>
      <category>ai</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>I Built an AI Health Coach That Actually Knows Me</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Wed, 11 Feb 2026 15:11:03 +0000</pubDate>
      <link>https://dev.to/dcruver/i-built-an-ai-health-coach-that-actually-knows-me-2hji</link>
      <guid>https://dev.to/dcruver/i-built-an-ai-health-coach-that-actually-knows-me-2hji</guid>
      <description>&lt;p&gt;Every health app wants to coach you, but none of them know you. They don't know your baseline. They don't know that your blood pressure spikes after certain meals, or that your ketones tank when you're stressed. They offer generic advice based on population averages and call it personalized.&lt;/p&gt;

&lt;p&gt;So I built my own. It runs on my infrastructure, stores everything in a time-series database I control, and uses Claude to understand what I'm eating, estimate the macros, and give me context on every measurement I log. Not generic advice. Insights based on &lt;em&gt;my&lt;/em&gt; data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build This?
&lt;/h2&gt;

&lt;p&gt;For years, my lab results have told the same story: HDL too low, LDL too high, triglycerides elevated. The kind of numbers that make doctors reach for the prescription pad.&lt;/p&gt;

&lt;p&gt;A decade ago, I tried keto and saw good results. My labs improved dramatically. But life happened, I fell off, and the numbers crept back. Now I'm trying again, but this time I want data. Real data. Not scattered across six apps, but unified, queryable, and analyzed by something smart enough to spot patterns I'd miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Claude Fits In
&lt;/h2&gt;

&lt;p&gt;This isn't just a database with a chatbot bolted on. Claude is woven into every interaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Meal Tracking Without the Tedium
&lt;/h3&gt;

&lt;p&gt;Traditional calorie tracking is tedious. You either scan barcodes (hope you're eating packaged food), search through databases (is this "grilled chicken breast" or "chicken breast, grilled, skinless"?), or give up and guess.&lt;/p&gt;

&lt;p&gt;I just describe what I ate:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Breakfast: 3 eggs scrambled with cheese and butter, 4 strips of bacon, coffee with heavy cream"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude parses that, estimates the macros (protein, fat, carbs, calories), and records everything to InfluxDB. Then it responds with a summary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Logged breakfast: ~640 cal, 48g fat, 42g protein, 2g net carbs. Solid keto-friendly start. You're at 640/1800 calories for the day."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No searching. No scanning. Just natural language and an AI that understands food.&lt;/p&gt;

&lt;h3&gt;
  
  
  Context on Every Measurement
&lt;/h3&gt;

&lt;p&gt;When I log my morning ketones or blood pressure, Claude doesn't just acknowledge the number. It puts it in context:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Ketones 2.2, up from your 7-day average of 1.9. You've been consistently above 2.0 for three days now. Whatever you're doing is working."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Or:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Blood pressure 128/82. That's higher than your recent trend. Yesterday was 118/76. Anything different today?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The feedback is immediate. When I can see that my ketones dropped after a particular meal, I think twice about eating it again. That's the behavior change that apps promising "insights" never delivered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ask Questions, Get Answers
&lt;/h3&gt;

&lt;p&gt;Claude has access to query the database directly. So I can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"How's my blood pressure trending this month?"&lt;/p&gt;

&lt;p&gt;"What did I eat last time my ketones dropped below 0.5?"&lt;/p&gt;

&lt;p&gt;"Show me my average daily carbs for the past week."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It pulls the data, analyzes it, and responds in plain English. No dashboards to click through. No exports to spreadsheets. Just a conversation with something that has my complete health history.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rest of the Stack
&lt;/h2&gt;

&lt;p&gt;Claude handles the intelligence. Here's what handles the plumbing:&lt;/p&gt;

&lt;h3&gt;
  
  
  Matrix for Input
&lt;/h3&gt;

&lt;p&gt;I use Element as my daily chat client anyway. Rather than building a separate app, I set up a bot that accepts health data via natural conversation. Quick, frictionless, always accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  n8n for Routing
&lt;/h3&gt;

&lt;p&gt;n8n is a self-hosted workflow automation tool. Think Zapier, but on your own infrastructure. It receives messages from Matrix, routes them to Claude for processing, and handles the database writes. It also manages weekly summaries and could easily add anomaly alerts.&lt;/p&gt;

&lt;h3&gt;
  
  
  InfluxDB for Storage
&lt;/h3&gt;

&lt;p&gt;All metrics land in InfluxDB, a time-series database. Perfect for health data: everything has a timestamp, queries are usually by time range, and calculating rolling averages is trivial.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grafana for Visualization
&lt;/h3&gt;

&lt;p&gt;When I want the big picture (trends over weeks or months, overlaid metrics, drill-downs into specific periods), Grafana turns the raw data into dashboards. The 7-day moving averages smooth out daily noise and make real trends visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Tracking
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Meals:&lt;/strong&gt; Natural language descriptions, AI-estimated macros, stored for correlation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blood ketones:&lt;/strong&gt; Morning measurements to verify I'm staying in ketosis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blood pressure and pulse:&lt;/strong&gt; Watching for improvements as weight drops&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weight:&lt;/strong&gt; Weekly, to track trends without daily obsession&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lab work:&lt;/strong&gt; Periodic lipid panels via &lt;a href="https://ownyourlabs.com" rel="noopener noreferrer"&gt;OwnYourLabs&lt;/a&gt;, which lets you order tests without a doctor's visit&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Not Just Use Apps?
&lt;/h2&gt;

&lt;p&gt;The health app graveyard is full of services that customers depended on until the plug got pulled. Jawbone went bankrupt and bricked thousands of fitness trackers. Pebble got acquired and shut down. Google keeps retreating from health features. Every one of these left users with devices that no longer served their purpose and data they couldn't easily export.&lt;/p&gt;

&lt;p&gt;Even when the apps survive, they're silos. You've got one app for meal tracking, another for weight, a third for workouts, and none of them talk to each other. Want to correlate your food intake with your lab results? Most health apps don't even have a place to store lab data. You end up juggling multiple services, manually cross-referencing, and hoping none of them get acquired or discontinued.&lt;/p&gt;

&lt;p&gt;With my setup, everything flows into one database I control. If a tool stops working, I replace it. The data stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  On Data and Privacy
&lt;/h2&gt;

&lt;p&gt;Health data is personal. I want it consolidated, not scattered across a dozen app companies with a dozen privacy policies.&lt;/p&gt;

&lt;p&gt;With this architecture, the data lives on my servers. Claude processes it during conversations, but Anthropic's API doesn't retain prompts or use them for training. That's a meaningful difference from apps that permanently store your data and mine it for their own purposes.&lt;/p&gt;

&lt;p&gt;For those who want complete sovereignty, the architecture works with local LLMs too. I wrote about &lt;a href="https://dev.to/dcruver/running-claude-code-with-local-llms-via-vllm-and-litellm-30il"&gt;running Claude Code with local models via vLLM&lt;/a&gt;. The same infrastructure powers this system, and swapping to a fully self-hosted model should be a simple configuration change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Early Results
&lt;/h2&gt;

&lt;p&gt;It's been a few weeks, and having an AI that actually knows my history changes behavior more than any notification ever did. It can connect today's meal to tomorrow's ketone reading. The feedback is immediate, personal, and relevant.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're interested in running Claude on your own infrastructure, I wrote about &lt;a href="https://dev.to/dcruver/running-claude-code-with-local-llms-via-vllm-and-litellm-30il"&gt;setting up Claude Code with local LLMs via vLLM&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>health</category>
      <category>quantifiedself</category>
    </item>
    <item>
      <title>Running Claude Code with Local LLMs via vLLM and LiteLLM</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Thu, 05 Feb 2026 02:12:49 +0000</pubDate>
      <link>https://dev.to/dcruver/running-claude-code-with-local-llms-via-vllm-and-litellm-599b</link>
      <guid>https://dev.to/dcruver/running-claude-code-with-local-llms-via-vllm-and-litellm-599b</guid>
      <description>&lt;p&gt;Every query to Claude Code means sending my source code to Anthropic's servers. For proprietary codebases, that's a non-starter. With vLLM and LiteLLM, I can point Claude Code at my own hardware - keeping my code on my network while maintaining the same workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;The trick is that Claude Code expects the Anthropic Messages API, but local inference servers speak OpenAI's API format. LiteLLM bridges this gap. It accepts Anthropic-formatted requests and translates them to OpenAI format for my local vLLM instance.&lt;/p&gt;

&lt;p&gt;The stack looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code → LiteLLM (port 4000) → vLLM (port 8000) → Local GPU
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One environment variable makes it work:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claude Code now sends all requests to my local LiteLLM proxy, which forwards them to vLLM running my model of choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vLLM Configuration
&lt;/h2&gt;

&lt;p&gt;I'm running Qwen3-Coder 30B A3B, a Mixture of Experts model with 30 billion total parameters but only 3 billion active per forward pass. The AWQ quantization brings memory requirements down enough to split it across my dual MI60 GPUs using tensor parallelism:&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="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vllm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nalanzeyu/vllm-gfx906:v0.11.2-rocm6.3&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/kfd:/dev/kfd&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/card1:/dev/dri/card1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/card2:/dev/dri/card2&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/renderD128:/dev/dri/renderD128&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/renderD129:/dev/dri/renderD129&lt;/span&gt;
    &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;16g&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;HIP_VISIBLE_DEVICES=0,1&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-m&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vllm.entrypoints.openai.api_server&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--model&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--tensor-parallel-size&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--max-model-len&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;65536"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--gpu-memory-utilization&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.9"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--enable-auto-tool-choice&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--tool-call-parser&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;qwen3_coder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--enable-auto-tool-choice&lt;/code&gt; and &lt;code&gt;--tool-call-parser qwen3_coder&lt;/code&gt; flags are essential for agentic use. They let the model emit tool calls that Claude Code expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LiteLLM Translation Layer
&lt;/h2&gt;

&lt;p&gt;LiteLLM maps Claude model names to the local vLLM endpoint. The wildcard pattern catches any model Claude Code requests:&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="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;claude-*&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hosted_vllm/QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ&lt;/span&gt;
      &lt;span class="na"&gt;api_base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://vllm:8000/v1&lt;/span&gt;
      &lt;span class="na"&gt;api_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not-needed"&lt;/span&gt;
    &lt;span class="na"&gt;model_info&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65536&lt;/span&gt;
      &lt;span class="na"&gt;max_input_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;57344&lt;/span&gt;
      &lt;span class="na"&gt;max_output_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8192&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drop_params&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;request_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;600&lt;/span&gt;
  &lt;span class="na"&gt;modify_params&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;general_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;disable_key_check&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;p&gt;A few settings to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;drop_params: true&lt;/code&gt; silently ignores Anthropic-specific parameters that don't translate to OpenAI format&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;modify_params: true&lt;/code&gt; allows LiteLLM to adjust parameters as needed for the target API&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;disable_key_check: true&lt;/code&gt; skips API key validation since we're running locally&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Practical Usage
&lt;/h2&gt;

&lt;p&gt;With everything running, Claude Code works exactly as normal:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4000"&lt;/span&gt;

&lt;span class="nb"&gt;cd &lt;/span&gt;my-project
claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The experience is nearly identical to using Anthropic's API, with a few caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Token throughput&lt;/strong&gt;: My dual MI60 setup does roughly 25-30 tokens/second with ~175ms time-to-first-token. No rate limiting, no queue times, no network latency.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Context limits&lt;/strong&gt;: I cap at 64K tokens. Claude Opus can handle 200K.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Model capability&lt;/strong&gt;: Qwen3-Coder is excellent for coding tasks, but Claude has broader knowledge and better instruction following.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The upside is obvious: zero API costs, complete data sovereignty, and the ability to run Claude Code on air-gapped networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agentic File Creation
&lt;/h2&gt;

&lt;p&gt;The real test of Claude Code compatibility isn't chat. It's whether the model can create files, run commands, and iterate on a codebase. The &lt;code&gt;--tool-call-parser qwen3_coder&lt;/code&gt; flag handles the translation between Qwen's XML-style tool calls and the OpenAI tool format that LiteLLM expects.&lt;/p&gt;

&lt;p&gt;To verify this works end-to-end, I asked Claude Code to build a complete Flask application:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4000"&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mkdir &lt;/span&gt;flask-test &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;flask-test
claude &lt;span class="nt"&gt;--dangerously-skip-permissions&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"Build a Flask todo app with SQLite persistence, &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
   modern UI with gradients and animations, &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
   mobile responsive design, and full CRUD operations."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model created a complete project structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flask_todo_app/
├── app.py              # Flask routes and SQLite setup
├── requirements.txt    # Dependencies
├── run_app.sh          # Launch script
├── static/
│   ├── css/
│   │   └── style.css   # Gradients, animations, hover effects
│   └── js/
│       └── script.js   # Client-side interactions
└── templates/
    └── index.html      # Jinja2 template with responsive layout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated &lt;code&gt;app.py&lt;/code&gt; includes proper SQLite initialization:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render_template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url_for&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;init_db&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;todos.db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;CREATE TABLE IF NOT EXISTS todos
                 (id INTEGER PRIMARY KEY AUTOINCREMENT,
                  task TEXT NOT NULL,
                  completed BOOLEAN DEFAULT FALSE)&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;init_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&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;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;todos.db&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SELECT id, task, completed FROM todos ORDER BY id DESC&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;todos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;render_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CSS includes gradients, glass-morphism effects, and animations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'Poppins'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#667eea&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#764ba2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;800px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;text-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;40px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;white&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;text-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.1&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 activating the venv and running the app, everything works. Add a task, toggle it complete, delete it. The database persists across restarts.&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%2Fbya612bceb7hwon93yxz.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%2Fbya612bceb7hwon93yxz.png" alt="Flask Todo App generated by Claude Code with local LLM" width="800" height="591"&gt;&lt;/a&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="nb"&gt;cd &lt;/span&gt;flask_todo_app
&lt;span class="nb"&gt;source &lt;/span&gt;venv/bin/activate
python app.py
&lt;span class="c"&gt;# Visit http://localhost:5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full generation took about five minutes across multiple agentic iterations. Each file is a separate tool call: the model generates, Claude Code executes, the result comes back, and the model plans the next step. The 91% prefix cache hit rate shows vLLM efficiently reusing context across the multi-turn loop.&lt;/p&gt;

&lt;p&gt;This confirms the agentic workflow functions correctly. The model reads the prompt, plans a file structure, emits tool calls to create directories and write files, and produces a functional application. All inference happens locally on the MI60s. No code leaves my network.&lt;/p&gt;

&lt;p&gt;I have not yet tested this on a larger codebase. A small Flask app is one thing; a multi-thousand-line refactor is another. The 64K context limit will eventually become a constraint, and I expect the model to struggle with complex architectural decisions that the real Claude handles gracefully. For now, this works well for focused, scoped tasks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a Model
&lt;/h2&gt;

&lt;p&gt;For Claude Code compatibility, you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Strong tool use&lt;/strong&gt;: The model must emit structured tool calls reliably&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Code focus&lt;/strong&gt;: Qwen3-Coder works well; DeepSeek Coder and CodeLlama variants should also be viable&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Sufficient context&lt;/strong&gt;: I used 64K; smaller context windows may work but I haven't tested them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my testing, Qwen3-Coder-30B-A3B handles straightforward coding tasks well. For complex refactoring or architectural decisions, the real Claude API is still the better choice.&lt;/p&gt;

&lt;p&gt;If you don't have 64GB of VRAM, smaller models like Qwen2.5-Coder-7B or Qwen3-8B should fit on a single 16GB or 24GB card. I haven't tested these configurations, so I can't speak to their context limits or how well they handle Claude Code's agentic workflows.&lt;/p&gt;

&lt;p&gt;In any case, the key is adjusting your workflow: instead of broad "refactor this module" prompts, break work into tighter, more focused requests. More prompts of narrower scope plays to a smaller model's strengths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Stack
&lt;/h2&gt;

&lt;p&gt;The full configuration lives in a single compose file:&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="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;vllm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nalanzeyu/vllm-gfx906:v0.11.2-rocm6.3&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;8000:8000"&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/kfd:/dev/kfd&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/card1:/dev/dri/card1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/card2:/dev/dri/card2&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/renderD128:/dev/dri/renderD128&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/renderD129:/dev/dri/renderD129&lt;/span&gt;
    &lt;span class="na"&gt;group_add&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;44"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;992"&lt;/span&gt;
    &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;16g&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/mnt/cache/huggingface:/root/.cache/huggingface:rw&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;HIP_VISIBLE_DEVICES=0,1&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-m&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vllm.entrypoints.openai.api_server&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--model&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;QuantTrio/Qwen3-Coder-30B-A3B-Instruct-AWQ&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--tensor-parallel-size&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--max-model-len&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;65536"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--gpu-memory-utilization&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.9"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--host&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--port&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--enable-auto-tool-choice&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--tool-call-parser&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;qwen3_coder&lt;/span&gt;

  &lt;span class="na"&gt;litellm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm/litellm:v1.80.15-stable&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;litellm&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&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;4000:4000"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./litellm-config.yaml:/app/config.yaml:ro&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/app/config.yaml&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--port&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4000"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--host&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;vllm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it with &lt;code&gt;nerdctl&lt;/code&gt; (or &lt;code&gt;docker&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;nerdctl compose &lt;span class="nt"&gt;-f&lt;/span&gt; coder.yaml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From any machine on my network, I can point Claude Code at Feynman (my GPU workstation) and get local inference. When I'm done, tear it down with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nerdctl compose &lt;span class="nt"&gt;-f&lt;/span&gt; coder.yaml down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Verdict
&lt;/h2&gt;

&lt;p&gt;This setup won't replace the Claude API for everyone. If you need maximum capability, Anthropic's hosted models are still the best option. But for those of us who care about where our code goes, local inference means complete data sovereignty. Proprietary code never leaves my network. Plus there's something satisfying about seeing your own GPUs light up every time you ask Claude Code a question.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>vllm</category>
      <category>selfhosted</category>
      <category>ai</category>
    </item>
    <item>
      <title>A Second Brain That My AI and I Share</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Mon, 02 Feb 2026 14:58:25 +0000</pubDate>
      <link>https://dev.to/dcruver/a-second-brain-that-my-ai-and-i-share-1747</link>
      <guid>https://dev.to/dcruver/a-second-brain-that-my-ai-and-i-share-1747</guid>
      <description>&lt;p&gt;My AI assistant and I share the same brain. Not metaphorically, we literally read and write to the same knowledge base. When I update a project note in Emacs, Nabu (my AI) sees the change. When Nabu logs something it learned, I see it in my daily file. This post is about how it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;Nabu connects to my self-hosted Matrix server and it has access to my org-roam knowledge base via an MCP server I built. But it's not just access, it's &lt;em&gt;integration&lt;/em&gt;. Nabu treats org-roam as its source of truth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before answering, search.&lt;/strong&gt; When I ask about a project or a person, Nabu searches org-roam first. It doesn't hallucinate details; it looks them up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learning gets recorded.&lt;/strong&gt; When Nabu learns something worth keeping (a decision we made, a fix we applied), it writes it to the knowledge base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared memory.&lt;/strong&gt; We have a context that persists across conversations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, while writing this post, I asked Nabu to find examples of our shared brain in action. It searched the org-roam daily notes and memory files, looking for instances where it had looked something up to help me. The example it found was that very interaction.&lt;/p&gt;

&lt;p&gt;Another example: when I asked Nabu to troubleshoot my camera alert system, I did not explain how it worked. Nabu searched org-roam and found the Camera Alerts Pipeline note with the camera ID mapping, MQTT topics, container names, and alert rules. It diagnosed the issue and fixed the configuration without me having to re-explain my setup.&lt;/p&gt;

&lt;p&gt;Similarly, when my Home Assistant dashboard had gone stale, I asked Nabu to fix it up. It found the sensor names, device configurations, and integration details in my notes, then updated the dashboard accordingly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Configuration
&lt;/h2&gt;

&lt;p&gt;The magic is in how you instruct the agent. In OpenClaw, this happens via workspace files that the agent reads at startup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; tells the agent its role:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Org-Roam Knowledge Base&lt;/span&gt;

Org-roam is your primary knowledge base. Search it before answering
questions about projects, people, or decisions. Update it when you
learn something worth keeping.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MEMORY.md&lt;/code&gt; establishes the relationship:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Org-Roam Knowledge Base: My Primary Role&lt;/span&gt;

I am the live interface for Don's org-roam second brain. I can read,
search, create, edit, and reorganize notes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent internalizes these instructions. It knows where to look and what to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Foundation: Why Emacs
&lt;/h2&gt;

&lt;p&gt;I haven't tried every note-taking tool out there. I used TriliumNext for a while. I've used Evernote. I looked at Obsidian and a few others, but they either weren't self-hostable, or they limited you to their UI. I keep coming back to Emacs and org-mode for a few reasons.&lt;/p&gt;

&lt;p&gt;First, my notes are just text files. No proprietary database, no cloud lock-in, no wondering if the company will exist in five years. I can read them with &lt;code&gt;cat&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt; them, version control them with &lt;code&gt;git&lt;/code&gt;. And importantly, LLMs can read them too. When I paste a note into Claude, it just works. No export step, no format conversion.&lt;/p&gt;

&lt;p&gt;Second, org-roam adds what plain org-mode lacks: backlinks and a graph structure. When I mention a person or project, it becomes a link. Over time, connections emerge that I didn't plan. The structure grows organically from the content.&lt;/p&gt;

&lt;p&gt;Third, Emacs is programmable in a way that no other tool matches. When I want a new workflow, I write it. When something annoys me, I fix it. The tool bends to how I think, not the other way around.&lt;/p&gt;

&lt;p&gt;Finally, there's data sovereignty. My notes live on my machine and sync via &lt;code&gt;git&lt;/code&gt; to my own server. No cloud service has a copy. No company can discontinue access. This matters more to me the longer I do this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pieces
&lt;/h2&gt;

&lt;p&gt;Two components make the shared brain possible:&lt;/p&gt;

&lt;h3&gt;
  
  
  org-roam-second-brain (Emacs Package)
&lt;/h3&gt;

&lt;p&gt;Vanilla org-roam is great, but I wanted more structure. This package adds structured node types (people, projects, ideas, admin tasks), each with its own template. It adds semantic search via vector embeddings stored in the org files themselves, generated locally with no cloud APIs. And it adds proactive surfacing: a daily digest that shows active projects, stale items, pending follow-ups, and dangling links.&lt;/p&gt;

&lt;h3&gt;
  
  
  org-roam-mcp (Python Server)
&lt;/h3&gt;

&lt;p&gt;The MCP server exposes 30 tools via JSON-RPC: search (semantic, contextual, keyword), CRUD operations, task state management, and surfacing functions. It runs locally on port 8001. Any tool that can make HTTP requests can interact with my knowledge base, including AI agents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8001 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"semantic_search",
                 "arguments":{"query":"container networking"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to Set This Up
&lt;/h2&gt;

&lt;p&gt;If you want to replicate this, the process is straightforward:&lt;/p&gt;

&lt;p&gt;Install the Emacs package via straight.el:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight common_lisp"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;straight-use-package&lt;/span&gt;
  &lt;span class="o"&gt;'&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;org-roam-second-brain&lt;/span&gt; &lt;span class="ss"&gt;:host&lt;/span&gt; &lt;span class="nv"&gt;github&lt;/span&gt; &lt;span class="ss"&gt;:repo&lt;/span&gt; &lt;span class="s"&gt;"dcruver/org-roam-second-brain"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="ss"&gt;'org-roam-second-brain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="ss"&gt;'org-roam-api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run an embedding server: either Infinity with Docker (&lt;code&gt;docker run -d -p 8080:7997 michaelf34/infinity:latest --model-id nomic-ai/nomic-embed-text-v1.5&lt;/code&gt;) or Ollama (&lt;code&gt;ollama pull nomic-embed-text&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Start the MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;org-roam-mcp
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EMACS_SERVER_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/emacs-server/server
org-roam-mcp &lt;span class="nt"&gt;--port&lt;/span&gt; 8001
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure your agent to use org-roam as its source of truth. The specifics depend on your agent framework. See the &lt;a href="https://github.com/dcruver/org-roam-second-brain/blob/main/SETUP.md" rel="noopener noreferrer"&gt;full setup guide&lt;/a&gt; for details.&lt;/p&gt;

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

&lt;p&gt;This setup is not for everyone. It requires comfort with Emacs, willingness to run local services, and some patience for configuration. But for me, it solves real problems: my notes are mine forever, in a format that will outlast any company. I can find things by meaning, not just keywords. My AI assistant and I share the same context. The knowledge compounds over time.&lt;/p&gt;

&lt;p&gt;If any of that resonates, the code is &lt;a href="https://github.com/dcruver/org-roam-second-brain" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. For the AI integration, check out &lt;a href="https://openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt;. Take what's useful, ignore the rest.&lt;/p&gt;

</description>
      <category>emacs</category>
      <category>ai</category>
      <category>productivity</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>An Affordable AI Server</title>
      <dc:creator>Donald Cruver</dc:creator>
      <pubDate>Sat, 31 Jan 2026 17:44:29 +0000</pubDate>
      <link>https://dev.to/dcruver/an-affordable-ai-server-3dba</link>
      <guid>https://dev.to/dcruver/an-affordable-ai-server-3dba</guid>
      <description>&lt;p&gt;Two AMD MI60s from eBay cost me about $1,000 total and gave me 64GB of VRAM. That's enough to run Llama 3.3 70B at home with a 32K context window.&lt;/p&gt;

&lt;p&gt;When I started looking into running large language models locally, the obvious limiting factor was VRAM. Consumer GPUs top out at 24GB, and that's on an RTX 4090 at the high end. I wanted to run 70B parameter models locally, on hardware I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Datacenter Castoff, Homelab Treasure
&lt;/h2&gt;

&lt;p&gt;The MI60 is a 2018 server GPU that AMD built for datacenters. It has 32GB of HBM2 memory, the same high-bandwidth memory you find in modern AI accelerators, and you can pick one up for around $500 on eBay. Two of them give you 64GB of VRAM, more than enough for Llama 3.3 70B.&lt;/p&gt;

&lt;p&gt;One problem: they're passive-cooled cards designed for server chassis with serious airflow. Plug one into a regular PC case and it'll thermal throttle within minutes. I ended up 3D printing a duct and running a push-pull configuration: a 120mm fan inside blowing air across the heatsinks, and a 92mm fan on the rear pulling hot air out. A custom fan controller script keeps the fans in sync with GPU utilization, maintaining junction temps around 80°C instead of the 97°C I saw before I figured out cooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use NVIDIA?
&lt;/h2&gt;

&lt;p&gt;NVIDIA has better software support, more documentation, and CUDA is everywhere. But the MI60 has 32GB of HBM2. An RTX 3090 has 24GB of GDDR6X and costs significantly more on the secondary market. The MI60 gives me more memory for less money, and for inference workloads, that memory matters more than raw compute throughput. The MI60's HBM2 delivers higher theoretical memory bandwidth than GDDR6X. For inference, which is memory-bound, that helps. The tradeoff: with two cards doing tensor parallelism, PCIe becomes the bottleneck.&lt;/p&gt;

&lt;p&gt;The software situation is workable, with caveats. The MI60 uses AMD's gfx906 architecture. AMD stopped actively developing for it, but backward compatibility carries forward. I'm running ROCm 6.3 without issues. The upside is that years of bug fixes have made the platform stable. I'm building on well-established code.&lt;/p&gt;

&lt;p&gt;vLLM has been my best experience. I tried Ollama first, but performance was noticeably worse and tensor parallelism across both GPUs wasn't as smooth. vLLM gives me better speeds, but switching models isn't as simple as Ollama's pull-and-run. I built a solution for that, which I'll cover in another post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Can It Actually Do?
&lt;/h2&gt;

&lt;p&gt;Here are some real numbers from my setup, running vLLM with AWQ-quantized models:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Tokens/sec&lt;/th&gt;
&lt;th&gt;GPUs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 8B&lt;/td&gt;
&lt;td&gt;~90&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 32B&lt;/td&gt;
&lt;td&gt;~31&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Llama 3.3 70B&lt;/td&gt;
&lt;td&gt;~26&lt;/td&gt;
&lt;td&gt;2 (tensor parallel)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 8B and 32B models respond quickly, and even the 70B is very usable.&lt;/p&gt;

&lt;p&gt;Most dual-GPU consumer setups max out at 48GB. Two MI60s give you 64GB for around $1,000. You'll need to solve cooling (see above), but it's a one-time fix.&lt;/p&gt;

&lt;p&gt;I'll be writing more about this setup: the cooling solution, the software stack, and how I switch between model configurations. Spoiler: Stable Diffusion still locks up the GPU, and I haven't gotten Whisper working yet.&lt;/p&gt;

&lt;p&gt;The MI60 isn't the only option: there are MI50s, MI100s, and various NVIDIA Tesla cards floating around the secondary market. Memory, compute, and software support all matter.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>homelab</category>
      <category>llm</category>
      <category>amd</category>
    </item>
  </channel>
</rss>
