<?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: Christian Anderson</title>
    <description>The latest articles on DEV Community by Christian Anderson (@c1-anderson).</description>
    <link>https://dev.to/c1-anderson</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110779%2Ff7ed549e-875c-4c07-8cb7-c1d3ff6a355e.jpg</url>
      <title>DEV Community: Christian Anderson</title>
      <link>https://dev.to/c1-anderson</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/c1-anderson"/>
    <language>en</language>
    <item>
      <title>My homelab agent can only recommend what the dataset can prove</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 25 Sep 2026 10:59:42 +0000</pubDate>
      <link>https://dev.to/c1-anderson/my-homelab-agent-can-only-recommend-what-the-dataset-can-prove-3k8c</link>
      <guid>https://dev.to/c1-anderson/my-homelab-agent-can-only-recommend-what-the-dataset-can-prove-3k8c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path One: Ship an Agent That Queries Real Content&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Toolsmith&lt;/strong&gt; is a weekly agent that asks one question about my self-hosted homelab: &lt;em&gt;is there a better tool than the one we run, and is that actually true?&lt;/em&gt; It measures what's running, researches replacements, tests up to two in a sandbox with no network, and writes recommendations. A validator then &lt;strong&gt;withholds&lt;/strong&gt; any recommendation whose numbers aren't in the facts it cites.&lt;/p&gt;

&lt;p&gt;After two weekly runs it had written 8 recommendations and published 3. The problem was that the validator worked on prose. It pulled digits out of sentences, so &lt;code&gt;Apache-2.0&lt;/code&gt; became the number 2.0, and it held back correct claims along with wrong ones.&lt;/p&gt;

&lt;p&gt;For this challenge I moved Toolsmith's output into Sanity and built an agent that answers questions about it over &lt;strong&gt;Sanity Context MCP&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured content is the evidence.&lt;/strong&gt; Every fact is a document with typed &lt;code&gt;measurements[]{value, unit}&lt;/code&gt;. Every number a recommendation relies on is a &lt;code&gt;numbersUsed[]&lt;/code&gt; entry holding a &lt;em&gt;reference&lt;/em&gt; to the fact that must contain it. Licences are an SPDX field, not a phrase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Knowledge Base holds the recall.&lt;/strong&gt; Each run's full report and the validator's "withheld" file are &lt;code&gt;report&lt;/code&gt; documents, indexed as a Context Knowledge Base, so the agent can explain &lt;em&gt;why&lt;/em&gt; something was held back in the run's own words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the structure can make a claim "shown".&lt;/strong&gt; The model can explore both freely, but it can present a recommendation only through one tool. That tool fetches the recommendation with its facts joined in, through Context MCP, and checks every number against the referenced fact. A number that turns up only in the prose backs nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent only works &lt;em&gt;because&lt;/em&gt; the content is structured. Ask it "should we replace our reverse proxy?" and it has to say that all three recommendations were withheld, not paraphrase them into something that sounds confident.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fysbq6i34n8pt60tu9x0p.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fysbq6i34n8pt60tu9x0p.png" alt="Architecture: Toolsmith runs flow into a Sanity dataset with structured evidence and prose; two Context MCP endpoints (GROQ mode and Knowledge Base mode) feed an agent whose present_recommendation tool validates by reference, followed by an answer guard" width="800" height="509"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"What did the observability runs find?"&lt;/strong&gt; Five recommendations exist for that area. Four are withheld and one (Grafana OSS) is shown. The answer guard also caught six numbers that no shown recommendation holds, and made the model take them out:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg3z3j53esz4n4t8tfmv9.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg3z3j53esz4n4t8tfmv9.png" alt="Terminal trace: the agent calls initial_context and groq_query over Context MCP, presents five recommendations, four come back withheld and one shown, then the answer guard catches unbacked numbers" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The shown recommendation claims the target container has 4440 MB free. Here is the join the validator runs, in Vision. The claim's number points at fact &lt;code&gt;F037&lt;/code&gt;, and &lt;code&gt;F037&lt;/code&gt;'s measurements contain 4440:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftviddaty1u0u2zigqmjv.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftviddaty1u0u2zigqmjv.png" alt="Sanity Vision: the recommendation's numbersUsed entry references fact F037, whose measurements include 4440" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Why were the reverse-proxy recommendations withheld?"&lt;/strong&gt; This one uses both endpoints. The agent reads the outline of the Knowledge Base, pulls the &lt;code&gt;reverse_proxy&lt;/code&gt; and &lt;code&gt;fact_verification&lt;/code&gt; entries through &lt;code&gt;knowledge_base_read&lt;/code&gt;, and then presents each recommendation through the validator. The Knowledge Base prose is full of figures. The guard strips every one of them, because recall isn't evidence. What's left is an answer built entirely from the validator's structured result:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpr7puy4su5egr8vuzzj.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmpr7puy4su5egr8vuzzj.png" alt="Terminal trace: the agent reads the Knowledge Base outline and two entries, presents three reverse-proxy recommendations (all withheld), and the guard strips the figures that came from the Knowledge Base prose" width="799" height="249"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Studio, for anyone who wants to see the shape of the data:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftywo3kfpqdxcjpwfsu8y.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftywo3kfpqdxcjpwfsu8y.png" alt="Studio: a recommendation with its cited facts and the Numbers Used array, each entry a reference to a fact" width="800" height="500"&gt;&lt;/a&gt;&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7lgesadk7uks17ij7lae.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7lgesadk7uks17ij7lae.png" alt="Studio: fact F037 with its statement and typed measurements" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/casareanderson/toolsmith" rel="noopener noreferrer"&gt;https://github.com/casareanderson/toolsmith&lt;/a&gt; (MIT). The Sanity work is in &lt;a href="https://github.com/casareanderson/toolsmith/tree/main/sanity" rel="noopener noreferrer"&gt;&lt;code&gt;sanity/&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;convert/convert.py&lt;/code&gt; and &lt;code&gt;convert/reports.py&lt;/code&gt;: Toolsmith run artefacts → NDJSON. Deterministic (stable &lt;code&gt;_id&lt;/code&gt;s), stdlib only, and sanitised, because the dataset is public.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;studio/&lt;/code&gt;: the schema. It's deployed because Context only serves datasets that have a deployed Studio (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent/mcp_client.py&lt;/code&gt;: a ~130-line Streamable HTTP MCP client, stdlib only.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent/validator.py&lt;/code&gt;: &lt;code&gt;validate()&lt;/code&gt;, which works on the joined document instead of prose.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent/agent.py&lt;/code&gt;: the loop, the &lt;code&gt;present_recommendation&lt;/code&gt; tool and the answer guard. It works with any OpenAI-compatible model. I used &lt;code&gt;qwen/qwen3.7-flash&lt;/code&gt; on OpenRouter.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python -m agent --trace "What did the observability runs find?"   # via Context MCP
python -m agent --offline "..."                                   # public GROQ API, no token needed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How I Used Sanity
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The schema is the validator's contract.&lt;/strong&gt; The original validator asked "does this number appear in the text of fact F037?" The new one asks "does the fact this &lt;code&gt;numbersUsed&lt;/code&gt; entry references have this value in its &lt;code&gt;measurements&lt;/code&gt;?" That's one GROQ projection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*[_id == $id][0]{
  title, verdict, claim,
  numbersUsed[]{ value, unit, "fact": fact-&amp;gt;{ factId, measurements } },
  citedFacts[]-&amp;gt;{ factId, licenceSpdx }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A licence is &lt;code&gt;licenceSpdx: "Apache-2.0"&lt;/code&gt;, so it can't be mistaken for the number 2.0. That was the false positive that started all this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Two Context MCP endpoints, two jobs.&lt;/strong&gt; An endpoint serves either GROQ tools or Knowledge Base tools. If you attach both source types, the dataset wins and the Knowledge Base is silently ignored. So there are two endpoints. &lt;code&gt;toolsmith&lt;/code&gt; runs in GROQ mode over the whole dataset, and the validator's joins go through it. &lt;code&gt;toolsmith-kb&lt;/code&gt; runs in Knowledge Base mode over the &lt;code&gt;report&lt;/code&gt; documents. The agent connects to both and namespaces the KB tools as &lt;code&gt;kb_*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The Knowledge Base is deliberately not evidence.&lt;/strong&gt; This is the design decision I'd defend hardest. Prose is exactly what a Knowledge Base is good at: the reports are long, and the reasons for withholding are narrative. But if a number in a report could back a claim, the validator would be back to trusting prose. So the answer guard only accepts numbers from a recommendation that &lt;code&gt;present_recommendation&lt;/code&gt; has returned as &lt;strong&gt;shown&lt;/strong&gt;. Anything else gets sent back to the model once, and after that it's replaced with &lt;code&gt;[not verified]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. What caught me out:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-32004 Only datasets with deployed Studio applications are supported&lt;/code&gt;.&lt;/strong&gt; My first MCP handshake failed with this. Context needs a deployed Studio for the dataset, even if nobody ever opens it. One &lt;code&gt;sanity deploy&lt;/code&gt; fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint name is permanent&lt;/strong&gt;, and a GROQ filter that matches nothing looks exactly like a broken connection. The &lt;code&gt;toolsmith&lt;/code&gt; endpoint uses an empty filter, so it serves the whole dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context wants an organisation token&lt;/strong&gt;, not a project token. The client says so when it gets a 401 or 403.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The step limit.&lt;/strong&gt; Each &lt;code&gt;present_recommendation&lt;/code&gt; call is one model round. An area with five recommendations used up most of a 10-round budget before the model reached the one that passes. The limit is now 16.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My own redaction broke run IDs.&lt;/strong&gt; The answer guard replaced an unbacked &lt;code&gt;2305&lt;/code&gt; inside &lt;code&gt;run-20260914-2305&lt;/code&gt;, so the demo printed &lt;code&gt;run-20260914-[not verified]&lt;/code&gt;. Redaction now skips the same ID, date and URL spans the detector skips, and there's a test for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Knowledge Base isn't a copy of your documents.&lt;/strong&gt; From 4 &lt;code&gt;report&lt;/code&gt; documents, the build produced &lt;strong&gt;5 entries&lt;/strong&gt; organised by topic (&lt;code&gt;observability&lt;/code&gt;, &lt;code&gt;reverse_proxy&lt;/code&gt;, &lt;code&gt;fact_verification&lt;/code&gt;, &lt;code&gt;candidates&lt;/code&gt;, &lt;code&gt;recommendations&lt;/code&gt;), each with an outline of what it covers and what it leaves out. The agent navigates that outline and doesn't search raw documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free plan caps a Knowledge Base source at 150 documents, and editing a filter doesn't re-index.&lt;/strong&gt; My first source pointed at the whole dataset (275 documents), so Build entries stayed greyed out. Saving a &lt;code&gt;_type == "report"&lt;/code&gt; filter on the existing source changed nothing. I had to remove the source and add it again with the filter (4 documents) before it would build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Withheld reasons contained the very numbers the guard forbids.&lt;/strong&gt; "The claim states 119.1 but no cited fact backs it" came out as "the claim states [not verified]…". Now &lt;code&gt;present_recommendation&lt;/code&gt; gives reasons in words ("six figures in the claim have no structured source"), and counts are spelled out because a digit would itself trip the guard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a Knowledge Base is connected, prose goes to it.&lt;/strong&gt; The GROQ endpoint serves the whole dataset, reports included, so at first the model just read report bodies over GROQ and never touched the Knowledge Base. Now, when a KB is connected, a GROQ query for &lt;code&gt;report&lt;/code&gt; documents gets a pointer to &lt;code&gt;kb_knowledge_base_read&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The guard polices numbers, not words.&lt;/strong&gt; The model can read any document over GROQ, including a withheld claim, and paraphrase it: "heavier than the incumbent" gets through, "4 GB" doesn't. I chose that on purpose, because hiding withheld claims from the model would also hide the context it needs to explain them. But it's a real limit, and the demo's answer shows it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The measurement labels are ugly.&lt;/strong&gt; The converter derives labels from the statement text (&lt;code&gt;# MB free of 60167 MB&lt;/code&gt;). They're correct but hard to read. I left them alone because the validator matches on &lt;code&gt;value&lt;/code&gt;, not labels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5. Honest numbers, including where it's stricter than I'd like.&lt;/strong&gt; 245 facts, 8 recommendations, 7 candidate tools, 3 runs, 12 tool areas and 4 reports. &lt;strong&gt;The structured validator shows 1 of the 8. The old text validator published 3.&lt;/strong&gt; The two it now withholds are SigNoz and HyperDX, both REJECTs, and I checked why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's mostly the right call.&lt;/strong&gt; The old validator only checked a number when a fact ID sat next to it. The new rule is that &lt;em&gt;every&lt;/em&gt; number in a claim needs a structured source. SigNoz's "4 GB minimum RAM" and HyperDX's "4 GB + 2 cores" come from the vendors' docs, not from my estate. Web evidence has no document type yet, so those numbers have nothing to point at. The next step is a &lt;code&gt;sourceClaim&lt;/code&gt; type (URL, fetched-at, quoted value) that &lt;code&gt;numbersUsed&lt;/code&gt; can reference, the same way it references facts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some of it was my bug, and I fixed it.&lt;/strong&gt; The first comparison also flagged &lt;code&gt;2026&lt;/code&gt; and &lt;code&gt;14&lt;/code&gt; from "Sep 14, 2026", &lt;code&gt;208&lt;/code&gt; from a CISA bulletin ID (&lt;code&gt;sb26-208&lt;/code&gt;) and a sandbox run ID read as a number. The identifier patterns now cover all of these, each with a test (27 tests in total).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every withhold now names the exact number and says what's missing, instead of a regex failing to match somewhere in a sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;en9phc0q&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public dataset:&lt;/strong&gt; &lt;code&gt;toolsmith&lt;/code&gt;. Try it: &lt;a href="https://en9phc0q.apicdn.sanity.io/v2025-02-19/data/query/toolsmith?query=*%5B_type==%22recommendation%22%5D%7Btitle,verdict%7D" rel="noopener noreferrer"&gt;https://en9phc0q.apicdn.sanity.io/v2025-02-19/data/query/toolsmith?query=*[_type=="recommendation"]{title,verdict}&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Studio:&lt;/strong&gt; &lt;a href="https://toolsmith-kb.sanity.studio" rel="noopener noreferrer"&gt;https://toolsmith-kb.sanity.studio&lt;/a&gt; (members only; it exists so that Context will serve the dataset)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knowledge Base:&lt;/strong&gt; &lt;code&gt;kbReXSyq7xIs&lt;/code&gt; ("Toolsmith"), built from &lt;code&gt;*[_type == "report"]&lt;/code&gt; in the &lt;code&gt;toolsmith&lt;/code&gt; dataset: 4 documents, 5 entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context MCP endpoints:&lt;/strong&gt; &lt;code&gt;toolsmith&lt;/code&gt; (GROQ mode) and &lt;code&gt;toolsmith-kb&lt;/code&gt; (Knowledge Base mode) in organisation &lt;code&gt;omdh8fw7q&lt;/code&gt;. They need an org token with Context Viewer, so to try the agent without one, run &lt;code&gt;--offline&lt;/code&gt;. That mode runs the same GROQ against the public dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;No Agent Session is attached: the build transcripts are full of my network's internal details. The code and the dataset are both public instead.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Ollama keep_alive -1 hangs every other model: a silent deadlock on a 6 GB GPU</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 25 Sep 2026 08:00:14 +0000</pubDate>
      <link>https://dev.to/c1-anderson/ollama-keepalive-1-hangs-every-other-model-a-silent-deadlock-on-a-6-gb-gpu-4ma5</link>
      <guid>https://dev.to/c1-anderson/ollama-keepalive-1-hangs-every-other-model-a-silent-deadlock-on-a-6-gb-gpu-4ma5</guid>
      <description>&lt;p&gt;My local LLM box is one machine with an RTX 2060. That's 6 GB of VRAM, and with the main model loaded there's about 1 GB left. Everything in the homelab that wants a local model talks to the one Ollama instance on it: the profiles of my agent platform, a user-modelling service, Home Assistant, a listing tool, and Open WebUI.&lt;/p&gt;

&lt;p&gt;Twice now, every one of those clients has stopped getting answers. No crash, no error, nothing in the log. This is what was actually going on, including the fix I was sure about that turned out not to be one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first time: 26 August
&lt;/h2&gt;

&lt;p&gt;Every agent turn hung. Not slow — hung. The requests for the main model sat open until each client gave up on its own schedule: 9 minutes on one, 3 minutes on another, 150 seconds on a third.&lt;/p&gt;

&lt;p&gt;What I could see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nvidia-smi&lt;/code&gt; read &lt;strong&gt;181 MiB of 6,144 used, 0% utilisation&lt;/strong&gt;. The GPU wasn't struggling. It was idle.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/ps&lt;/code&gt; listed exactly one model: a CPU-only build of &lt;code&gt;qwen2.5:7b&lt;/code&gt;, with &lt;code&gt;size_vram: 0&lt;/code&gt; and an &lt;code&gt;expires_at&lt;/code&gt; in the &lt;strong&gt;year 2318&lt;/strong&gt;. That's what &lt;code&gt;keep_alive: -1&lt;/code&gt; looks like — "never unload".&lt;/li&gt;
&lt;li&gt;The Ollama log had nothing. No error, no model-load line, no request line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one cost me the most time. Ollama's HTTP request log line is written when a request &lt;em&gt;completes&lt;/em&gt;. A request that's waiting to be scheduled hasn't completed, so it doesn't exist in the log. &lt;strong&gt;No log line means in flight, not never arrived.&lt;/strong&gt; And &lt;code&gt;/api/ps&lt;/code&gt; only lists loaded models, so the model everyone was waiting for wasn't anywhere either.&lt;/p&gt;

&lt;p&gt;The pinned model belonged to the user-modelling service. I'd pointed it at a CPU-only build on purpose, so it would stop fighting the agents for the card. It stopped fighting for the card and started blocking the queue instead.&lt;/p&gt;

&lt;p&gt;The un-stick was one request, no restart:&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;-s&lt;/span&gt; http://&amp;lt;ollama-host&amp;gt;:11434/api/generate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&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;'{"model":"&amp;lt;pinned-model&amp;gt;","keep_alive":0}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returned &lt;code&gt;"done_reason":"unload"&lt;/code&gt;. The queued request for the main model then loaded in &lt;strong&gt;52.8 seconds&lt;/strong&gt; and answered, and the embedding model loaded straight after it. The whole queue had been stuck behind one pin.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that wasn't
&lt;/h2&gt;

&lt;p&gt;The obvious suspect was how many models Ollama will keep loaded at once. I set &lt;code&gt;OLLAMA_MAX_LOADED_MODELS=2&lt;/code&gt; in a systemd drop-in, restarted, and ran the failure case on purpose: pin the CPU model with &lt;code&gt;keep_alive: -1&lt;/code&gt;, then immediately ask for the main one. Before the change that hung past nine minutes. After it, HTTP 200 in 13.4 seconds, with the pinned model evicted.&lt;/p&gt;

&lt;p&gt;I wrote it up as fixed. It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second time: 10 September
&lt;/h2&gt;

&lt;p&gt;I went to run one of the agent profiles and it produced nothing for about 15 minutes. Same signature: &lt;code&gt;expires_at&lt;/code&gt; in 2318, &lt;code&gt;size_vram: 0&lt;/code&gt;. The quickest test turned out to be asking for the loaded model and then for any other one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the pinned model: &lt;strong&gt;200 in 1.7 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;any other model: &lt;strong&gt;no response after 60 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first note said the setting must have reverted. I checked the host. It hadn't. &lt;code&gt;OLLAMA_MAX_LOADED_MODELS=2&lt;/code&gt; had been in place, and active, since 26 August. &lt;strong&gt;That setting was never the protection.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Worth knowing too: Ollama's default for that setting isn't 1. The documented default is three models per GPU. The number of slots was never the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening
&lt;/h2&gt;

&lt;p&gt;It's VRAM. On a 6 GB card with about 1 GB free, a second model can't load until the first one leaves. Ollama's scheduler doesn't refuse the request when there's no room — it waits for room. A model pinned with &lt;code&gt;keep_alive: -1&lt;/code&gt; never leaves, so the wait never ends.&lt;/p&gt;

&lt;p&gt;No environment variable fixes that. None of them can make VRAM, and none of them can override a &lt;code&gt;keep_alive&lt;/code&gt; a client explicitly asked for.&lt;/p&gt;

&lt;p&gt;The part that made it properly bad: &lt;strong&gt;a hang never triggers a fallback chain.&lt;/strong&gt; My agent profiles each have fallback providers configured, and those fire on an error. A request that just never answers isn't an error. So every profile on a local model was dead, the fallbacks sat unused, and nothing anywhere reported a fault. I found it because I happened to try to use one.&lt;/p&gt;

&lt;h2&gt;
  
  
  It comes back within hours
&lt;/h2&gt;

&lt;p&gt;I cleared the pin at 11:39. By 14:07 a different model, plain &lt;code&gt;qwen2.5:7b&lt;/code&gt;, was pinned to 2318. Nothing in my agent profiles or scripts referenced the first one, and none of them set &lt;code&gt;keep_alive&lt;/code&gt; at all. So this wasn't a leftover. Something is actively doing it.&lt;/p&gt;

&lt;p&gt;I still don't know what. I didn't pin it, and Ollama doesn't log request bodies. The clients I could see connected were the listing tool, Home Assistant, the agent host, and Open WebUI running on the box itself. Open WebUI has a per-model "Keep Alive" setting and Home Assistant's Ollama integration exposes one too, and both accept -1. Those are my two suspects. Neither is proven.&lt;/p&gt;

&lt;p&gt;The guard's own log narrows it a little. The expiry timestamps on the pins it clears overnight almost all fall at about 18 and 48 minutes past the hour, so whatever it is runs on a half-hourly schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix: a guard, not a setting
&lt;/h2&gt;

&lt;p&gt;A small Python script on a cron every 15 minutes. It reads &lt;code&gt;/api/ps&lt;/code&gt; and unloads any model whose expiry is more than 48 hours away. A real &lt;code&gt;keep_alive&lt;/code&gt; is minutes or hours (mine defaults to 30 minutes), so anything further out was pinned, not scheduled. The core of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/ps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;models&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]:&lt;/span&gt;
    &lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+00:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_SANE_HOURS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# 48
&lt;/span&gt;        &lt;span class="nf"&gt;_unload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;   &lt;span class="c1"&gt;# POST /api/generate {"model": ..., "keep_alive": 0}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in there that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only touches pins. A model with a normal expiry is left alone even if it's hogging the whole card, because that one will leave on its own.&lt;/li&gt;
&lt;li&gt;If Ollama is unreachable it exits cleanly and says so. A down server is a different fault with its own monitor, and this guard shouldn't page for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It has a &lt;code&gt;--dry-run&lt;/code&gt; flag, and it's deterministic. No model is involved in deciding what to unload.&lt;/p&gt;

&lt;p&gt;It's still firing. On the morning I'm writing this it unloaded &lt;code&gt;qwen2.5:7b&lt;/code&gt;, "pinned for 292 years", at 09:45, 12:15 and 12:45. The log shows 408 unloads since it went in on 10 September, between 20 and 43 a day. Every one of those was a window where any other model would have hung.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone running Ollama on a small card
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A hang with no log line isn't a quiet server.&lt;/strong&gt; Ollama logs a request when it finishes, so a missing line means the request is still waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/api/ps&lt;/code&gt; won't show you the model everyone's waiting for.&lt;/strong&gt; It lists loaded models only. Look for &lt;code&gt;expires_at&lt;/code&gt; years in the future and &lt;code&gt;size_vram: 0&lt;/code&gt; on the one that is loaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An idle GPU during a hang means a scheduling problem, not a resource problem.&lt;/strong&gt; 181 MiB used at 0% utilisation isn't a card under strain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback chains fire on errors, not on silence.&lt;/strong&gt; If your failover depends on the primary failing loudly, a hang gets past it. Put a timeout on the client, or watch for this directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the fix against the real thing, then check again later.&lt;/strong&gt; My regression test passed on the night, and the problem came back two weeks later with the setting still there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If one client can pin the card, assume one will.&lt;/strong&gt; On a shared single-GPU box, clearing the pin on a schedule is more reliable than hunting down every client that might set it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The un-stick is one &lt;code&gt;curl&lt;/code&gt;. Knowing that you need it is the hard part, because nothing tells you.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>llm</category>
      <category>debugging</category>
      <category>ollama</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Cryptonym Desk on Sanity: moving a word list into a dataset found two bugs</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 24 Sep 2026 18:35:39 +0000</pubDate>
      <link>https://dev.to/c1-anderson/cryptonym-desk-on-sanity-moving-a-word-list-into-a-dataset-found-two-bugs-1e32</link>
      <guid>https://dev.to/c1-anderson/cryptonym-desk-on-sanity-moving-a-word-list-into-a-dataset-found-two-bugs-1e32</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/sanity-2026-09-16"&gt;Sanity Challenge, Path Two: Vibe-Code Something Strange&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Cryptonym Desk&lt;/strong&gt; gives you a spy file for your agent. You type in films, anime and characters you like. It gives you back three names: a CIA-style cryptonym (a real two-letter office digraph plus a word that means nothing, like &lt;code&gt;AEDINOSAUR&lt;/code&gt; or &lt;code&gt;LIENVOY&lt;/code&gt;), a working alias made by grafting your names together at their vowels, and an ADJECTIVE NOUN field codename.&lt;/p&gt;

&lt;p&gt;The first version was one 29 KB HTML file with every word list hard-coded in a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag. For this challenge I moved the lists into a &lt;strong&gt;public Sanity dataset&lt;/strong&gt; and rebuilt the page in &lt;strong&gt;Astro&lt;/strong&gt;, which reads that dataset when the site is built.&lt;/p&gt;

&lt;p&gt;It's for people who name things: agents, bots, side projects, D&amp;amp;D characters. It doesn't take itself seriously. The one thing I kept strict is that &lt;strong&gt;nothing you type ever leaves the page.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://casareanderson.github.io/cryptonym-desk-sanity/" rel="noopener noreferrer"&gt;https://casareanderson.github.io/cryptonym-desk-sanity/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The corpus, rendered from Sanity:&lt;/strong&gt; &lt;a href="https://casareanderson.github.io/cryptonym-desk-sanity/corpus/" rel="noopener noreferrer"&gt;https://casareanderson.github.io/cryptonym-desk-sanity/corpus/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3pfvnld92c38iwu3t32.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3pfvnld92c38iwu3t32.png" alt="The desk issuing a record from the Space western preset" width="800" height="538"&gt;&lt;/a&gt;&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr887ezjdtcvsxxd3l236.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr887ezjdtcvsxxd3l236.png" alt="The corpus page: every digraph, word and weight, plus the GROQ query the page was built with" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/casareanderson/cryptonym-desk-sanity" rel="noopener noreferrer"&gt;https://github.com/casareanderson/cryptonym-desk-sanity&lt;/a&gt; (MIT). The original single-file version is at &lt;a href="https://github.com/casareanderson/cryptonym-desk" rel="noopener noreferrer"&gt;https://github.com/casareanderson/cryptonym-desk&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Build Process
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The tool:&lt;/strong&gt; Claude Code, in a terminal, not an IDE. I gave it a short brief and it did the build end to end: import, schema, Astro port, tests, GitHub Action, deploy. I reviewed the result. What went wrong along the way is more useful than what went right, so this section is mostly that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The schema came first, and the data got cleaner as a result.&lt;/strong&gt; The three document types are &lt;code&gt;digraph&lt;/code&gt; (code, provenance note, &lt;code&gt;retired&lt;/code&gt; flag), &lt;code&gt;wordBank&lt;/code&gt; (bank, value, &lt;code&gt;weight&lt;/code&gt;) and &lt;code&gt;preset&lt;/code&gt; (label, seed list). The corpus came to 103 documents. Just turning the arrays into documents exposed a bug: &lt;strong&gt;&lt;code&gt;MERIDIAN&lt;/code&gt; was in the noun list twice&lt;/strong&gt;, so it had been drawn twice as often as any other noun. In a JavaScript array you never notice that. As separate documents, it stands out immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A test written from the README failed, and the README was the thing that was wrong.&lt;/strong&gt; The README says the splitter cuts &lt;code&gt;Spiegel&lt;/code&gt; into &lt;code&gt;Spie·gel&lt;/code&gt; and &lt;code&gt;Kusanagi&lt;/code&gt; into &lt;code&gt;Ku·sa·na·gi&lt;/code&gt;, so &lt;code&gt;Spiegel × Kusanagi&lt;/code&gt; gives &lt;code&gt;Spienagi&lt;/code&gt;. The agent wrote a test for exactly that. It failed. The regex had always kept one consonant after each vowel group, giving &lt;code&gt;Spieg·el&lt;/code&gt; and &lt;code&gt;Kus·an·ag·i&lt;/code&gt;, so across 200 seeds that pair only ever produced &lt;strong&gt;&lt;code&gt;Spiegagi&lt;/code&gt;&lt;/strong&gt;. The README's own example could not happen. The obvious fix was to change the test to match the code. Instead we treated the documented behaviour as the design and the regex as the bug. The code now matches the README, and there's a test that the README's example can actually be produced. The cost: the same inputs now give different aliases than the original desk did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;weight&lt;/code&gt; field was decoration at first.&lt;/strong&gt; The schema had it and the generator did weighted picks, but every entry weighed 1, so none of it did anything. I only noticed when the corpus page showed a column of &lt;code&gt;×1&lt;/code&gt;. The fix was an edit in the dataset, not the code: plain &lt;code&gt;SECRET&lt;/code&gt; weighs 5 and &lt;code&gt;CODE WORD&lt;/code&gt; weighs 0.5. That edit is also the end-to-end proof. The corpus revision printed on the live site went from &lt;code&gt;211eb8a&lt;/code&gt; to &lt;code&gt;4ca7fee&lt;/code&gt; on the next build, with no code change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Every record now carries the corpus revision.&lt;/strong&gt; Records have always been seeded (same inputs plus the same salt gives the same record), which is what makes a "file reference" mean something. Once the words live in a dataset, the same inputs can produce a different record after someone edits a bank. So the page hashes every document's &lt;code&gt;_rev&lt;/code&gt; into a 7-character revision and prints it on the record and in "Copy record". Without it, "same inputs, same record" would quietly stop being true.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The build refuses a corpus that would break the page.&lt;/strong&gt; If a bank is empty, a bank name is unknown, a weight is zero or no digraphs are active, &lt;code&gt;astro build&lt;/code&gt; fails, so the page can't ship and then throw on click. The Studio schema enforces the same rules earlier (positive weights, uppercase 2–3 letter codes, at least two seeds per preset).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. What I deliberately didn't do.&lt;/strong&gt; The corpus is fetched &lt;strong&gt;at build time only&lt;/strong&gt;. The page makes no runtime calls to Sanity, because it has always promised that nothing you type leaves the browser, and I'd rather keep that promise than add a live query. Edits reach the site through a GitHub Action that runs on push, on demand and nightly. A Sanity webhook would be faster, but it would mean storing a GitHub token inside Sanity, and a nightly rebuild is plenty for a word list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Where it stopped, and the update.&lt;/strong&gt; The first version was a read-only frontend, and I said the obvious next step was a Workflow that lets people propose words, with a person approving each one into a bank. That's now built (update, 25 September):&lt;/p&gt;

&lt;h2&gt;
  
  
  Update: word proposals, a workflow kept as data
&lt;/h2&gt;

&lt;p&gt;Every proposal is a &lt;code&gt;wordProposal&lt;/code&gt; document in the same public dataset: the word, the target bank, a weight, a rationale, and a &lt;code&gt;status&lt;/code&gt; that moves through &lt;strong&gt;proposed → in review → approved → merged&lt;/strong&gt; (or &lt;strong&gt;rejected&lt;/strong&gt;, which can be reopened). Each move appends to a &lt;code&gt;history[] {status, at, by, note}&lt;/code&gt; array on the document, so one GROQ query answers "who approved CISTERN, and when?". There's no second database for the workflow state.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmo7jkyx8jreufuz9yf56.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmo7jkyx8jreufuz9yf56.png" alt="The App SDK review queue: five lanes (Proposed, In review, Approved, Rejected, Merged), each card showing the word, its rationale, the reviewer's note and the last move" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One rules module, three surfaces.&lt;/strong&gt; An App SDK review board, Studio document actions and a CLI all call the same dependency-free &lt;code&gt;src/lib/proposals.js&lt;/code&gt;. None of them can skip review: proposed → merged is refused. A rejection needs a reviewer note.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The board is deployed&lt;/strong&gt; as a Sanity App SDK app ("Word proposals"), and organisation members can open it from the Sanity Dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The board updates live.&lt;/strong&gt; It uses &lt;code&gt;useQuery&lt;/code&gt; from &lt;code&gt;@sanity/sdk-react&lt;/code&gt;, which subscribes to changes. A move made from the CLI showed up on the open board in about 2.6 seconds, with no reload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The merge is deterministic code in one transaction.&lt;/strong&gt; It does a &lt;code&gt;createIfNotExists&lt;/code&gt; for the new &lt;code&gt;wordBank&lt;/code&gt; entry (a predictable &lt;code&gt;_id&lt;/code&gt;, so a double click lands on the same document) plus a patch to the proposal pinned with &lt;code&gt;ifRevisionID&lt;/code&gt;. If two reviewers merge at once, only one succeeds. I tested this against the live API: the stale merge got a 409 and wrote nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It catches the bug the port found.&lt;/strong&gt; A proposal for a word already in the bank links to the existing entry instead of creating a second one. That's exactly how &lt;code&gt;MERIDIAN&lt;/code&gt; ended up drawn twice as often in the original.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proposals don't change the corpus revision.&lt;/strong&gt; Moving cards around leaves everyone's CORPUS stamp alone. Only a merge changes the words, so only a merge changes the stamp.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzoo0nke4ri4d3e8kumb.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgzoo0nke4ri4d3e8kumb.png" alt="A proposal in the Studio mid-review: status, reviewer note and the append-only history" width="800" height="850"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The loop, run for real:&lt;/strong&gt; &lt;code&gt;CISTERN&lt;/code&gt; went proposed → in review → approved → merged, and it's now in the noun bank of the live site. There are 15 nouns instead of 14, and the corpus revision changed with no code change.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frcik29zvdkujp8i9v3iy.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frcik29zvdkujp8i9v3iy.png" alt="The corpus page with CISTERN in the noun bank" width="800" height="1208"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What went wrong:&lt;/strong&gt; &lt;code&gt;sanity@latest&lt;/code&gt; (v6) needs Node 22.12 or later, and the box has Node 20. The v4 CLI's unattended &lt;code&gt;init --template app-quickstart&lt;/code&gt; asks for flags it then refuses to accept together, so the app was written by hand from the template the CLI bundles. A project robot token can deploy a Studio but not an App SDK app, which needs an organisation-level grant. And the tradeoff of a public dataset: proposals, rationales and reviewer notes are world-readable, like the rest of the corpus. The Studio actions patch the published document and are disabled while a draft exists, so an unsaved edit can't be silently left behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests:&lt;/strong&gt; 19 in total (10 new). They cover the transition table, skipped-review refusal, the rejection note, the predictable merge &lt;code&gt;_id&lt;/code&gt;, the duplicate guard and the one-transaction revision pin.&lt;/p&gt;

&lt;p&gt;One honest gap in the data is still there: each digraph's provenance note is a generic "real digraph from declassified material" line, not an individual citation.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The rest of this section is from the original build.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small things that went wrong:&lt;/strong&gt; The Studio's first &lt;code&gt;sanity deploy&lt;/code&gt; stopped and asked for the new app ID to be pinned in &lt;code&gt;sanity.cli.js&lt;/code&gt;. The headless screenshot step first tried a Python Playwright that wasn't installed, then used the Node one. And GitHub Actions now warns that Node 20 actions are deprecated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests:&lt;/strong&gt; 9 &lt;code&gt;node:test&lt;/code&gt; cases. They cover determinism, the revision on each record, weighted-pick distribution (10,000 draws), split parts rejoining to the original word, the README example actually appearing, and the corpus validator rejecting each kind of bad data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity Project Details
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project ID:&lt;/strong&gt; &lt;code&gt;en9phc0q&lt;/code&gt;, dataset &lt;strong&gt;&lt;code&gt;production&lt;/code&gt;&lt;/strong&gt; (public read)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the whole corpus, no token needed:&lt;/strong&gt;
&lt;a href="https://en9phc0q.api.sanity.io/v2024-01-01/data/query/production?query=*%5B_type%20in%20%5B%22digraph%22%2C%22wordBank%22%2C%22preset%22%5D%5D" rel="noopener noreferrer"&gt;https://en9phc0q.api.sanity.io/v2024-01-01/data/query/production?query=*%5B_type%20in%20%5B%22digraph%22%2C%22wordBank%22%2C%22preset%22%5D%5D&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema:&lt;/strong&gt; &lt;a href="https://github.com/casareanderson/cryptonym-desk-sanity/blob/main/studio/schemaTypes/index.js" rel="noopener noreferrer"&gt;&lt;code&gt;studio/schemaTypes/index.js&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The GROQ query the page is built from&lt;/strong&gt; is printed at the bottom of the &lt;a href="https://casareanderson.github.io/cryptonym-desk-sanity/corpus/" rel="noopener noreferrer"&gt;corpus page&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Agent Session
&lt;/h2&gt;

&lt;p&gt;Not embedded. The session that built this ran on my homelab box, and the transcript is full of private network detail that has nothing to do with the build. I'd rather leave it out than publish a heavily redacted version. The repo's commit history, tests and this write-up are the record.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Built with an AI coding agent (Claude Code) and drafted with AI assistance from the build notes, commits and test output, then reviewed before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>devchallenge</category>
      <category>sanitychallenge</category>
      <category>sanity</category>
      <category>ai</category>
    </item>
    <item>
      <title>Piper TTS over Wyoming: the frame format and empty model that broke my podcast</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:55:31 +0000</pubDate>
      <link>https://dev.to/c1-anderson/piper-tts-over-wyoming-the-frame-format-and-empty-model-that-broke-my-podcast-1c1l</link>
      <guid>https://dev.to/c1-anderson/piper-tts-over-wyoming-the-frame-format-and-empty-model-that-broke-my-podcast-1c1l</guid>
      <description>&lt;p&gt;I wanted my own articles as audio: something I could listen to on a walk, as a conversation between two voices rather than one voice reading. Not a cloud service. A thing I run, on the hardware I already have, with no GPU.&lt;/p&gt;

&lt;p&gt;It works now. The first episode came out at 6 minutes 46 seconds, 58 turns, normalised to -16 LUFS. Getting there took two bugs that both looked like something else, and a licence check that ruled out the voices I wanted most. Here is what actually happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;Four stages: &lt;strong&gt;fetch → script → voice → mix&lt;/strong&gt;. Each one writes its output to disk (the article JSON, the dialogue turns, one WAV per turn, the final MP3), so any stage can be re-run on its own. That turned out to matter more than anything else in the design, because every bug below was found by re-running one stage, not the whole thing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Script:&lt;/strong&gt; a local 7B model writes the dialogue, one article section at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Voice:&lt;/strong&gt; Piper, spoken to over the Wyoming protocol, with two stock British voices, one per speaker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mix:&lt;/strong&gt; &lt;code&gt;ffmpeg&lt;/code&gt; concat plus short pauses, then loudness normalisation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Voice: Piper over Wyoming, no new service
&lt;/h2&gt;

&lt;p&gt;I already had a &lt;code&gt;wyoming-piper&lt;/code&gt; service running for my home voice assistant. It turns out that if you send it a &lt;code&gt;synthesize&lt;/code&gt; request naming a voice it has never seen, it downloads that voice on first use. So two distinct speakers cost nothing: no new container, no GPU, no API key.&lt;/p&gt;

&lt;p&gt;Measured on a 2-core container: &lt;strong&gt;8.4 seconds of audio in 2.9 seconds of wall time&lt;/strong&gt;, a real-time factor of about 0.35. A 15-minute episode is roughly five minutes of synthesis. Clips are cached by a hash of &lt;code&gt;(voice, text)&lt;/code&gt;, so fixing one line re-renders one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: the frame has three parts, not two
&lt;/h2&gt;

&lt;p&gt;The first version of my Wyoming client read a JSON header line, then read the audio payload after it. It failed straight away with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Extra data: line 1 column 62
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads like a broken server sending malformed JSON. It wasn't. A Wyoming event can carry &lt;strong&gt;three&lt;/strong&gt; parts: the header line, then an optional block of JSON &lt;code&gt;data&lt;/code&gt;, then an optional binary payload. The header announces the sizes of both. If it declares a &lt;code&gt;data_length&lt;/code&gt;, that data is its own block of bytes after the newline, not a field inline in the header. My reader skipped it, landed in the middle of a JSON object, and choked.&lt;/p&gt;

&lt;p&gt;The fix is to read what the header tells you to read, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_read_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data_length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dn&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;pn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;payload_length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pn&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;audio-start&lt;/code&gt; event puts the sample rate, width and channel count in that &lt;code&gt;data&lt;/code&gt; block, which is why skipping it was fatal rather than cosmetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: the model that answered in a &lt;code&gt;thinking&lt;/code&gt; key
&lt;/h2&gt;

&lt;p&gt;The first full run produced a cheerful log line: &lt;code&gt;intro: 0 turns&lt;/code&gt;. No error. The natural assumption is a bad prompt, and I spent time there first.&lt;/p&gt;

&lt;p&gt;The real cause was the model. My default local model is a thinking model, and I was using a variant whose Modelfile is meant to switch thinking off. Measured through Ollama's API: &lt;code&gt;message.content&lt;/code&gt; was an &lt;strong&gt;empty string&lt;/strong&gt;, and the entire 200-token budget had gone into a separate &lt;code&gt;thinking&lt;/code&gt; key. The "no thinking" Modelfile parameter was accepted and ignored.&lt;/p&gt;

&lt;p&gt;Two fixes. Send &lt;code&gt;"think": false&lt;/code&gt; in the request body (the API-level switch does work where the Modelfile one didn't), and refuse to return an empty answer silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thinking&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt; spent its whole budget in a `thinking` &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block and returned no content.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty &lt;code&gt;content&lt;/code&gt; from a thinking model looks identical to a model with nothing to say, and everything downstream just counts zero turns. That sends you to the prompt when the problem is the model. Now it fails loudly and says why.&lt;/p&gt;

&lt;p&gt;I also switched models on measurement rather than habit. On my 6 GB card, which is already mostly holding an image model, the default ran at &lt;strong&gt;5.3 tokens/s&lt;/strong&gt; (about 90% on CPU). A plain 7B non-thinking model ran at &lt;strong&gt;12.6 tokens/s&lt;/strong&gt;. Faster, and it actually returns content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small 7B problems
&lt;/h2&gt;

&lt;p&gt;A 7B model writing structured dialogue needs guard rails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ollama's JSON mode returns an object, and I wanted an array&lt;/strong&gt;, so the model wrapped the array in a key it invented, differently each time. Asking plainly and parsing defensively was more predictable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sometimes it wraps each object in quotes&lt;/strong&gt;, which is invalid JSON. A last-resort regex pulls out the speaker/text pairs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;check()&lt;/code&gt; step runs before synthesis&lt;/strong&gt; and rejects monologue turns, empty turns, unknown speakers, URLs and bullet points. That's cheaper than finding them after five minutes of CPU spent speaking them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rule that keeps it honest: the source sets the ceiling
&lt;/h2&gt;

&lt;p&gt;The model writes dialogue; it never supplies facts. Each prompt carries only the section it's allowed to talk about. And the target length is capped at &lt;strong&gt;1.15 × the article's word count&lt;/strong&gt;. An 828-word article becomes about six minutes, not fifteen. The only way to stretch a short article into a long episode is to make things up, so the pipeline refuses to ask for it, and says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ffmpeg -f concat&lt;/code&gt; copies the PCM straight through, so stitching takes seconds, not minutes. I insert a 320 ms pause between lines from the same speaker and 520 ms on a handover, which is enough to hear the conversation change hands. Then &lt;code&gt;loudnorm=I=-16&lt;/code&gt;, because Piper's two voices aren't equally loud out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The licence check
&lt;/h2&gt;

&lt;p&gt;I wanted to go further and have one of the voices be mine, cloned from an old recording. The two most-recommended cloning models were out as soon as I read the licences: &lt;strong&gt;XTTS-v2 is under a non-commercial licence (CPML), and the F5-TTS weights are CC-BY-NC.&lt;/strong&gt; This podcast links to things I sell, so neither was an option.&lt;/p&gt;

&lt;p&gt;I used OpenVoice v2 with MeloTTS instead (both MIT). The lesson there was a different one: &lt;strong&gt;OpenVoice changes timbre, not rhythm.&lt;/strong&gt; My vocal colour on top of Piper's cadence still sounded like a machine. No better sample fixes that; the cadence comes from the underlying TTS. Kokoro (Apache-2.0) is the next candidate. Measured on CPU, it speaks the same line about 24% slower because it takes the pauses a person would.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you if you're building one
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the protocol spec for framing before writing a client.&lt;/strong&gt; A three-part frame read as two gives you an error that blames the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat empty model output as an error, not an answer.&lt;/strong&gt; Check for a &lt;code&gt;thinking&lt;/code&gt; or &lt;code&gt;reasoning&lt;/code&gt; field when &lt;code&gt;content&lt;/code&gt; is empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write every stage to disk.&lt;/strong&gt; Being able to re-run one stage is what turned each of these bugs into a short investigation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read the model licence before you fall in love with the demo.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and code, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>opensource</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Authelia passkeys with TOTP as break-glass: why the config diff was empty</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 24 Sep 2026 08:00:03 +0000</pubDate>
      <link>https://dev.to/c1-anderson/i-switched-my-sso-to-passkeys-and-the-config-diff-was-empty-4e5k</link>
      <guid>https://dev.to/c1-anderson/i-switched-my-sso-to-passkeys-and-the-config-diff-was-empty-4e5k</guid>
      <description>&lt;p&gt;Everything behind my front door goes through one login portal: photos, password vault, the VPN control plane, my agent dashboard. It runs Authelia, and for a long time the second factor was a TOTP code out of an authenticator app.&lt;/p&gt;

&lt;p&gt;Now it's a passkey, with TOTP kept only for the day I lose the authenticator.&lt;/p&gt;

&lt;p&gt;I went looking for the configuration change that expresses that. There isn't one. The diff is empty, and understanding &lt;em&gt;why&lt;/em&gt; it's empty is the whole point of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the policy actually says
&lt;/h2&gt;

&lt;p&gt;The relevant lines, unchanged before and after:&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;access_control&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;default_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;two_factor'&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*.example.internal'&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;two_factor'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every OIDC client registered with it:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;some-app'&lt;/span&gt;
        &lt;span class="na"&gt;authorization_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;two_factor'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;two_factor&lt;/code&gt; is a &lt;strong&gt;count&lt;/strong&gt;, not a method. It says: a password, and then one more thing you have proven you control. It does not say which thing. Authelia will accept any second factor the user has enrolled, and the user decides which one to present at the prompt.&lt;/p&gt;

&lt;p&gt;So "passkey normally, TOTP only if I'm locked out" is expressed by &lt;strong&gt;what I enrolled&lt;/strong&gt;, not by anything in the file. My config has no &lt;code&gt;webauthn:&lt;/code&gt; section and no &lt;code&gt;totp:&lt;/code&gt; section at all — both run on v4.39 defaults, because nothing needed saying.&lt;/p&gt;

&lt;p&gt;If you came here expecting a config diff, that expectation is the misunderstanding. It was mine too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the passkey is the normal one now
&lt;/h2&gt;

&lt;p&gt;TOTP is a &lt;strong&gt;shared secret&lt;/strong&gt;. When you scan that QR code, the server keeps a copy of the seed — something that can regenerate your codes. And the code itself is six digits that can be read over your shoulder, screenshotted, typed into the wrong box, or relayed by a real-time proxy: a fake login page that takes your code and replays it to the real portal inside the 30-second window. You do everything right and still lose.&lt;/p&gt;

&lt;p&gt;A passkey is different in kind, not in degree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The private half never leaves the authenticator. The server stores a &lt;strong&gt;public&lt;/strong&gt; key, so a database leak gives an attacker nothing to replay.&lt;/li&gt;
&lt;li&gt;The browser binds the credential to the &lt;strong&gt;origin&lt;/strong&gt;. A lookalike domain cannot invoke the passkey for the real one — not "shouldn't", &lt;em&gt;cannot&lt;/em&gt;. The relay attack above has nothing to relay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second property is the one that matters. Most 2FA advice is about making the user more careful. This one removes the situation where carefulness was the defence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TOTP is still enrolled
&lt;/h2&gt;

&lt;p&gt;Because recovery beats purity.&lt;/p&gt;

&lt;p&gt;Every service in the house sits behind this portal. If the passkey lives on one device and that device goes in a canal, an untested recovery path is not a recovery path. TOTP as break-glass is the honest trade: normal logins get the unphishable factor, and there is a second door I have actually opened.&lt;/p&gt;

&lt;p&gt;The uncomfortable part, stated plainly: &lt;strong&gt;a break-glass path is a path&lt;/strong&gt;. An attacker who can steer me to the "use a code instead" link gets phishability back. Keeping TOTP means keeping that floor. I accept it, knowing it's a real cost and not a technicality — the alternative is a single device standing between me and everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 2FA can't go
&lt;/h2&gt;

&lt;p&gt;The other thing reading that config taught me is that two rules in it are deliberate holes, and both are the same shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A vault whose native clients aren't browsers.&lt;/strong&gt; The browser extension and the mobile apps talk to API paths directly. They can't follow an HTML redirect to a login portal, so gating those paths doesn't secure them — it breaks every client. The rule gates the browser-only admin panel and bypasses the rest:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vault.example.internal'&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^/admin.*$'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;two_factor'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vault.example.internal'&lt;/span&gt;
      &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bypass'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Webhook endpoints.&lt;/strong&gt; A webhook is a machine posting to a URL. There is no human to prompt.&lt;/p&gt;

&lt;p&gt;The generalisation: &lt;strong&gt;an SSO portal authenticates browsers.&lt;/strong&gt; Anything that isn't a browser needs its own mechanism — a token, an API key, a signed payload — and pretending otherwise produces either a broken app or a bypass rule you wrote in a hurry and never revisited. Write the rule narrow, scope it to paths rather than whole hosts, and put the reason in a comment next to it. Mine has one, and it's the only reason I trusted it a year later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Two factors is a policy. &lt;em&gt;Which&lt;/em&gt; two is an enrolment. Those live in different places, and only one of them is in your config file.&lt;/p&gt;

&lt;p&gt;If your portal already says &lt;code&gt;two_factor&lt;/code&gt;, you are one enrolment screen away from unphishable logins — no redeploy, no YAML, and no reason to put it off as a project.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>security</category>
      <category>selfhosted</category>
      <category>authentication</category>
      <category>authelia</category>
    </item>
    <item>
      <title>eBay sold prices without the paid API: I built my own sold-comps engine and measured it</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Wed, 23 Sep 2026 08:00:03 +0000</pubDate>
      <link>https://dev.to/c1-anderson/i-built-my-own-ebay-sold-comps-engine-so-i-could-measure-whether-it-could-replace-the-paid-one-2d9c</link>
      <guid>https://dev.to/c1-anderson/i-built-my-own-ebay-sold-comps-engine-so-i-could-measure-whether-it-could-replace-the-paid-one-2d9c</guid>
      <description>&lt;h2&gt;
  
  
  The paid meter was running out
&lt;/h2&gt;

&lt;p&gt;I had a paid eBay sold-comparables service. 250 requests a month. Some products&lt;br&gt;
need more than two hundred listings before you get a usable price cluster, so&lt;br&gt;
one wrong query burns a week's ration. I started wondering: do I actually still&lt;br&gt;
need it, or could I grow my own?&lt;/p&gt;

&lt;p&gt;That question is empirical. The repo that came out of it is&lt;br&gt;
&lt;a href="https://github.com/casareanderson/sold-shadow" rel="noopener noreferrer"&gt;sold-shadow&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The premise (and the thing that made it possible)
&lt;/h2&gt;

&lt;p&gt;eBay's completed-listing search has been dead to automation for years. Free&lt;br&gt;
alternatives show asking prices, which are opinions. Paid services resell&lt;br&gt;
closed-access sold data, and they're metered.&lt;/p&gt;

&lt;p&gt;But an ended listing does not disappear from the Browse API. A &lt;code&gt;GET&lt;br&gt;
/item/{itemId}&lt;/code&gt; on a listing that finished last week returns HTTP 200 with the&lt;br&gt;
&lt;code&gt;itemEndDate&lt;/code&gt; field set, and the payload tells you whether anything actually&lt;br&gt;
sold. The signal is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OUT_OF_STOCK, estimatedRemainingQuantity 0, estimatedSoldQuantity &amp;gt;= 1  -&amp;gt; SOLD
IN_STOCK,     estimatedRemainingQuantity 1, estimatedSoldQuantity 0     -&amp;gt; ended unsold
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tested on thirty real listings re-fetched 6-8 days after first capture: 30/30&lt;br&gt;
returned 200, two had ended, one of those had sold. That proved the data doesn't&lt;br&gt;
evaporate.&lt;/p&gt;

&lt;p&gt;This uses a client-credentials token — the Browse API application-level flow.&lt;br&gt;
No user consent, no scraping, nothing that touches the API License Agreement&lt;br&gt;
edge. The same credentials that run publishing and orders.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;You tell it what to track. Not categories, not everything — the API quota is&lt;br&gt;
finite, and a corpus of products you never sell is worth zero. Each &lt;code&gt;track&lt;/code&gt;&lt;br&gt;
command stores one search phrase.&lt;/p&gt;

&lt;p&gt;A daily &lt;code&gt;sweep&lt;/code&gt; re-fetches every open listing, checks whether it ended, and&lt;br&gt;
records the outcome if it did. Self-capped at 600 calls per day against a 5,000&lt;br&gt;
Browse allowance. When a sale arrives, the listing becomes a permanent record:&lt;br&gt;
item ID, title, sold price or best-offer ask, the query that found it, and a&lt;br&gt;
flag for whether the listing accepted offers.&lt;/p&gt;

&lt;p&gt;You can also feed it your paid service's API key and run &lt;code&gt;compare&lt;/code&gt;, which&lt;br&gt;
fetches the same queries from the paid source. It caches responses for 24 hours&lt;br&gt;
so only the first run each day costs real quota, and it shows you exactly how&lt;br&gt;
far your own corpus is from matching the one you pay for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers said after a week
&lt;/h2&gt;

&lt;p&gt;After about seven days of daily sweeps on one install (measured evening of&lt;br&gt;
2026-09-12):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3,190 listings tracked across 54 queries&lt;/li&gt;
&lt;li&gt;14 confirmed sold&lt;/li&gt;
&lt;li&gt;9 of those were best-offer listings (upper bounds, not real prices)&lt;/li&gt;
&lt;li&gt;5 genuinely usable sold comparables&lt;/li&gt;
&lt;li&gt;5 queries out of 54 had at least one usable comp — &lt;strong&gt;9% coverage&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The switch-off bar I wrote into &lt;code&gt;shadow.py&lt;/code&gt; is 80% coverage within 15% of the&lt;br&gt;
paid source's median. The honest verdict: not close. One query had a real&lt;br&gt;
cluster (four Casio FX-CG50 sales in four days). The rest were n=1.&lt;/p&gt;

&lt;p&gt;That is not a soft disclaimer. A corpus is either mature or it is nothing, and&lt;br&gt;
you cannot know which it is without measuring it against truth. The &lt;code&gt;compare&lt;/code&gt;&lt;br&gt;
harness was built for that purpose, and putting it in the same repo forces the&lt;br&gt;
honest conversation: &lt;strong&gt;can I stop paying yet?&lt;/strong&gt; Most of the time, right now, no.&lt;/p&gt;

&lt;h2&gt;
  
  
  The things that bite (four of them)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No backfill, ever.&lt;/strong&gt; The search endpoint returns &lt;em&gt;active&lt;/em&gt; listings only. Any&lt;br&gt;
sale that completed before your first sweep is invisible. The highest-value&lt;br&gt;
action you can take with this repo is start it today and walk away for a month.&lt;br&gt;
Time is the only thing that grows a corpus.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best-offer sales are ceilings, not prices.&lt;/strong&gt; eBay never exposes the accepted&lt;br&gt;
offer on a best-offer listing. The ended item still shows the ask. On the data&lt;br&gt;
above, 9 of 14 sales were flagged as best-offer listings — excluding them loses&lt;br&gt;
64% of your comps, including them prices against a number nobody paid. They are&lt;br&gt;
stored with a flag, excluded from &lt;code&gt;sold_comps()&lt;/code&gt; by default, and returned&lt;br&gt;
separately by &lt;code&gt;best_offer_comps()&lt;/code&gt; so the caller can decide.&lt;/p&gt;

&lt;p&gt;The flag itself is permissive: it is set when &lt;code&gt;BEST_OFFER&lt;/code&gt; appears in&lt;br&gt;
&lt;code&gt;buyingOptions&lt;/code&gt;, which means the listing &lt;em&gt;accepted offers&lt;/em&gt;, not that &lt;em&gt;this&lt;br&gt;
particular sale went through one&lt;/em&gt;. On eBay UK most used fixed-price listings are&lt;br&gt;
best-offer. One product (the same Casio FX-CG50) had a clean sale at £89.99 and&lt;br&gt;
three best-offer asks at £80.00, £83.90 and £88.99 — the discarded asks sat&lt;br&gt;
&lt;em&gt;beneath&lt;/em&gt; the clean sale. Excluding them was not the conservative choice it&lt;br&gt;
looked like. &lt;code&gt;compare&lt;/code&gt; therefore counts them in a separate &lt;code&gt;bound&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auctions lie about their end date.&lt;/strong&gt; &lt;code&gt;itemEndDate&lt;/code&gt; is set at listing creation&lt;br&gt;
for auctions, not when they finish. The assumption that "live listings have a&lt;br&gt;
null end date" is true for fixed-price only. An earlier version of this code&lt;br&gt;
marked every auction as ended-unsold while it was still taking bids, and never&lt;br&gt;
revisited the row. Auctions could never contribute a comp. Since auctions are&lt;br&gt;
the cheap end of a used market, the corpus was biased upward by construction.&lt;br&gt;
The fix: a future end date now leaves the row open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One search string finds one slice.&lt;/strong&gt; eBay UK holds roughly twenty active&lt;br&gt;
listings of any mid-volume used product at once, and no single query sees all&lt;br&gt;
of them. The same case is listed as "Argon ONE M.2", "Raspberry Pi Argon One&lt;br&gt;
With" and "Argon One V2 aluminium". On the same install, adding alternative&lt;br&gt;
phrasings for the same product changed the count:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apple Magic Mouse: 210 → 388&lt;/li&gt;
&lt;li&gt;Casio FX-CG50: 18 → 104&lt;/li&gt;
&lt;li&gt;Xiaomi TV Box S: 36 → 81&lt;/li&gt;
&lt;li&gt;Raspberry Pi Argon ONE: 23 → 23 (no more to find — phrasings only help if the volume exists)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That was +640 listings for +100 API calls in a single sweep. Two rules from that&lt;br&gt;
run: keep a phrasing to eight words or fewer (a long string ANDs every word and&lt;br&gt;
returns &lt;em&gt;less&lt;/em&gt;), and cap it at three per product so a sweep of thirty products&lt;br&gt;
stays under a hundred searches.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the GitHub repo gives you
&lt;/h2&gt;

&lt;p&gt;Python 3.11+, &lt;strong&gt;stdlib only&lt;/strong&gt; — no dependencies. Three commands: &lt;code&gt;track&lt;/code&gt; to&lt;br&gt;
enrol a search phrase, &lt;code&gt;sweep&lt;/code&gt; as a daily cron job, &lt;code&gt;comps&lt;/code&gt; to see what has&lt;br&gt;
matured. A &lt;code&gt;compare&lt;/code&gt; harness that talks to a paid service's API so you can&lt;br&gt;
measure the gap. An MIT licence.&lt;/p&gt;

&lt;p&gt;The repo is&lt;br&gt;
&lt;a href="https://github.com/casareanderson/sold-shadow" rel="noopener noreferrer"&gt;https://github.com/casareanderson/sold-shadow&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;This is not a story about replacing a paid tool. It is a story about building a&lt;br&gt;
thing, being forced to measure it honestly, and discovering that the hard&lt;br&gt;
constraint is not the API quota — it is calendar time. You cannot backfill a&lt;br&gt;
used-goods market from the Browse API. You can only watch and wait.&lt;/p&gt;

&lt;p&gt;The sold-shadow code works. The corpus, after a week, does not. Both of those&lt;br&gt;
statements are visible in the same output, and that is the point.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Start the sweeps today. Check back in a month.&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>api</category>
      <category>python</category>
      <category>opensource</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Naming AI agents: what the CIA's declassified cryptonym convention taught me</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Tue, 22 Sep 2026 08:00:04 +0000</pubDate>
      <link>https://dev.to/c1-anderson/aedinosaur-what-a-declassified-naming-convention-taught-me-about-naming-agents-5b9</link>
      <guid>https://dev.to/c1-anderson/aedinosaur-what-a-declassified-naming-convention-taught-me-about-naming-agents-5b9</guid>
      <description>&lt;p&gt;I have a lot of agents running on my homelab, and they were all called things like&lt;br&gt;
&lt;code&gt;writer&lt;/code&gt;, &lt;code&gt;coder&lt;/code&gt;, &lt;code&gt;sec&lt;/code&gt;. Fine. Boring, but fine.&lt;/p&gt;

&lt;p&gt;Then I went looking at how a real service names things, and found out that one flat&lt;br&gt;
name is not how it's done at all. You get several, and they do different jobs. That&lt;br&gt;
turned into a small single-file web toy — &lt;a href="https://casareanderson.github.io/cryptonym-desk/" rel="noopener noreferrer"&gt;Cryptonym Desk&lt;/a&gt;&lt;br&gt;
— and the interesting parts were not the ones I expected.&lt;/p&gt;

&lt;p&gt;Let me start with the honest bit, because it shapes everything after it.&lt;/p&gt;
&lt;h2&gt;
  
  
  This is not a market gap
&lt;/h2&gt;

&lt;p&gt;Name generators are one of the most crowded categories on the web. I knew that&lt;br&gt;
before I wrote a line. I have ten products on a store with effectively no sales,&lt;br&gt;
and the lesson I keep re-learning is that the bottleneck is &lt;strong&gt;distribution, not&lt;br&gt;
ideas&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So this was built on a stated basis: a cheap, shareable link. Not revenue. Not a&lt;br&gt;
product. Something with one good detail in it that someone might pass on. If you&lt;br&gt;
are building your fifteenth side project this year, it is worth writing that&lt;br&gt;
sentence down before you start, because it changes what you optimise for.&lt;/p&gt;

&lt;p&gt;What follows is what actually turned out to be interesting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three names, because one name is wrong
&lt;/h2&gt;

&lt;p&gt;The convention I borrowed from is the cryptonym system in declassified US&lt;br&gt;
intelligence files. The shape is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A two-letter digraph, then a word chosen to mean nothing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AEDINOSAUR&lt;/code&gt;. &lt;code&gt;QKACTIVE&lt;/code&gt;. &lt;code&gt;LIENVOY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The digraph is the part people miss. It isn't decoration — it encodes which&lt;br&gt;
office or country the material belongs to, and the word after it is &lt;em&gt;deliberately&lt;/em&gt;&lt;br&gt;
meaningless, because a meaningful codename leaks. If you call an operation&lt;br&gt;
&lt;code&gt;NIGHTFALL&lt;/code&gt; you've already told me something about the mood of it. &lt;code&gt;AEDINOSAUR&lt;/code&gt;&lt;br&gt;
tells you nothing, which is the whole point.&lt;/p&gt;

&lt;p&gt;The digraphs on the page are real ones pulled from declassified material, which is&lt;br&gt;
the detail that makes it feel grounded rather than generated.&lt;/p&gt;

&lt;p&gt;Then two more names, because the cryptonym can't do every job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A working alias&lt;/strong&gt; — a human-looking name, for when the agent has to appear as
a person in a log or a commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A field codename&lt;/strong&gt; — &lt;code&gt;ADJECTIVE NOUN&lt;/code&gt;, for traffic that must not carry the
alias at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three names, three purposes. That structure is what makes it not-another-generator,&lt;br&gt;
and it came entirely from reading how the real thing works instead of guessing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The alias is a mashup, and the seams are ugly on purpose
&lt;/h2&gt;

&lt;p&gt;This is the part I'd want to read about, so here it is properly.&lt;/p&gt;

&lt;p&gt;The working alias is not picked from a list. It is a &lt;strong&gt;graft&lt;/strong&gt;: two source names are&lt;br&gt;
split at their vowel groups and the front of one is joined to the tail of the other.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Spiegel  +  Kusanagi   -&amp;gt;   Spienagi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The card shows you which two names were cut, because the join is most of the fun.&lt;/p&gt;

&lt;p&gt;Now the bit that surprised me. My splitter is crude. It finds vowel groups with a&lt;br&gt;
blunt regex and cuts there, with no understanding of onsets, codas, or where a&lt;br&gt;
syllable legally breaks in English. My first instinct was to fix that — pull in a&lt;br&gt;
proper syllabifier, get the linguistics right.&lt;/p&gt;

&lt;p&gt;I tried it. &lt;strong&gt;The names got worse.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A correct syllabifier gives you tidy, well-formed seams, and a tidy seam produces a&lt;br&gt;
name that sounds like it was always going to exist: plausible, forgettable, dull. The&lt;br&gt;
crude cut sometimes lands mid-cluster and produces something with a slight wrongness&lt;br&gt;
to it — and &lt;em&gt;that&lt;/em&gt; is what makes a name sound like a codename rather than a&lt;br&gt;
colleague. The bad algorithm is the feature.&lt;/p&gt;

&lt;p&gt;The only correction I kept is collapsing a tripled letter at the join, so you get&lt;br&gt;
&lt;code&gt;Rippley&lt;/code&gt; and not &lt;code&gt;Ripppley&lt;/code&gt;. That one is just a typo, not a texture.&lt;/p&gt;

&lt;p&gt;There's a general lesson hiding in there: when you're generating something whose&lt;br&gt;
purpose is to feel a certain way, "more correct" and "better" can point in opposite&lt;br&gt;
directions. Measure the output, not the algorithm's respectability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The records are seeded, and everyone finds this weird
&lt;/h2&gt;

&lt;p&gt;Type the same inputs in twice and you get the same record back. Same cryptonym,&lt;br&gt;
same alias, same codename.&lt;/p&gt;

&lt;p&gt;That's deliberate — the inputs plus a salt seed the generator, so a given set of&lt;br&gt;
material always rebuilds the same identity. It means a record is reproducible: you&lt;br&gt;
can hand someone the inputs instead of the output. Variety comes from an explicit&lt;br&gt;
&lt;strong&gt;"Issue new record"&lt;/strong&gt; action, not from mashing the same button and hoping.&lt;/p&gt;

&lt;p&gt;Every single person who has used it has hit this and assumed it was broken. I put it&lt;br&gt;
in the README, and I'd now argue that if a design decision reliably surprises users,&lt;br&gt;
the README is not where the explanation belongs — the UI should say it. That's the&lt;br&gt;
next change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deployment trap: an artifact is not a web page
&lt;/h2&gt;

&lt;p&gt;The thing also exists as a Claude artifact, and I assumed the artifact HTML and the&lt;br&gt;
GitHub Pages HTML would be the same file. They are not, and the difference cost me&lt;br&gt;
a confusing ten minutes.&lt;/p&gt;

&lt;p&gt;The artifact host supplies things I'd taken for granted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the doctype&lt;/li&gt;
&lt;li&gt;the charset and viewport meta tags&lt;/li&gt;
&lt;li&gt;a small CSS reset&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy that file to a static host and it renders subtly wrong — wrong scaling on&lt;br&gt;
mobile, a mystery margin round the edge. The standalone version needed all of the&lt;br&gt;
above added back plus an explicit &lt;code&gt;body { margin: 0 }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So if you publish a page in both places, don't copy one to the other. Keep the&lt;br&gt;
standalone file as the real one and re-wrap for the artifact, not the reverse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's made of
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;index.html&lt;/code&gt;. No dependencies, no build step, no framework, no network calls —&lt;br&gt;
nothing you type leaves the browser. Three fonts doing three jobs: Antonio for&lt;br&gt;
display, Share Tech Mono for file numbers, and Public Sans for body text, which is&lt;br&gt;
an actual government typeface and therefore exactly right for the subject.&lt;/p&gt;

&lt;p&gt;It's MIT, it's about a screen and a half of JavaScript, and you can read all of it&lt;br&gt;
in one sitting: &lt;strong&gt;&lt;a href="https://github.com/casareanderson/cryptonym-desk" rel="noopener noreferrer"&gt;github.com/casareanderson/cryptonym-desk&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go and issue yourself a record. If the alias it grafts for you is slightly wrong in&lt;br&gt;
a way you like, that's the crude splitter doing its job.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Spotify + djay on an iPad as a hands-off party DJ: my child's baptism, and an agent that ignored the brief</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Mon, 21 Sep 2026 08:00:03 +0000</pubDate>
      <link>https://dev.to/c1-anderson/hands-off-dj-for-my-childs-baptism-spotify-djay-an-ipad-and-an-agent-that-ignored-the-brief-4gib</link>
      <guid>https://dev.to/c1-anderson/hands-off-dj-for-my-childs-baptism-spotify-djay-an-ipad-and-an-agent-that-ignored-the-brief-4gib</guid>
      <description>&lt;p&gt;My child's baptism party was at a pub. I wanted proper music — music jukebox, not a Spotify "Chill Hits" playlist — and I did not want to spend the day standing over an iPad.&lt;/p&gt;

&lt;p&gt;This is how that worked: Hermes (the agent that runs on my homelab) and Claude Code built the playlist between them, and the iPad just played it. It also covers the part that didn't work, because the first version the agent built was wrong in a way that would have been embarrassing at a christening.&lt;/p&gt;

&lt;h2&gt;
  
  
  The brief
&lt;/h2&gt;

&lt;p&gt;I wrote a structure, not a list of songs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;warm R&amp;amp;B while people arrive (Jorja Smith, Tems)&lt;/li&gt;
&lt;li&gt;afrobeats as the energy lifts&lt;/li&gt;
&lt;li&gt;UK garage for the peak&lt;/li&gt;
&lt;li&gt;afro / reggae&lt;/li&gt;
&lt;li&gt;an outro to close the day&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And a reference: Chuckie Online's mixes — his apartment life mix and his BBC 1Xtra R&amp;amp;B Slow Jamz mix. The instruction was that his selection is the &lt;strong&gt;base&lt;/strong&gt;, not a flavour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt one: the agent ran the wrong job
&lt;/h2&gt;

&lt;p&gt;Hermes has a Spotify skill I'd built for weekly mixes. Given the baptism brief, it didn't follow it. It ran the weekly-mix defaults instead and produced &lt;strong&gt;60 tracks of 90s and modern rap, with duplicates and explicit tracks&lt;/strong&gt;. At a baptism.&lt;/p&gt;

&lt;p&gt;The cause was a gap, not a bad model: the Spotify tool had a "make a playlist from these genres" mode and no "build from a brief or a reference tracklist" mode. With no tool that matched the job, the agent reached for the nearest one and reported success.&lt;/p&gt;

&lt;p&gt;Two more things the brief itself got wrong, because an LLM had helped write it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credits were wrong.&lt;/strong&gt; "Essence" is Wizkid featuring Tems; "Location" is Dave featuring Burna Boy; "Re-Rewind" is Artful Dodger featuring Craig David. Every credit had to be checked by search.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The artist links were invalid IDs.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Attempt two: rebuilt from the brief, by hand
&lt;/h2&gt;

&lt;p&gt;I took it off Hermes and rebuilt it with Claude Code, using Hermes's Spotify credentials and box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reference tracklists.&lt;/strong&gt; A SoundCloud mix page has no tracklist. The YouTube upload of the same mix does, in its &lt;strong&gt;chapters&lt;/strong&gt;. The BBC programme pages list every track for his 1Xtra episodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Baptism rules.&lt;/strong&gt; Drop anything that only exists as an explicit version, and drop the bedroom slow jams. There are certain 90s R&amp;amp;B songs your nan shouldn't hear at a christening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocks, not a shuffle.&lt;/strong&gt; The final running order:&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Block&lt;/th&gt;
&lt;th&gt;Opens with&lt;/th&gt;
&lt;th&gt;Length&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Warm R&amp;amp;B&lt;/td&gt;
&lt;td&gt;Essence — Wizkid, Tems&lt;/td&gt;
&lt;td&gt;40 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reggae / lovers rock&lt;/td&gt;
&lt;td&gt;I Feel Good — Beres Hammond&lt;/td&gt;
&lt;td&gt;56 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Afrobeats&lt;/td&gt;
&lt;td&gt;Last Last — Burna Boy&lt;/td&gt;
&lt;td&gt;56 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Afro house&lt;/td&gt;
&lt;td&gt;I Adore You — HUGEL, Topic&lt;/td&gt;
&lt;td&gt;43 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK garage&lt;/td&gt;
&lt;td&gt;Re-Rewind — Artful Dodger&lt;/td&gt;
&lt;td&gt;26 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old-school dancehall&lt;/td&gt;
&lt;td&gt;Heads High — Mr. Vegas&lt;/td&gt;
&lt;td&gt;42 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modern R&amp;amp;B&lt;/td&gt;
&lt;td&gt;Is This Real Love? — Debbie&lt;/td&gt;
&lt;td&gt;32 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow jams&lt;/td&gt;
&lt;td&gt;When I See U — Fantasia&lt;/td&gt;
&lt;td&gt;61 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outro&lt;/td&gt;
&lt;td&gt;Where Did I Go? — Jorja Smith → By Your Side — Sade&lt;/td&gt;
&lt;td&gt;18 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;101 tracks, 6.2 hours.&lt;/strong&gt; It peaked at 233 tracks and 14.5 hours before I cut it back to the order I actually wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spotify API fights back
&lt;/h2&gt;

&lt;p&gt;It's worth knowing about these if you're building anything on Spotify's API in 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A removed endpoint returns 403, not 404.&lt;/strong&gt; Spotify renamed several endpoints in February 2026 (&lt;code&gt;/playlists/{id}/tracks&lt;/code&gt; → &lt;code&gt;/items&lt;/code&gt;, &lt;code&gt;POST /users/{id}/playlists&lt;/code&gt; → &lt;code&gt;POST /me/playlists&lt;/code&gt;). The old paths answer &lt;code&gt;403 Forbidden&lt;/code&gt;, which looks exactly like a permissions problem. I lost hours re-consenting scopes before checking the path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search caps at 10 results&lt;/strong&gt; for a development-mode app, and &lt;code&gt;year:&lt;/code&gt; / &lt;code&gt;genre:&lt;/code&gt; filters return nothing. Era and style have to come from how you phrase the search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't read other people's playlists' tracks&lt;/strong&gt; in dev mode, so "copy Chuckie's playlist" isn't possible. The YouTube chapters were the workaround.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit.&lt;/strong&gt; About 250 searches in one evening got a 429 with a retry-after of &lt;strong&gt;77,746 seconds — 21.6 hours&lt;/strong&gt;. A second Spotify app didn't help, because the throttle is per user account, not per app. So the rest was scheduled on Hermes's box: one timer to add about 40 Chuckie tracks the next evening, and a second to reorder the playlist into the final blocks, which only ran once the first had succeeded. Both ran unattended the night before the party.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The playback: djay on an iPad, hands off
&lt;/h2&gt;

&lt;p&gt;The player was &lt;strong&gt;djay Pro on an iPad, logged into Spotify inside djay&lt;/strong&gt;, on the 7-day free trial. So the playlist Hermes's box built was just there in djay's library, and djay's Automix did the transitions. The iPad connected to the pub's speaker over &lt;strong&gt;Bluetooth&lt;/strong&gt;, and it held up with no drop-outs.&lt;/p&gt;

&lt;p&gt;You can also &lt;strong&gt;wire it&lt;/strong&gt;: a USB-C (or Lightning) to 3.5mm/aux adapter into the pub's mixer or amp. Wired is the safer choice if the speaker is a long way away or the room is full of phones. It also has no Bluetooth delay, and with a USB audio interface you get a headphone output to hear the next track before the room does — which is what you'd need to mix by hand.&lt;/p&gt;

&lt;p&gt;The settings that made it hands-off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shuffle off.&lt;/strong&gt; The order is the whole point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automix on, with short transitions (about 10 seconds).&lt;/strong&gt; Long blends make two rappers talk over each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max song length about 2:30–2:45.&lt;/strong&gt; Most 90s tracks carry a minute of outro nobody dances to. That turns the 6.2 hours into about 4.5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto gain and keylock on.&lt;/strong&gt; 90s masters are much quieter than modern ones, and keylock stops voices going chipmunk when tempos are matched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit content off, repeat on, iPad auto-lock off, Do Not Disturb on.&lt;/strong&gt; Explicit off is the belt-and-braces for a christening. Repeat means the music never stops. Auto-lock and Do Not Disturb are the two ways an iPad can kill the music mid-party.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Bluetooth, don't mix by hand.&lt;/strong&gt; You can't hear the next track before the room does, and the audio lags the screen. Let Automix do it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only thing a human does is steer: if the room is ready early, jump to the first song of the Afrobeats block; when people start leaving, jump to the slow jams, then the outro. The blocks mean that one tap changes the mood without breaking the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Hermes actually did, and didn't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Did:&lt;/strong&gt; hold the Spotify skill, credentials and scheduling that everything else ran on. The two timers that finished the playlist ran on its box, unattended, the night before the party.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Didn't:&lt;/strong&gt; build this playlist. Given the brief, it ran the wrong job and called it done; the actual build was done by hand with Claude Code. The fix wasn't a better prompt. It was noticing that the tool had no mode for the job, so the agent improvised.&lt;/p&gt;

&lt;p&gt;That's the lesson I keep relearning with agents: &lt;strong&gt;when there's no tool that fits the job, they don't stop and say so — they use the nearest tool and report success.&lt;/strong&gt; Check the output against the brief, not against the agent's summary.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>homelab</category>
      <category>ai</category>
      <category>spotify</category>
      <category>djay</category>
    </item>
    <item>
      <title>AI blog writing pipeline without made-up facts: how I post daily from real work</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 18 Sep 2026 08:00:02 +0000</pubDate>
      <link>https://dev.to/c1-anderson/how-i-post-every-day-without-a-content-team-or-a-lying-robot-the-writer-pipeline-that-turns-real-4kgb</link>
      <guid>https://dev.to/c1-anderson/how-i-post-every-day-without-a-content-team-or-a-lying-robot-the-writer-pipeline-that-turns-real-4kgb</guid>
      <description>&lt;p&gt;I publish a homelab post most days. I don't have a content team, I don't have a&lt;br&gt;
backlog of pre-written drafts, and — this is the part that matters — I don't have&lt;br&gt;
an AI cheerfully inventing things I "did" so it can hit a daily quota.&lt;/p&gt;

&lt;p&gt;What I have is a small pipeline that turns &lt;em&gt;work I actually did&lt;/em&gt; into posts, with a&lt;br&gt;
strict rule about what the AI is and isn't allowed to do. This post is that&lt;br&gt;
pipeline, explained — and, fittingly, it came through the pipeline itself. Let me&lt;br&gt;
show you the machine, then prove it by telling you how &lt;em&gt;this&lt;/em&gt; got made.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem with "just get AI to write your blog"
&lt;/h2&gt;

&lt;p&gt;The obvious version of this is a disaster, and it's worth being clear about why.&lt;/p&gt;

&lt;p&gt;If you point a language model at "write me a post about my homelab this week," it&lt;br&gt;
will write you a post. It will be fluent, confident, and it will contain things&lt;br&gt;
that never happened — a metric it made up, a feature you didn't ship, a lesson&lt;br&gt;
from a bug you never hit. Not because it's malicious, but because it was asked to&lt;br&gt;
&lt;em&gt;produce content&lt;/em&gt; and it has no way to tell the difference between a fact and a&lt;br&gt;
plausible sentence. A model told to fill a daily slot will fill it, truthfully or&lt;br&gt;
not, and you won't be able to see the seam.&lt;/p&gt;

&lt;p&gt;So the entire design of my pipeline is built to make that specific failure&lt;br&gt;
impossible. One rule underneath everything:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Scripts gather the facts. The model only ever phrases them.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The clever, non-deterministic part is allowed to &lt;em&gt;write&lt;/em&gt;. It is never allowed to&lt;br&gt;
&lt;em&gt;decide what's true&lt;/em&gt;. Here's how that splits into stages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 1 — a deterministic radar finds real topics
&lt;/h2&gt;

&lt;p&gt;Nothing about topic selection touches an AI. A plain script scans the actual&lt;br&gt;
evidence of what I've been doing — commit history across my repos, what changed,&lt;br&gt;
what shipped — and turns it into a list of candidate topics, each with the &lt;em&gt;real&lt;/em&gt;&lt;br&gt;
facts attached: the actual commit messages, the actual repo, the actual dates.&lt;/p&gt;

&lt;p&gt;This is the crucial move. The list of things worth writing about is derived from&lt;br&gt;
things that &lt;em&gt;demonstrably happened&lt;/em&gt;, mechanically, with no model in the loop. If I&lt;br&gt;
did nothing worth writing about this week, the radar says so — and the honest&lt;br&gt;
consequence is &lt;em&gt;fewer posts&lt;/em&gt;, not an invented one. A quiet week is allowed to be&lt;br&gt;
quiet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 2 — the model drafts, from those facts only
&lt;/h2&gt;

&lt;p&gt;Now the AI comes in, and its job is deliberately narrow: take &lt;strong&gt;one&lt;/strong&gt; candidate&lt;br&gt;
topic and its gathered facts, and write it up in my voice. It's handed the real&lt;br&gt;
commit history and told, in effect, &lt;em&gt;"only use this; if a detail isn't here, stay&lt;br&gt;
general rather than inventing a specific."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's a writer working from a brief, not a journalist making up a story. It phrases,&lt;br&gt;
structures, and finds the through-line — the things models are genuinely good at —&lt;br&gt;
but the raw material is all real, because it was gathered by Stage 1 and handed&lt;br&gt;
over. The model never went looking for a fact, so it never had the chance to&lt;br&gt;
fabricate one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 3 — two gates before anything is public
&lt;/h2&gt;

&lt;p&gt;A draft is not a post. Between "the model wrote something" and "the world sees it"&lt;br&gt;
there are two hard gates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An automated leak gate.&lt;/strong&gt; The draft is scanned for anything that should never
be public — real IP addresses, hostnames, secrets, personal details. Anything
that trips it gets quarantined, not published. This is a machine check, not a
judgment call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human gate.&lt;/strong&gt; I read it. A draft lands in a review folder, and it only enters
the publish queue when I approve it. This is the one step I refuse to automate,
because a structural check can catch a leaked IP but it can't catch a subtly
overstated claim. A ten-second read by the person whose name is on it can.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after both gates does a draft join the queue.&lt;/p&gt;




&lt;h2&gt;
  
  
  Stage 4 — a boring publisher ships one a day
&lt;/h2&gt;

&lt;p&gt;The publishing itself is the dumbest part on purpose. A scheduled job wakes up&lt;br&gt;
once a day, takes the next approved post from the queue, publishes it, and moves&lt;br&gt;
on. If a publish fails, the post stays in the queue and tries again tomorrow — it&lt;br&gt;
never double-posts, and it never publishes something that isn't there. If the queue&lt;br&gt;
is empty, it does nothing.&lt;/p&gt;

&lt;p&gt;That "does nothing" is a feature. The whole system &lt;strong&gt;fails toward silence, never&lt;br&gt;
toward fabrication.&lt;/strong&gt; The worst case is that I post less often. The worst case is&lt;br&gt;
&lt;em&gt;not&lt;/em&gt; that a robot invents a week of accomplishments to keep the streak alive.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ethics bit, because it matters
&lt;/h2&gt;

&lt;p&gt;Every post this produces carries a visible line saying it was drafted with AI&lt;br&gt;
assistance from my own notes and then reviewed before publishing. I'm not going to&lt;br&gt;
pretend a human typed every word — that would be its own kind of lie. What I &lt;em&gt;can&lt;/em&gt;&lt;br&gt;
honestly claim is that every &lt;em&gt;fact&lt;/em&gt; is real, gathered mechanically, and that a&lt;br&gt;
human read it before it went out. "AI-assisted, human-verified, honestly labelled"&lt;br&gt;
is a position I can defend. "Secretly generated, possibly fabricated, passed off as&lt;br&gt;
hand-written" is not, and the pipeline is designed so I never drift into the second&lt;br&gt;
one by accident.&lt;/p&gt;




&lt;h2&gt;
  
  
  Proof: how this exact post was made
&lt;/h2&gt;

&lt;p&gt;Fitting, given the subject:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The radar flagged that I'd been building a content pipeline — from the real
commits, not from a prompt.&lt;/li&gt;
&lt;li&gt;The model drafted it from those facts, in my voice.&lt;/li&gt;
&lt;li&gt;It passed the leak gate (no infrastructure details leaked — you'll notice I've
named no IPs, no hostnames, no specific tools).&lt;/li&gt;
&lt;li&gt;I read it, edited the bits that were slightly off, and approved it.&lt;/li&gt;
&lt;li&gt;The daily publisher put it live, with its AI-assistance line at the bottom.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The system documents the system. That's either delightfully recursive or slightly&lt;br&gt;
cursed, and honestly it's both.&lt;/p&gt;




&lt;h2&gt;
  
  
  What to steal
&lt;/h2&gt;

&lt;p&gt;You don't need my code. The shape is the point:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gather facts with a script; let the model only phrase them.&lt;/strong&gt; The single most
important line in the whole design. It's the difference between a writing
assistant and a fabrication machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive topics from things that provably happened&lt;/strong&gt;, so you can't write about
work you didn't do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two gates: a machine one for leaks, a human one for judgment.&lt;/strong&gt; Automate the
check a computer does better; keep the check a person does better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail toward silence.&lt;/strong&gt; An empty queue posts nothing. Never build a system whose
failure mode is &lt;em&gt;inventing&lt;/em&gt; content to avoid a gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label it honestly.&lt;/strong&gt; AI-assisted and proud of it beats secretly-generated and
exposed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason I can post most days isn't that I found a robot that writes my blog. It's&lt;br&gt;
that I built a machine that turns my real work into drafts, keeps the AI on a very&lt;br&gt;
short leash, and lets me stay the editor. The cleverness writes. I still decide&lt;br&gt;
what's true. That division is the whole thing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by the pipeline it describes, reviewed by the human it describes, and&lt;br&gt;
posted by the boring scheduler it describes. Turtles all the way down.&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>writing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>ZimaBlade as a real home server (not a NAS appliance): Docker, disks and what to change</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 17 Sep 2026 08:00:04 +0000</pubDate>
      <link>https://dev.to/c1-anderson/running-a-zimablade-like-a-server-not-an-appliance-48ol</link>
      <guid>https://dev.to/c1-anderson/running-a-zimablade-like-a-server-not-an-appliance-48ol</guid>
      <description>&lt;p&gt;I bought the little ZimaBlade the way most of us buy homelab hardware: convinced it would be a tidy appliance I'd plug in and forget. A fanless board, a NAS-oriented OS, a photo library and a couple of containers, done.&lt;/p&gt;

&lt;p&gt;Three weeks later I was rooting around in Docker containers to borrow a root shell, because the thing I actually needed — my TLS certificates — had quietly fallen out of every backup.&lt;/p&gt;

&lt;p&gt;This post is the honest account of that, in the form of two recipes I've now verified enough times to trust. They're living in a public repo: &lt;a href="https://github.com/casareanderson/zimablade-recipes" rel="noopener noreferrer"&gt;https://github.com/casareanderson/zimablade-recipes&lt;/a&gt;. The specifics are ZimaOS-flavored, but the two lessons transfer to any small server, and honestly to any server at all:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When your platform refuses to give you root, the container runtime is right there, already privileged. Borrow it.&lt;/li&gt;
&lt;li&gt;Backing up "the data" is not backing up the system. If your certs, definitions, and config live outside the data, your recovery plan has a hole the size of everything you actually depend on.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me show you what broke, and what I changed so it wouldn't break again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the appliance assumption gets you
&lt;/h2&gt;

&lt;p&gt;ZimaOS is clever about being approachable. Apps get installed from a storefront, they get their own little sandboxed runtime, and the UI tells you everything is fine. That's the whole promise of the form factor: you never need to see a terminal.&lt;/p&gt;

&lt;p&gt;The trouble: an appliance treats you like a guest. Some operations — the ones that touch the OS itself, or that need a host-level privilege — simply aren't offered in the UI. There's no "give me a root shell" button, because the product assumes you'll never want one.&lt;/p&gt;

&lt;p&gt;I wanted one. My setup needs to read some system state and reach interfaces that the sandboxed app runtime deliberately keeps out of reach. Nothing malicious — the kind of thing a normal Linux admin does without thinking — but the platform had no sanctioned path for it.&lt;/p&gt;

&lt;p&gt;I spent a frustrating evening looking for the "correct" way. There wasn't one. Which is the first honest lesson: when a platform assumes you're a guest and you actually need to be an operator, don't burn the whole night searching for a supported path that doesn't exist. Look for the seam that's already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seam: the container runtime is already root
&lt;/h2&gt;

&lt;p&gt;Here's the thing about any of these little appliance-boxes running an app sandbox: under the hood, the apps are containers, and containers are just processes. Somewhere on that board there is a container with root privileges — your app containers almost certainly run as root inside their own sandbox by default. If you can get a shell into one of them, you have effectively borrowed root.&lt;/p&gt;

&lt;p&gt;The recipe is short. Get a shell inside a running container — most of these runtimes let you do that from the UI or a shell command — and from there you can mount, inspect, and reach the host pieces you need. The container's boundary is exactly as high as you think it isn't.&lt;/p&gt;

&lt;p&gt;Two things that made this work reliably for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only first.&lt;/strong&gt; Before changing anything, use the borrowed root to look. What's actually mounted on the host? Where are the app definitions? What has write access to what? You'll learn more about your own box in ten minutes of read-only poking than in a month of UI clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what you did.&lt;/strong&gt; The whole reason this lives in a repo instead of my memory is that I forget shell one-liners. Every command I actually use to borrow root, and every command that depends on it, is now a numbered recipe so I can reproduce it after an update changes something.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson that transfers beyond this box: when you're locked out of a system you own, look through the layer that's already entitled. It's usually the container runtime, and it's usually a few minutes of work to reach the access that is rightfully yours, to your own hardware, for the purpose you bought it for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recipe 02: backups that actually include your certificates
&lt;/h2&gt;

&lt;p&gt;The second thing that broke taught me more, because it failed slowly and silently.&lt;/p&gt;

&lt;p&gt;I set up what I thought was a solid photo-library backup. The library lives on the ZimaBlade; a scheduled job pushes it offsite to a low-power box at a relative's place, over an encrypted connection, starting with the originals. That part worked, and still works.&lt;/p&gt;

&lt;p&gt;The failure was everything &lt;em&gt;around&lt;/em&gt; the photos.&lt;/p&gt;

&lt;p&gt;TLS certificates, for one. My offsite photo job, like most, transfers application data — the files the app owns. It does not transfer the platform's &lt;em&gt;identity&lt;/em&gt; material: the certificate store, the keys, the trust configuration that lets my other devices authenticate to this box without heaving a warning every time. Rebuild that little board from scratch and the data comes back, but the TLS identity is gone, and reinstating it is not a button press. It's a day of regenerating, re-importing, and re-distributing trust — during which every client that talked to this device is throwing TLS errors.&lt;/p&gt;

&lt;p&gt;And not just certificates. I realized my backup schedule covered the &lt;em&gt;data&lt;/em&gt; the apps produced, but nothing about the &lt;em&gt;definitions&lt;/em&gt; of the apps themselves. Recreate the box, and you've got the photos but you've lost the runnable setup — the storefront installs, the config, the file layout — that made the box what it was. Rebuilding that by hand is exactly the soul-destroying work I was trying to avoid by having backups.&lt;/p&gt;

&lt;p&gt;So recipe 02 became two rules, and both are now encoded in the repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Back up the certificates alongside the data.&lt;/strong&gt; Yes, treat secret material with appropriate care about where it lands and who can read it — but "too sensitive to back up" is how you end up with no recovery path at all. Encrypt it, restrict it, and ship it. The alternative is a box whose data you can restore but whose identity you can't, which is a different, worse kind of broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up the app definitions, not just the data.&lt;/strong&gt; If you can't recreate the application layer from a list of what's installed and how it was configured, your backup is a partial backup. The photos coming back means nothing if the setup that served them is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general form of the lesson, and the one I actually want you to keep: &lt;strong&gt;a backup is only as good as the smallest thing you depend on that it doesn't include.&lt;/strong&gt; Most of us design backups around the data we love — the photos, the library, the documents. But the things we truly depend on to run are often smaller, uglier, and easier to miss: a key, a config file, a manifest of installed apps. Data recovery without identity and definition recovery is a museum piece, not a working server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why they live in a repo
&lt;/h2&gt;

&lt;p&gt;Everything above has the same shape: an observed problem, a command that fixed it, a note about where it broke. That's a recipe. And recipes rot if you don't keep them.&lt;/p&gt;

&lt;p&gt;A few conventions I've settled into, because they're what made the difference between "notes I trust" and "notes I'd have to re-derive":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Genericize the site-specific details.&lt;/strong&gt; Early drafts of my recipes were full of the specific hostname, the specific path, the specific network names for &lt;em&gt;my&lt;/em&gt; setup. I stripped all of that out and replaced it with placeholders. Now the recipes are comprehensible to anyone, and — just as important — they force me to think about &lt;em&gt;why&lt;/em&gt; a step exists, not just copy it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-link to related recipes.&lt;/strong&gt; The GPU-report note and these two recipes reference each other, so a future-me landing on one page finds the other two things I also need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down submission notes.&lt;/strong&gt; When I post a recipe publicly, I note where it was shared and why, so months later I can tell which fixes actually helped people and which were just me.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is glamorous. It's the "here's what worked AND what broke" record-keeping that makes tinkering with a tiny server feel less like tinkering and more like engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The version I'd tell myself at the start
&lt;/h2&gt;

&lt;p&gt;If I could hand a note to the version of me that unboxed this board, it would say three things.&lt;/p&gt;

&lt;p&gt;First, an appliance is a marketing stance, not a property of the hardware. The moment you need one thing that the UI doesn't offer, you're a server operator whether you signed up for it or not — and the container runtime is ready to give you the access the menu won't.&lt;/p&gt;

&lt;p&gt;Second, back up the whole system you depend on, not just the data you like. The certificates and the app definitions are not optional extras; they're the difference between a restorable box and a shiny paperweight.&lt;/p&gt;

&lt;p&gt;Third, write it down the minute it works. Reproduce it, genericize it, and put it somewhere you'll actually find it again. Future you is a worse archivist than you think.&lt;/p&gt;

&lt;p&gt;The recipes are public — borrow the root access technique, check your own backup for the missing certificates — at &lt;a href="https://github.com/casareanderson/zimablade-recipes" rel="noopener noreferrer"&gt;https://github.com/casareanderson/zimablade-recipes&lt;/a&gt;. Contribute back if you find a step that's wrong on your board; that's the whole point of sharing them.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;It's a server. They just hadn't told you yet.&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>docker</category>
      <category>hardware</category>
      <category>infrastructure</category>
      <category>linux</category>
    </item>
    <item>
      <title>Free LLM API endpoints: a radar that finds them and a five-part gate before adopting one</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Wed, 16 Sep 2026 08:00:03 +0000</pubDate>
      <link>https://dev.to/c1-anderson/i-run-a-radar-that-finds-free-llm-endpoints-and-auto-adopts-the-good-ones-behind-a-five-part-3bja</link>
      <guid>https://dev.to/c1-anderson/i-run-a-radar-that-finds-free-llm-endpoints-and-auto-adopts-the-good-ones-behind-a-five-part-3bja</guid>
      <description>&lt;p&gt;Free LLM endpoints appear and vanish constantly — a provider opens a generous&lt;br&gt;
tier, a new open model shows up on someone's gateway, a promo runs for a month.&lt;br&gt;
Chasing them by hand is a losing game. So I built a small radar&lt;br&gt;
(&lt;a href="https://github.com/casareanderson/model-radar" rel="noopener noreferrer"&gt;model-radar&lt;/a&gt;) that watches for&lt;br&gt;
free models and, when one is genuinely good enough, &lt;strong&gt;adopts it automatically&lt;/strong&gt; into&lt;br&gt;
my agent stack — but only after it clears a gate strict enough that it has never&lt;br&gt;
once adopted something that embarrassed me.&lt;/p&gt;

&lt;p&gt;The auto-adoption is the flashy bit. The &lt;em&gt;gate&lt;/em&gt; is the bit worth your time, because&lt;br&gt;
"just use the free model" is advice that will quietly wreck an agent pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why free models are a trap you still want to fall into
&lt;/h2&gt;

&lt;p&gt;The appeal is obvious: a lot of agent work — summarise, triage, draft, classify —&lt;br&gt;
doesn't need a frontier model, and paying frontier prices for it is silly. A free&lt;br&gt;
tier that's "good enough" for the boring 90% is real money saved.&lt;/p&gt;

&lt;p&gt;The catch is that "free model" and "works in an agent" are almost unrelated&lt;br&gt;
properties. A model can score beautifully on a leaderboard and be &lt;strong&gt;useless for&lt;br&gt;
agentic work&lt;/strong&gt;, because agent work needs things benchmarks don't measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reliable tool-calling.&lt;/strong&gt; The model must emit correct, structured tool calls —
and, more importantly, actually &lt;em&gt;use the results&lt;/em&gt; it gets back. Plenty of models
emit a tidy call and then ignore the response, which is &lt;em&gt;worse&lt;/em&gt; than not calling
the tool, because it looks like it's working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest context handling.&lt;/strong&gt; Some endpoints silently truncate a long prompt and
answer from the half they kept, with no error. Confident, wrong, and invisible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stability.&lt;/strong&gt; A free tier that rate-limits mid-task or disappears next week can't
be in your critical path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a radar that adopted models on &lt;em&gt;quality scores&lt;/em&gt; would be a machine for&lt;br&gt;
introducing silent failures. The whole design problem is: &lt;strong&gt;how do you let a&lt;br&gt;
process adopt something automatically without letting it adopt something bad?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The answer: adoption is a gate, not a vote
&lt;/h2&gt;

&lt;p&gt;The radar can &lt;em&gt;find&lt;/em&gt; candidates freely. It can &lt;em&gt;promote&lt;/em&gt; one only by passing every&lt;br&gt;
stage of a fixed, deterministic gate — and if any stage fails, it's rejected, full&lt;br&gt;
stop. No score, no averaging, no "it's mostly fine." The gate checks, in order,&lt;br&gt;
things a leaderboard never will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does it actually respond&lt;/strong&gt;, at the endpoint, right now, within a sane timeout?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can it tool-call for real&lt;/strong&gt; — emit a correct call &lt;em&gt;and&lt;/em&gt; incorporate the result
into its next turn — on a concrete test task, not a synthetic one?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it respect context&lt;/strong&gt; — feed it something long and confirm it didn't
silently drop the tail?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it stable enough&lt;/strong&gt; across repeated calls, not a one-lucky-response fluke?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it beat what I'm already running&lt;/strong&gt; on the specific jobs I'd actually route
to it?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only a model that clears all five gets adopted. Everything is &lt;strong&gt;measured on real&lt;br&gt;
tasks and written down&lt;/strong&gt;, so adoption is a reproducible decision I can audit later,&lt;br&gt;
not a vibe. And there's a blocklist: models measured to fail agentic use in a way&lt;br&gt;
that matters are banned by name, so the radar can't rediscover and re-adopt a known&lt;br&gt;
dud.&lt;/p&gt;

&lt;p&gt;The principle underneath is the same one my whole estate runs on: &lt;strong&gt;let the&lt;br&gt;
automated part gather and propose freely; let it &lt;em&gt;act&lt;/em&gt; only through a gate that a&lt;br&gt;
bad candidate cannot argue its way past.&lt;/strong&gt; A guardrail written as a suggestion in a&lt;br&gt;
prompt is decoration. A guardrail written as a pass/fail function is a wall.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it actually buys me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Most of my agent work runs on free or cheap models&lt;/strong&gt;, adopted automatically as
better free options appear, with the expensive frontier models reserved for the
genuinely hard slice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I'm not manually chasing&lt;/strong&gt; which provider opened a tier this week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And nothing junk gets in&lt;/strong&gt;, because adoption is gated on the properties that
actually matter for agents — tool-calling, context honesty, stability — not on a
benchmark number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure I designed the whole thing to avoid is the one that gets everyone else:&lt;br&gt;
a cheaper model quietly swapped in that &lt;em&gt;looks&lt;/em&gt; like it's working — emits calls,&lt;br&gt;
returns fluent text — while ignoring tool results or truncating context, producing&lt;br&gt;
confident garbage that no error ever flags. The gate exists specifically to catch&lt;br&gt;
the model that fails &lt;em&gt;silently&lt;/em&gt;, because that's the only kind of failure that&lt;br&gt;
survives long enough to hurt you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Take a look
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/casareanderson/model-radar" rel="noopener noreferrer"&gt;model-radar is on GitHub&lt;/a&gt;. Even if&lt;br&gt;
you never auto-adopt anything, the transferable idea is the gate: &lt;strong&gt;before you route&lt;br&gt;
real work to a free model, test it on the thing agents actually need — can it&lt;br&gt;
tool-call and use the result, does it keep your whole prompt, is it stable — and&lt;br&gt;
make that test a hard pass/fail, not a score.&lt;/strong&gt; Cheap intelligence is worth&lt;br&gt;
chasing. Cheap intelligence that fails silently is worth &lt;em&gt;fearing&lt;/em&gt;, and the only&lt;br&gt;
difference between the two is whether you gated it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Notes from someone who wanted a coffee-a-month agent bill and refused to pay for&lt;br&gt;
it in silent failures, so built a radar that adopts free models automatically and a&lt;br&gt;
gate strict enough to trust it.&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>automation</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Random song picker for Spotify: a physical-feeling 'jukebox dice' for 6,500 tracks</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Tue, 15 Sep 2026 08:00:04 +0000</pubDate>
      <link>https://dev.to/c1-anderson/i-built-a-physical-feeling-jukebox-dice-for-6500-tracks-tap-the-die-get-a-random-song-it-3cmo</link>
      <guid>https://dev.to/c1-anderson/i-built-a-physical-feeling-jukebox-dice-for-6500-tracks-tap-the-die-get-a-random-song-it-3cmo</guid>
      <description>&lt;p&gt;Choosing what to play is a solved problem right up until it's your turn to DJ at a&lt;br&gt;
family thing and everyone's watching. So I built the dumbest, most fun fix I could:&lt;br&gt;
&lt;a href="https://github.com/casareanderson/spin-that-dice" rel="noopener noreferrer"&gt;spin-that-dice&lt;/a&gt; — a jukebox&lt;br&gt;
&lt;em&gt;die&lt;/em&gt; you tap, and it picks a random track from a curated crate of thousands and&lt;br&gt;
just plays it. No scrolling, no "what should I put on," no dead air. Roll the dice,&lt;br&gt;
music happens.&lt;/p&gt;

&lt;p&gt;It's a small, silly project. It's also taught me two genuinely useful things — one&lt;br&gt;
about designing for &lt;em&gt;delight&lt;/em&gt; and one about a nasty API failure mode — so here's&lt;br&gt;
the tour.&lt;/p&gt;




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

&lt;p&gt;A crate of ~6,500 tracks, sorted into dozens of categories — hip hop, dancehall,&lt;br&gt;
afrobeats, Motown, soul, grime, amapiano, and a couple of dozen more. You "spin the&lt;br&gt;
die," it lands on a category, pulls a random track from it, and starts playback on&lt;br&gt;
your own account. The whole interaction is one tap and a moment of suspense, which&lt;br&gt;
turns out to be the entire point.&lt;/p&gt;

&lt;p&gt;The design goal wasn't "a better music browser." It was to &lt;strong&gt;remove choice as a&lt;br&gt;
source of friction and add a tiny bit of theatre.&lt;/strong&gt; A playlist makes you a curator;&lt;br&gt;
a dice roll makes you a participant. At a party, the second one is far more fun, and&lt;br&gt;
"what's it going to land on?" does more work than any recommendation algorithm.&lt;/p&gt;




&lt;h2&gt;
  
  
  The small design lesson: constraints are the feature
&lt;/h2&gt;

&lt;p&gt;The temptation with a project like this is to add: filters, history, skip-weighting,&lt;br&gt;
mood detection. I kept saying no, and the tool got &lt;em&gt;better&lt;/em&gt; every time I did.&lt;/p&gt;

&lt;p&gt;The magic is in the constraint. You &lt;strong&gt;don't&lt;/strong&gt; get to fine-tune it. You roll, you&lt;br&gt;
get what you get, you either vibe with it or roll again — and that little bit of&lt;br&gt;
surrender is exactly what makes it feel like a jukebox instead of a search box. Once&lt;br&gt;
I understood that the randomness &lt;em&gt;was&lt;/em&gt; the product, the rest of the design wrote&lt;br&gt;
itself: make the roll feel good, make the crate deep enough that repeats are rare,&lt;br&gt;
and get out of the way.&lt;/p&gt;

&lt;p&gt;If you build tools, it's a useful reminder: sometimes the feature is the thing you&lt;br&gt;
&lt;em&gt;refuse&lt;/em&gt; to let the user control.&lt;/p&gt;




&lt;h2&gt;
  
  
  The gnarly bit: a rate limit that re-arms while you wait
&lt;/h2&gt;

&lt;p&gt;Now the war story, because it's the transferable part.&lt;/p&gt;

&lt;p&gt;The tool drives playback through a third-party music API, and that API rate-limits.&lt;br&gt;
Fine — everyone rate-limits, you back off and retry. Except this one had a trap that&lt;br&gt;
turned a polite retry loop into a &lt;em&gt;permanent&lt;/em&gt; lockout.&lt;/p&gt;

&lt;p&gt;The failure: when I got throttled and backed off, the throttle window &lt;strong&gt;re-armed&lt;br&gt;
every time I probed it.&lt;/strong&gt; So my well-behaved "wait, then check if I'm allowed yet"&lt;br&gt;
loop was itself the thing keeping the door shut — each check reset the timer I was&lt;br&gt;
waiting on. A retry strategy that works against a normal rate limiter (poll until&lt;br&gt;
it clears) was exactly wrong here: the polling &lt;em&gt;was&lt;/em&gt; the problem, and the harder I&lt;br&gt;
tried, the longer I was locked out.&lt;/p&gt;

&lt;p&gt;The fix was counterintuitive: &lt;strong&gt;back off &lt;em&gt;longer&lt;/em&gt; and probe &lt;em&gt;less&lt;/em&gt;, not more.&lt;/strong&gt; Once&lt;br&gt;
I stopped hammering the check, the window actually expired and playback resumed. The&lt;br&gt;
lesson generalises past this one API:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not every rate limit clears on a fixed schedule. Some re-arm on contact — and&lt;br&gt;
for those, an aggressive retry loop is indistinguishable from a self-inflicted&lt;br&gt;
outage.&lt;/strong&gt; When backing off harder makes a "temporary" limit &lt;em&gt;worse&lt;/em&gt;, suspect your&lt;br&gt;
own retries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There was a second, dumber gotcha too: a LAN hostname for the thing needs a real DNS&lt;br&gt;
record, not just a local rewrite — but that's a story every self-hoster already&lt;br&gt;
knows in their bones.&lt;/p&gt;




&lt;h2&gt;
  
  
  Steal it, or the idea
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/casareanderson/spin-that-dice" rel="noopener noreferrer"&gt;spin-that-dice is on GitHub&lt;/a&gt;. If&lt;br&gt;
you want a zero-friction party player for your own crate, clone it and point it at&lt;br&gt;
your account. If you just want the idea: &lt;strong&gt;the next time you're building a&lt;br&gt;
"choose from a big list" interface, ask whether the choice is actually the fun&lt;br&gt;
part — or whether a dice roll would delight people more than a filter ever could.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And if you're building anything on a rate-limited API: find out whether its limits&lt;br&gt;
clear on a timer or re-arm on contact &lt;em&gt;before&lt;/em&gt; you write your retry loop, because&lt;br&gt;
those two worlds want opposite strategies, and guessing wrong locks you out of your&lt;br&gt;
own party.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;A genuinely daft little tool that's more fun than it has any right to be, plus one&lt;br&gt;
rate-limiting scar I'm still slightly annoyed about. Both real, both on the account&lt;br&gt;
I actually DJ from.&lt;/em&gt;&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>showdev</category>
      <category>api</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
