<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.paulsprogrammingnotes.com/atom.xml" rel="self" type="application/atom+xml" /><link href="https://www.paulsprogrammingnotes.com/" rel="alternate" type="text/html" /><updated>2026-08-17T05:19:19+00:00</updated><id>https://www.paulsprogrammingnotes.com/atom.xml</id><title type="html">Paul’s Programming Notes</title><author><name>Paul Brown</name></author><entry><title type="html">Claude Code - Using Context More Efficiently</title><link href="https://www.paulsprogrammingnotes.com/2026/08/claude-code-using-context-more-efficiently.html" rel="alternate" type="text/html" title="Claude Code - Using Context More Efficiently" /><published>2026-08-15T18:00:00+00:00</published><updated>2026-08-15T18:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/08/claude-code-using-context-more-efficiently</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/08/claude-code-using-context-more-efficiently.html"><![CDATA[<p>I’m trying to use Claude Code’s context more efficiently on long sessions. Anthropic published a much more thorough guide on the same topic the day before I wrote this: <a href="https://claude.com/blog/maximizing-the-value-of-your-claude-code-sessions">Maximizing the value of your Claude Code sessions</a>, covering things like <code class="language-plaintext highlighter-rouge">/clear</code> between tasks, @-mentioning files instead of naming them, and keeping <code class="language-plaintext highlighter-rouge">/model</code> and <code class="language-plaintext highlighter-rouge">/effort</code> settled so you don’t bust the prompt cache. Worth reading first. Two things I’ve been doing on top of it: a Stop hook that warns me when a session’s context gets too long, and a small skill for handing off work before it’s even started.</p>

<p>The hook fires after every response, so it can catch a session getting bloated before I notice the agent acting slower or worse:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Stop"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="p">{</span><span class="w">
        </span><span class="nl">"hooks"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"command"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"command"</span><span class="p">:</span><span class="w"> </span><span class="s2">"jq -r '.transcript_path' | { read -r t; tail -n 200 </span><span class="se">\"</span><span class="s2">$t</span><span class="se">\"</span><span class="s2"> | jq -s -r '[.[] | select(.type==</span><span class="se">\"</span><span class="s2">assistant</span><span class="se">\"</span><span class="s2"> and .message.usage != null)] | last as $l | if $l == null then empty else (($l.message.usage.input_tokens // 0) + ($l.message.usage.cache_creation_input_tokens // 0) + ($l.message.usage.cache_read_input_tokens // 0)) as $tot | if $tot &gt; 150000 then {systemMessage: (</span><span class="se">\"</span><span class="s2">Context ~</span><span class="se">\"</span><span class="s2"> + (($tot/1000)|floor|tostring) + </span><span class="se">\"</span><span class="s2">k tokens this turn (over 150k). Consider /compact, or say create handoff and start a fresh session.</span><span class="se">\"</span><span class="s2">)} else empty end end'; } 2&gt;/dev/null || true"</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The transcript is JSONL, one message per line, so <code class="language-plaintext highlighter-rouge">tail -n 200</code> finds the most recent assistant turn without scanning the whole file. The hook is read-only, so it just parses that file locally with <code class="language-plaintext highlighter-rouge">jq</code> and never talks to the model. What it’s reading is accounting the API already produces on every turn, hook or not. <code class="language-plaintext highlighter-rouge">usage</code> splits input tokens by whether they came from the prompt cache: <code class="language-plaintext highlighter-rouge">input_tokens</code> for what got processed fresh, <code class="language-plaintext highlighter-rouge">cache_creation_input_tokens</code> for what got newly cached, <code class="language-plaintext highlighter-rouge">cache_read_input_tokens</code> for what got served from an earlier turn’s cache. All three are still part of the model’s actual context for that turn, so summing just <code class="language-plaintext highlighter-rouge">input_tokens</code> badly undercounts a long session, where most of the history is being read from cache rather than reprocessed fresh. Past 150k the hook prints a <code class="language-plaintext highlighter-rouge">systemMessage</code>. Claude Code shows that inline without sending it back to the model, so even the warning itself costs nothing (<a href="https://code.claude.com/docs/en/hooks.md">hooks reference</a>).</p>

<p>A warning is only useful if there’s a good next step, and “just start a new chat” throws away real, unplanned-for work. Most of the time that means handing off work before it’s even started, not resuming something already underway. So I wrote <code class="language-plaintext highlighter-rouge">kickoff</code>, and kept the whole thing to one paragraph:</p>

<blockquote>
  <p>Write it as a prompt for the next session, not a document for a human.
That agent has the same repo access you do, so don’t summarize or quote
code; point at the files and areas that matter and let it read them
itself. Include the actual task, why it matters, and whatever took real
investigation to establish, especially constraints or dead ends already
found. End on the first concrete action, specific enough to act on
without re-deriving it.</p>
</blockquote>]]></content><author><name>Pawl</name></author><category term="llm" /><summary type="html"><![CDATA[A Stop hook that warns when a session's context gets too long, and a one-paragraph skill for handing off work that hasn't started yet.]]></summary></entry><entry><title type="html">JavaScript roguelike development in 2026</title><link href="https://www.paulsprogrammingnotes.com/2026/08/javascript-roguelike-development.html" rel="alternate" type="text/html" title="JavaScript roguelike development in 2026" /><published>2026-08-13T15:00:00+00:00</published><updated>2026-08-13T15:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/08/javascript-roguelike-development</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/08/javascript-roguelike-development.html"><![CDATA[<p>I’ve been building <a href="https://bruteslicer.com">Brute Slicer</a>, a turn-based tile dungeon crawler in TypeScript. Sharing a browser game is just sending a link, and it opens on a phone without an app store. Godot and Unity export to the web too, but that ships the engine itself as WebAssembly, and Godot’s export <a href="https://docs.godotengine.org/en/stable/tutorials/export/exporting_for_web.html">requires WebGL 2.0</a> in the browser. The game state only advances when the player takes a turn, and the browser handles the animations, so there’s no per-frame loop for an engine to run.</p>

<p><a href="https://github.com/ondras/rot.js">rot.js</a> is the standard roguelike toolkit and it covers the algorithm layer. I use it mostly for pathfinding, and it also handles dungeon generation, field of view and turn scheduling, with no dependencies of its own. Its README calls the project feature-complete, and the last release was a maintenance one in November 2024.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">Path</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">rot-js</span><span class="dl">'</span>

<span class="k">export</span> <span class="kd">function</span> <span class="nf">findPath</span><span class="p">(</span><span class="k">from</span><span class="p">:</span> <span class="nx">Pos</span><span class="p">,</span> <span class="nx">to</span><span class="p">:</span> <span class="nx">Pos</span><span class="p">,</span> <span class="nx">passable</span><span class="p">:</span> <span class="p">(</span><span class="nx">x</span><span class="p">:</span> <span class="kr">number</span><span class="p">,</span> <span class="nx">y</span><span class="p">:</span> <span class="kr">number</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">boolean</span><span class="p">):</span> <span class="nx">Pos</span><span class="p">[]</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">astar</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Path</span><span class="p">.</span><span class="nc">AStar</span><span class="p">(</span><span class="nx">to</span><span class="p">.</span><span class="nx">x</span><span class="p">,</span> <span class="nx">to</span><span class="p">.</span><span class="nx">y</span><span class="p">,</span> <span class="nx">passable</span><span class="p">,</span> <span class="p">{</span> <span class="na">topology</span><span class="p">:</span> <span class="mi">4</span> <span class="p">})</span>
  <span class="kd">const</span> <span class="nx">path</span><span class="p">:</span> <span class="nx">Pos</span><span class="p">[]</span> <span class="o">=</span> <span class="p">[]</span>
  <span class="nx">astar</span><span class="p">.</span><span class="nf">compute</span><span class="p">(</span><span class="k">from</span><span class="p">.</span><span class="nx">x</span><span class="p">,</span> <span class="k">from</span><span class="p">.</span><span class="nx">y</span><span class="p">,</span> <span class="p">(</span><span class="nx">x</span><span class="p">,</span> <span class="nx">y</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">path</span><span class="p">.</span><span class="nf">push</span><span class="p">({</span> <span class="nx">x</span><span class="p">,</span> <span class="nx">y</span> <span class="p">}))</span>
  <span class="k">return</span> <span class="nx">path</span>
<span class="p">}</span>
</code></pre></div></div>

<p><a href="https://github.com/phaserjs/phaser">Phaser</a> and <a href="https://github.com/excaliburjs/Excalibur">Excalibur</a> are both actively developed, and either one hands you a renderer, an input system and a camera. None of them has the RPG systems layer, so turn-based combat resolution, the inventory and equipment panels, save and load, and telegraphed enemy intent are all mine to write. I render SVG rather than canvas, so the camera that follows the player is mine too. rot.js never had one.</p>

<p><a href="https://github.com/RSamaium/RPG-JS">RPG-JS</a> comes closest, with inventory, skills, save and load, and prebuilt GUI screens. It’s built for RPG Maker-style games with maps you draw in the Tiled editor, and this game generates its floors from a seed. The rest of what turns up is abandoned. The one npm package named for the combat half, <a href="https://www.npmjs.com/package/turn-based-combat-framework"><code class="language-plaintext highlighter-rouge">turn-based-combat-framework</code></a>, last published in November 2018. <a href="https://www.npmjs.com/package/malwoden">Malwoden</a>, the newer take on rot.js, last released in January 2022. The <a href="https://github.com/topics/rotjs">rotjs topic on GitHub</a> is mostly finished games rather than pieces you can pull out of one.</p>

<p>So everyone seems to write it again, which surprised me given how many browser roguelikes are out there. My guess is that these games get finished as monoliths and nobody goes back to extract the reusable half.</p>

<p>The route I’ve settled on keeps the game logic in a pure <code class="language-plaintext highlighter-rouge">sim/</code> module with no React and no DOM in it, and a React interface reads from it and renders SVG. There are four runtime dependencies: <code class="language-plaintext highlighter-rouge">react</code>, <code class="language-plaintext highlighter-rouge">rot-js</code>, <a href="https://github.com/dubzzz/pure-rand"><code class="language-plaintext highlighter-rouge">pure-rand</code></a> for the seeded RNG, and <code class="language-plaintext highlighter-rouge">zod</code> to validate an imported save string. rot.js has its own RNG, but it’s a global singleton, and the sim keeps all of its state explicit, so a run replays identically from its seed and the whole thing unit-tests without a browser.</p>]]></content><author><name>Pawl</name></author><category term="javascript" /><category term="gamedev" /><summary type="html"><![CDATA[rot.js covers the algorithms and Phaser the rendering, but no JavaScript library handles turn-based combat, inventory or progression. I wrote those myself.]]></summary></entry><entry><title type="html">Kimi K3’s cache discount doesn’t survive OpenRouter</title><link href="https://www.paulsprogrammingnotes.com/2026/08/kimi-k3-cache-discount-openrouter.html" rel="alternate" type="text/html" title="Kimi K3’s cache discount doesn’t survive OpenRouter" /><published>2026-08-02T15:00:00+00:00</published><updated>2026-08-02T15:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/08/kimi-k3-cache-discount-openrouter</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/08/kimi-k3-cache-discount-openrouter.html"><![CDATA[<p>I wrote recently that <a href="/2026/07/llm-tool-calls-resend-context.html">every LLM tool call re-sends your entire conversation</a>, and that prompt caching brings the re-read down to about a tenth of full price. There is a lot of buzz around Kimi K3 at the moment, so I tried it in <a href="https://opencode.ai">opencode</a>, paying per token through <a href="https://openrouter.ai">OpenRouter</a> and expecting caching to absorb most of the re-reads. Thirteen prompts in, the billing page said otherwise.</p>

<p>Four rows from the OpenRouter activity log:</p>

<table>
  <thead>
    <tr>
      <th>Input</th>
      <th>Output</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>11,235</td>
      <td>217</td>
      <td>$0.0364</td>
    </tr>
    <tr>
      <td>13,949</td>
      <td>196</td>
      <td>$0.0443</td>
    </tr>
    <tr>
      <td>22,210</td>
      <td>2,699</td>
      <td>$0.107</td>
    </tr>
    <tr>
      <td>45,512</td>
      <td>1,194</td>
      <td>$0.104</td>
    </tr>
  </tbody>
</table>

<p>Subtract the first two rows and you get the input rate: 2,714 extra tokens for $0.0079, or $2.91 per million. That is K3’s list price, so nothing was cached.</p>

<p>Moonshot does cache automatically, with no <code class="language-plaintext highlighter-rouge">cache_control</code> breakpoints to place like Anthropic’s API. The discount just doesn’t survive the trip through OpenRouter, and you can confirm that before spending anything: every K3 endpoint on OpenRouter reports <code class="language-plaintext highlighter-rouge">supports_implicit_caching: false</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> https://openrouter.ai/api/v1/models/moonshotai/kimi-k3/endpoints <span class="se">\</span>
  | jq <span class="s1">'.data.endpoints[] | {provider_name, supports_implicit_caching}'</span>
</code></pre></div></div>

<p>Input ended up 73% of my spend, nearly all of it conversation I had already paid for once. Shorter sessions trim that, but every turn still re-reads its whole history at full price.</p>

<p>That leaves two ways out: go to Moonshot’s API directly, where the discount does apply, or pick a model whose caching survives the trip. Moonshot’s plans are behind a waitlist at the moment, so I took the second one. Run the same endpoints check against any candidate and look for a <code class="language-plaintext highlighter-rouge">cache_read</code> price.</p>

<p>K3 is good, and noticeably more concise than Opus, which likes to narrate what it is about to do before doing it. I would still reach for it on a hard problem.</p>

<p>I only run any of this when Claude’s rate limits hit, so this is a first pass rather than a verdict. The cheapest thing that has held up so far is GPT-5.6 Terra, which caches and is on sale at the moment:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"model"</span><span class="p">:</span><span class="w"> </span><span class="s2">"openrouter/openai/gpt-5.6-terra:online"</span><span class="w">
</span></code></pre></div></div>

<p>Cache reads run a tenth of the input price. Over a long session that is the number that decides the bill.</p>]]></content><author><name>Pawl</name></author><category term="llm" /><summary type="html"><![CDATA[Moonshot caches prompts automatically, but the discount never reaches you through OpenRouter. Backing the real rate out of the billing rows.]]></summary></entry><entry><title type="html">Every LLM tool call re-sends your entire conversation</title><link href="https://www.paulsprogrammingnotes.com/2026/07/llm-tool-calls-resend-context.html" rel="alternate" type="text/html" title="Every LLM tool call re-sends your entire conversation" /><published>2026-07-26T18:00:00+00:00</published><updated>2026-07-26T18:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/llm-tool-calls-resend-context</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/llm-tool-calls-resend-context.html"><![CDATA[<p>When it comes to LLM chats, I know long context costs more, so I try to keep mine short. What I couldn’t explain was why a single prompt still billed for many times more tokens than the context window holds.</p>

<p>Every time an AI agent calls a tool, it re-sends the entire conversation. <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview">The Messages API is stateless</a>, so the model remembers nothing between requests. When it calls a tool, the request ends, your client runs the tool, appends the result to the history, and POSTs the whole thing back as a new request. You pay input tokens to remind it every time.</p>

<p>So a chat near the 1M-token context limit that makes 20 tool calls pays for that 1M about 20 times over, turning it into 20M billed tokens and around $20 on today’s frontier models. That is 20 times the context window, from a single prompt.</p>

<p><a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching">Prompt caching</a> helps keep the cost down. Normally the model reprocesses the whole conversation on every request, but a cache lets it reuse the unchanged prefix, so re-sending that part costs about a tenth of full price. You pay a small premium to store it the first time, then read it back cheap after, the same way OpenAI (<a href="https://platform.openai.com/docs/guides/prompt-caching">automatic caching</a>) and Gemini (<a href="https://ai.google.dev/gemini-api/docs/caching">implicit caching</a>) do it. The reuse only holds while the start of the prompt stays byte-for-byte identical, so a changing timestamp in your system prompt breaks the cache and puts you back at full price.</p>]]></content><author><name>Pawl</name></author><category term="llm" /><summary type="html"><![CDATA[Every tool call POSTs the whole conversation back to the API, so one prompt can bill many times its context window. Prompt caching cuts reads to ~10%.]]></summary></entry><entry><title type="html">Home Assistant Leak Notification Automation</title><link href="https://www.paulsprogrammingnotes.com/2026/07/home-assistant-leak-notification-automation.html" rel="alternate" type="text/html" title="Home Assistant Leak Notification Automation" /><published>2026-07-25T15:00:00+00:00</published><updated>2026-07-25T15:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/home-assistant-leak-notification-automation</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/home-assistant-leak-notification-automation.html"><![CDATA[<p>The Sonoff SNZB-05P seems like the best Zigbee leak sensor right now. It’s around $20, and its snap-on <a href="https://sonoff.tech/en-us/products/sonoff-zigbee-water-leak-sensor-snzb-05p?variant=46197343944945">WLDC200 sensing cable</a> detects water anywhere along the cord, so it isn’t just watching a single spot the water can spread around. I have them in appliance drip pans and under sinks, all feeding one Home Assistant automation that alerts my phone when a sensor trips and sends an all-clear when it dries.</p>

<h2 id="automationsyaml"><code class="language-plaintext highlighter-rouge">automations.yaml</code></h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">id</span><span class="pi">:</span> <span class="s">leak_notifications</span>
  <span class="na">alias</span><span class="pi">:</span> <span class="s">Leak Notifications</span>
  <span class="na">triggers</span><span class="pi">:</span>
  <span class="c1"># Wet: the state must hold 15s to filter momentary blips, and not_from</span>
  <span class="c1"># stops a sensor coming back from unavailable from re-alerting.</span>
  <span class="pi">-</span> <span class="na">trigger</span><span class="pi">:</span> <span class="s">state</span>
    <span class="na">entity_id</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">binary_sensor.kitchen_sink_leak_sensor</span>
    <span class="pi">-</span> <span class="s">binary_sensor.water_heater_leak_sensor</span>
    <span class="na">not_from</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">unavailable</span>
    <span class="pi">-</span> <span class="s">unknown</span>
    <span class="na">to</span><span class="pi">:</span> <span class="s1">'</span><span class="s">on'</span>
    <span class="na">for</span><span class="pi">:</span>
      <span class="na">seconds</span><span class="pi">:</span> <span class="m">15</span>
    <span class="na">id</span><span class="pi">:</span> <span class="s">wet</span>
  <span class="c1"># Dry: same sensors, opposite transition.</span>
  <span class="pi">-</span> <span class="na">trigger</span><span class="pi">:</span> <span class="s">state</span>
    <span class="na">entity_id</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">binary_sensor.kitchen_sink_leak_sensor</span>
    <span class="pi">-</span> <span class="s">binary_sensor.water_heater_leak_sensor</span>
    <span class="na">from</span><span class="pi">:</span> <span class="s1">'</span><span class="s">on'</span>
    <span class="na">to</span><span class="pi">:</span> <span class="s1">'</span><span class="s">off'</span>
    <span class="na">id</span><span class="pi">:</span> <span class="s">dry</span>
  <span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">if</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">condition</span><span class="pi">:</span> <span class="s">trigger</span>
      <span class="na">id</span><span class="pi">:</span> <span class="s">wet</span>
    <span class="na">then</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">action</span><span class="pi">:</span> <span class="s">notify.mobile_app_your_phone</span>
      <span class="na">data</span><span class="pi">:</span>
        <span class="na">title</span><span class="pi">:</span> <span class="s">Leak Detected</span>
        <span class="na">message</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Device:</span><span class="nv"> </span><span class="s">{{</span><span class="nv"> </span><span class="s">trigger.to_state.attributes.friendly_name</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default(trigger.entity_id)</span><span class="nv"> </span><span class="s">}}</span><span class="nv"> </span><span class="s">has</span><span class="nv"> </span><span class="s">detected</span><span class="nv"> </span><span class="s">water.'</span>
        <span class="na">data</span><span class="pi">:</span>
          <span class="c1"># Both branches share a per-sensor tag, so the dry notification</span>
          <span class="c1"># overwrites the wet one and your phone shows a single</span>
          <span class="c1"># up-to-date entry per sensor instead of a stack.</span>
          <span class="na">tag</span><span class="pi">:</span> <span class="s">leak_{{ trigger.entity_id }}</span>
          <span class="c1"># Android-specific: alarm_stream plays at alarm volume even</span>
          <span class="c1"># during Do Not Disturb (a leak at 3am is worth waking up for),</span>
          <span class="c1"># and priority high + ttl 0 deliver it immediately.</span>
          <span class="na">priority</span><span class="pi">:</span> <span class="s">high</span>
          <span class="na">ttl</span><span class="pi">:</span> <span class="m">0</span>
          <span class="na">channel</span><span class="pi">:</span> <span class="s">alarm_stream</span>
    <span class="na">else</span><span class="pi">:</span>
    <span class="c1"># The all-clear is a normal-priority push.</span>
    <span class="pi">-</span> <span class="na">action</span><span class="pi">:</span> <span class="s">notify.mobile_app_your_phone</span>
      <span class="na">data</span><span class="pi">:</span>
        <span class="na">title</span><span class="pi">:</span> <span class="s">Leak Dry</span>
        <span class="na">message</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Device:</span><span class="nv"> </span><span class="s">{{</span><span class="nv"> </span><span class="s">trigger.to_state.attributes.friendly_name</span><span class="nv"> </span><span class="s">|</span><span class="nv"> </span><span class="s">default(trigger.entity_id)</span><span class="nv"> </span><span class="s">}}</span><span class="nv"> </span><span class="s">is</span><span class="nv"> </span><span class="s">dry.'</span>
        <span class="na">data</span><span class="pi">:</span>
          <span class="na">tag</span><span class="pi">:</span> <span class="s">leak_{{ trigger.entity_id }}</span>
  <span class="c1"># Parallel so one burst pipe tripping sensors in two rooms alerts for both.</span>
  <span class="na">mode</span><span class="pi">:</span> <span class="s">parallel</span>
</code></pre></div></div>

<h2 id="to-adapt">To adapt</h2>

<p>Swap in your own sensors under both <code class="language-plaintext highlighter-rouge">entity_id</code> lists and point the notify actions at your phone’s service. The Android keys are documented in the <a href="https://companion.home-assistant.io/docs/notifications/critical-notifications/">critical notifications docs</a> and the tag behavior in the <a href="https://companion.home-assistant.io/docs/notifications/notifications-basic/#replacing">basic notification docs</a>. The iOS equivalent of the alarm-stream keys is a critical push:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">data</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">sound</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">default</span>
      <span class="na">critical</span><span class="pi">:</span> <span class="m">1</span>
      <span class="na">volume</span><span class="pi">:</span> <span class="m">1.0</span>
</code></pre></div></div>]]></content><author><name>Pawl</name></author><category term="homelab" /><category term="home-assistant" /><summary type="html"><![CDATA[The Sonoff SNZB-05P seems like the best Zigbee leak sensor right now. It’s around $20, and its snap-on WLDC200 sensing cable detects water anywhere along the cord, so it isn’t just watching a single spot the water can spread around. I have them in appliance drip pans and under sinks, all feeding one Home Assistant automation that alerts my phone when a sensor trips and sends an all-clear when it dries.]]></summary></entry><entry><title type="html">Is CloudNativePG ready to replace Aurora or AlloyDB?</title><link href="https://www.paulsprogrammingnotes.com/2026/07/cloudnativepg-replace-aurora-alloydb.html" rel="alternate" type="text/html" title="Is CloudNativePG ready to replace Aurora or AlloyDB?" /><published>2026-07-19T18:00:00+00:00</published><updated>2026-07-19T18:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/cloudnativepg-replace-aurora-alloydb</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/cloudnativepg-replace-aurora-alloydb.html"><![CDATA[<p>CloudNativePG is a <a href="https://github.com/cloudnative-pg/cloudnative-pg">Kubernetes operator for running PostgreSQL</a>. It <a href="https://www.cncf.io/projects/cloudnativepg/">joined the CNCF Sandbox in January 2025</a>, <a href="https://www.gabrielebartolini.it/articles/2025/12/cloudnativepg-in-2025-cncf-sandbox-postgresql-18-and-a-new-era-for-extensions/">applied for Incubation and added PostgreSQL 18 support</a> later that year, and shipped <a href="https://github.com/cloudnative-pg/cloudnative-pg/releases">v1.30</a> in June 2026. It’s <a href="https://github.com/cloudnative-pg/cloudnative-pg/blob/main/ADOPTERS.md">used by some big companies like IBM, Google Cloud, and Microsoft Azure</a>, and it looks solid, though I haven’t run it myself. I’ve been weighing whether it’s worth advocating a swap from a plain Aurora or AlloyDB database onto it, and how much work you’d be taking on.</p>

<p>The pitch for Aurora and AlloyDB is that they take storage off your plate and let you scale compute on its own. Storage grows automatically with no downtime, up to 128 TiB, and is <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Overview.StorageReliability.html">replicated six ways across three availability zones</a>, so a write survives losing an entire zone and you never plan capacity or schedule a resize. AlloyDB takes the same approach on its own disaggregated storage and adds a <a href="https://cloud.google.com/blog/products/databases/alloydb-for-postgresql-columnar-engine">columnar engine for analytics</a>. CloudNativePG doesn’t work that way. It runs <a href="https://cloudnative-pg.io/documentation/current/architecture/">classic shared-nothing streaming replication</a>, where each instance is a full Postgres node with its own PersistentVolumeClaim, and durability comes from replicating between instances rather than from a shared storage layer.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">postgresql.cnpg.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Cluster</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">pg</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">instances</span><span class="pi">:</span> <span class="m">3</span>                 <span class="c1"># one primary, two streaming replicas</span>
  <span class="na">storage</span><span class="pi">:</span>
    <span class="na">storageClass</span><span class="pi">:</span> <span class="s">local-nvme</span>   <span class="c1"># benchmark this before you trust it</span>
    <span class="na">size</span><span class="pi">:</span> <span class="s">200Gi</span>
  <span class="na">walStorage</span><span class="pi">:</span>                  <span class="c1"># WAL on its own volume</span>
    <span class="na">storageClass</span><span class="pi">:</span> <span class="s">local-nvme</span>
    <span class="na">size</span><span class="pi">:</span> <span class="s">40Gi</span>
  <span class="na">replicationSlots</span><span class="pi">:</span>
    <span class="na">highAvailability</span><span class="pi">:</span>
      <span class="na">enabled</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">postgresql</span><span class="pi">:</span>
    <span class="na">parameters</span><span class="pi">:</span>
      <span class="na">shared_buffers</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4GB"</span>    <span class="c1"># tuning is yours now</span>
    <span class="na">synchronous</span><span class="pi">:</span>               <span class="c1"># opt in for RPO=0 (replication is async by default)</span>
      <span class="na">method</span><span class="pi">:</span> <span class="s">any</span>              <span class="c1"># any = quorum-based</span>
      <span class="na">number</span><span class="pi">:</span> <span class="m">1</span>                <span class="c1"># standbys that must confirm each commit</span>
</code></pre></div></div>

<p>That model works, but the volume is now your job. The docs <a href="https://cloudnative-pg.io/documentation/current/storage/">recommend local NVMe for serious workloads</a> and tell you to benchmark with <code class="language-plaintext highlighter-rouge">fio</code> and <code class="language-plaintext highlighter-rouge">pgbench</code> first, because network-attached storage can wreck Postgres latency. And there’s no auto-scaling, so you size the PVC up front and grow it later only if your CSI driver supports expansion.</p>

<p>Each instance keeps its own copy of the data, so with local volumes a failed node takes its data with it and the operator re-creates that instance by cloning a fresh copy from the primary, a slow rebuild for a large database. That only protects against a zone outage if the replicas are in other zones, which takes pod anti-affinity and <code class="language-plaintext highlighter-rouge">topologySpreadConstraints</code> to arrange, with the usual advice to run nodes in multiples of three, one per zone. The operator promotes a replica automatically when the primary fails, but replication is <a href="https://cloudnative-pg.io/documentation/current/replication/">asynchronous by default</a>, so that promotion can drop the last few committed transactions. Synchronous replication (<code class="language-plaintext highlighter-rouge">method: any</code> quorum, above) gets you back to RPO=0, and it makes the CAP tradeoff explicit. With the default <code class="language-plaintext highlighter-rouge">dataDurability: required</code>, writes pause when the operator can’t reach enough synchronous standbys, so a bad enough zone outage costs availability to preserve consistency. Aurora and AlloyDB make that same call at the storage layer and keep taking writes through an AZ failure. Cross-region means running a separate replica cluster fed by streaming or WAL shipping, with no automatic failover between clusters.</p>

<p>The operational pieces are all yours too. Continuous backup and point-in-time recovery go to object storage through the <a href="https://github.com/cloudnative-pg/plugin-barman-cloud">barman-cloud plugin</a>, but you configure it, set the retention, and test that a restore actually works. Connection pooling is a <a href="https://cloudnative-pg.io/documentation/current/connection_pooling/"><code class="language-plaintext highlighter-rouge">Pooler</code> resource running PgBouncer</a> that you deploy and size. Monitoring is Prometheus metrics the operator exposes, wired into whatever stack you already run. Minor-version upgrades are rolling pod restarts you trigger.</p>

<p>So is it worth it? If you’re paying for Aurora mainly to avoid thinking about Postgres, self-hosting probably isn’t worth it. If you already have the Kubernetes and Postgres chops, live in GitOps, want off the managed-service markup, or need something the managed services won’t give you (specific extensions, exact versions, multi-cloud portability, no lock-in), it looks worth a serious trial. Either way I’d start on something smaller than a tier-1 database and prove out storage, backups, and failover first.</p>]]></content><author><name>Pawl</name></author><category term="kubernetes" /><category term="postgresql" /><summary type="html"><![CDATA[CloudNativePG is a Kubernetes operator for running PostgreSQL. It joined the CNCF Sandbox in January 2025, applied for Incubation and added PostgreSQL 18 support later that year, and shipped v1.30 in June 2026. It’s used by some big companies like IBM, Google Cloud, and Microsoft Azure, and it looks solid, though I haven’t run it myself. I’ve been weighing whether it’s worth advocating a swap from a plain Aurora or AlloyDB database onto it, and how much work you’d be taking on.]]></summary></entry><entry><title type="html">Switching my local LLM to Qwen 3.6, a 35B Mixture-of-Experts model, on a 16 GB GPU</title><link href="https://www.paulsprogrammingnotes.com/2026/07/switching-to-qwen-3-6-moe.html" rel="alternate" type="text/html" title="Switching my local LLM to Qwen 3.6, a 35B Mixture-of-Experts model, on a 16 GB GPU" /><published>2026-07-14T18:00:00+00:00</published><updated>2026-07-14T18:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/switching-to-qwen-3-6-moe</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/switching-to-qwen-3-6-moe.html"><![CDATA[<p>A few months ago I wrote about <a href="/2026/03/llama-cpp-over-ollama-for-local-llm.html">switching Open WebUI from Ollama to llama.cpp for Qwen 3.5</a>. I’m still using the same RTX 5060 Ti with 16 GB of VRAM, but I swapped the dense 9B for Qwen3.6-35B-A3B, a Mixture-of-Experts (MoE) model. A 35B model usually wouldn’t fit on a 16 GB card without MoE.</p>

<p>A dense model uses every parameter for every token, so the weights have to fit in VRAM or throughput tanks. On this card that capped me around 14B. MoE models have many expert sub-networks but only use a few per token. Qwen3.6-35B-A3B is 35B total but only ~3B active. The idle experts can sit in system RAM instead of VRAM, pulled onto the GPU when needed for a small speed hit. llama.cpp does this with <a href="https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md"><code class="language-plaintext highlighter-rouge">--n-cpu-moe</code></a>, which keeps the top N layers’ experts on the CPU.</p>

<p>Staying within the 16 GB budget took some more tuning. I set <code class="language-plaintext highlighter-rouge">LLAMA_ARG_N_CPU_MOE=8</code>, which freed about 2 GB of VRAM for context. Raise it if the model OOMs on load, lower it if you have VRAM to spare. I used a dynamic <a href="https://unsloth.ai/docs/basics/unsloth-dynamic-2.0-ggufs"><code class="language-plaintext highlighter-rouge">UD-Q3_K_M</code></a> quant (about 15 GB) and lowered <code class="language-plaintext highlighter-rouge">BATCH_SIZE</code> and <code class="language-plaintext highlighter-rouge">UBATCH</code> from the 9B’s values to leave room for a 64K KV cache. Flash attention and a <code class="language-plaintext highlighter-rouge">q8_0</code> KV cache save the rest, same as before.</p>

<p>Turning thinking mode off took two flags. <code class="language-plaintext highlighter-rouge">LLAMA_ARG_THINK_BUDGET=0</code> (reasoning-budget 0) alone didn’t stop it, a <a href="https://github.com/ggml-org/llama.cpp/issues/20182">known issue on the hybrid Qwen models</a>. I was still watching it think in circles for several minutes without getting anything done. The other was <code class="language-plaintext highlighter-rouge">enable_thinking=false</code> in the chat template (<code class="language-plaintext highlighter-rouge">LLAMA_ARG_CHAT_TEMPLATE_KWARGS</code>), with jinja on. I also set the sampling to Qwen3.6’s non-thinking defaults (<code class="language-plaintext highlighter-rouge">temp 0.7, top_k 20, top_p 0.8</code>).</p>

<p>For serious work I still use a hosted frontier model, but this is handy for local jobs like redacting or cleaning text before it goes to the cloud.</p>

<p>Minimal two-container compose to get mostly set up:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">llama-server</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/ggml-org/llama.cpp:server-cuda-b9592</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="c1"># Pin the model cache to the mounted volume so a container recreate</span>
      <span class="c1"># doesn't re-download the weights.</span>
      <span class="pi">-</span> <span class="s">LLAMA_CACHE=/root/.cache/llama.cpp</span>
      <span class="c1"># Auto-downloads from HuggingFace on first run.</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_HF_REPO=unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q3_K_M</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_N_GPU_LAYERS=99</span>      <span class="c1"># all layers to GPU...</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_N_CPU_MOE=8</span>          <span class="c1"># ...except the top 8 layers' experts, kept in system RAM</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_CTX_SIZE=65536</span>       <span class="c1"># 64K context, realistic for a 35B MoE in 16 GB</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_N_PARALLEL=1</span>         <span class="c1"># single user, one KV cache slot</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_FLASH_ATTN=1</span>         <span class="c1"># big KV cache VRAM savings</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_CACHE_TYPE_K=q8_0</span>    <span class="c1"># halve KV cache memory vs FP16</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_CACHE_TYPE_V=q8_0</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_BATCH_SIZE=2048</span>      <span class="c1"># lowered from 4096 to leave VRAM for the bigger model</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_UBATCH=512</span>           <span class="c1"># lowered from 2048 for the same reason</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_JINJA=1</span>              <span class="c1"># correct chat template + tool calling</span>
      <span class="c1"># Non-thinking mode. On the Qwen3 hybrid models, reasoning-budget 0 alone kept</span>
      <span class="c1"># emitting think blocks; enable_thinking=false is what stopped it, so set both.</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">LLAMA_ARG_CHAT_TEMPLATE_KWARGS={"enable_thinking":false}'</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_THINK_BUDGET=0</span>       <span class="c1"># reasoning-budget 0</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_TEMP=0.7</span>             <span class="c1"># Qwen3.6 non-thinking sampling defaults</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_TOP_K=20</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_TOP_P=0.8</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_MIN_P=0</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_PORT=11434</span>
      <span class="pi">-</span> <span class="s">LLAMA_ARG_HOST=0.0.0.0</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./models:/root/.cache/llama.cpp</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">11434:11434"</span>
    <span class="na">deploy</span><span class="pi">:</span>
      <span class="na">resources</span><span class="pi">:</span>
        <span class="na">reservations</span><span class="pi">:</span>
          <span class="na">devices</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">driver</span><span class="pi">:</span> <span class="s">nvidia</span>
              <span class="na">count</span><span class="pi">:</span> <span class="s">all</span>
              <span class="na">capabilities</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">gpu</span><span class="pi">]</span>

  <span class="na">open-webui</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/open-webui/open-webui:v0.9.6</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3000:8080"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">ENABLE_OLLAMA_API=false</span>
      <span class="pi">-</span> <span class="s">OPENAI_API_BASE_URLS=http://llama-server:11434/v1</span>
      <span class="pi">-</span> <span class="s">OPENAI_API_KEYS=no-key</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">./open-webui:/app/backend/data</span>
    <span class="na">depends_on</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">llama-server</span>
</code></pre></div></div>]]></content><author><name>Pawl</name></author><category term="docker" /><category term="llm" /><summary type="html"><![CDATA[Qwen3.6-35B-A3B fits on a 16 GB card with llama.cpp's `--n-cpu-moe`. Disabling thinking mode needed both reasoning-budget 0 and enable_thinking=false.]]></summary></entry><entry><title type="html">The best-value thin clients with a PCIe slot in 2026</title><link href="https://www.paulsprogrammingnotes.com/2026/07/thin-clients-with-a-pcie-slot.html" rel="alternate" type="text/html" title="The best-value thin clients with a PCIe slot in 2026" /><published>2026-07-14T15:00:00+00:00</published><updated>2026-07-14T15:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/thin-clients-with-a-pcie-slot</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/thin-clients-with-a-pcie-slot.html"><![CDATA[<p>I was picking hardware for an OPNsense router and needed a real PCIe slot, which most cheap low-power boxes don’t have. A slot lets you add a network card with SFP+ or more ports, and swap it later for something else (like a GPU).</p>

<p>The <a href="/2026/07/thin-clients-vs-raspberry-pi.html">thin clients I wrote about last time</a> don’t have one. The Wyse 5070 and OptiPlex 3000 both have an M.2 A/E key slot, good for a 2.5GbE adapter or a Coral TPU but not an SFP+ or a quad-port NIC.</p>

<p>A few corporate thin clients do have a full-size slot:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>CPU</th>
      <th>PCIe slot</th>
      <th>Used price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>HP t730</td>
      <td>AMD RX-427BB (4C)</td>
      <td>half-height x16 mechanical, x8 electrical</td>
      <td>~$100-120</td>
    </tr>
    <tr>
      <td>HP t740</td>
      <td>AMD Ryzen V1756B (4C/8T)</td>
      <td>half-height x16 mechanical, x8 electrical, Gen3</td>
      <td>~$150-170</td>
    </tr>
    <tr>
      <td>Dell Wyse 5070 Extended</td>
      <td>Celeron J4105 / Pentium J5005</td>
      <td>half-height slot in the thicker “Extended” chassis</td>
      <td>~$90-130</td>
    </tr>
  </tbody>
</table>

<p>The HP t730 and <a href="https://www.servethehome.com/hp-t740-thin-client-review-tinyminimicro-with-pcie-slot-amd-ryzen/">t740</a> are the well-known ones, both popular pfSense/OPNsense boxes with a <a href="https://h20195.www2.hp.com/v2/getpdf.aspx/c04743502.pdf?ver=3">half-height x16-mechanical, x8-electrical slot</a>. The Wyse 5070 comes in two sizes. The slim one has only the M.2 slot. The thicker <a href="https://forum.opnsense.org/index.php?topic=31008.0">Extended chassis</a> adds a half-height PCIe slot.</p>

<p>The slot is half-height, so you’ll want a low-profile card, and its power is limited (roughly 35W on the t740), enough for a NIC but only a low-power GPU.</p>

<p>The other route is a <a href="https://www.servethehome.com/new-4x-2-5gbe-and-2x-10gbe-intel-core-firewall-and-virtualization-appliance/">Topton or CWWK mini PC</a>, which usually skips the slot and solders on the NICs instead, often four 2.5GbE ports or a couple of SFP+ cages. Handy if those ports are what you need, but you’re stuck with them, with no way to swap in a specific card.</p>]]></content><author><name>Pawl</name></author><category term="homelab" /><category term="hardware" /><category term="networking" /><summary type="html"><![CDATA[I was picking hardware for an OPNsense router and needed a real PCIe slot, which most cheap low-power boxes don’t have. A slot lets you add a network card with SFP+ or more ports, and swap it later for something else (like a GPU).]]></summary></entry><entry><title type="html">A dead man’s switch for a single-host monitoring stack</title><link href="https://www.paulsprogrammingnotes.com/2026/07/dead-mans-switch-single-host-monitoring.html" rel="alternate" type="text/html" title="A dead man’s switch for a single-host monitoring stack" /><published>2026-07-05T18:00:00+00:00</published><updated>2026-07-05T18:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/dead-mans-switch-single-host-monitoring</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/dead-mans-switch-single-host-monitoring.html"><![CDATA[<p>I run Prometheus, Alertmanager, and Grafana on a single mini PC, which has an obvious blind spot: if that box goes down, nothing can alert me, because the thing that sends alerts is the thing that’s down. Prometheus once sat dead for two days before I noticed, and only because a dashboard had gone blank.</p>

<p>The fix is a dead man’s switch: an alert that fires constantly, routed to an outside service that complains when it <em>stops</em> arriving. Every other alert fires when something breaks; this one fires all the time, and silence is the failure signal.</p>

<p>The rule is just <code class="language-plaintext highlighter-rouge">vector(1)</code>, which is always true:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># alerts/meta.yml</span>
<span class="pi">-</span> <span class="na">alert</span><span class="pi">:</span> <span class="s">Watchdog</span>
  <span class="na">expr</span><span class="pi">:</span> <span class="s">vector(1)</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">severity</span><span class="pi">:</span> <span class="s">none</span>
</code></pre></div></div>

<p>kube-prometheus-stack ships the same alert under the same name. Then route that one alert away from Telegram and into a <a href="https://prometheus.io/docs/alerting/latest/configuration/#webhook_config">webhook</a>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">routes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">matchers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">alertname = "Watchdog"</span>
    <span class="na">receiver</span><span class="pi">:</span> <span class="s">deadmansswitch</span>
    <span class="na">group_wait</span><span class="pi">:</span> <span class="s">0s</span>
    <span class="na">repeat_interval</span><span class="pi">:</span> <span class="s">5m</span>   <span class="c1"># re-ping every 5 minutes</span>

<span class="na">receivers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">deadmansswitch</span>
    <span class="na">webhook_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">url</span><span class="pi">:</span> <span class="s">https://hc-ping.com/&lt;uuid&gt;</span>
        <span class="na">send_resolved</span><span class="pi">:</span> <span class="kc">false</span>
</code></pre></div></div>

<p>The webhook target is a <a href="https://healthchecks.io">healthchecks.io</a> check. Alertmanager pings it every five minutes while the pipeline is healthy; I set the check to a ~10 minute period with a ~5 minute grace, so a real outage pages me within about 15 minutes, from a service that isn’t on my network.</p>

<p>Two details that bit me: the ping URL lives in my git-ignored <code class="language-plaintext highlighter-rouge">alertmanager.yml</code> (low-sensitivity, like the Telegram chat ID), and <code class="language-plaintext highlighter-rouge">send_resolved: false</code> so the “resolved” call doesn’t muddy the heartbeat.</p>

<p>The same trick works for any scheduled job: have the script <code class="language-plaintext highlighter-rouge">curl</code> a healthchecks.io ping URL on success, and let the absence page you.</p>]]></content><author><name>Pawl</name></author><category term="prometheus" /><category term="monitoring" /><category term="homelab" /><summary type="html"><![CDATA[If Prometheus and Alertmanager run on one box, nothing alerts when that box dies. A `vector(1)` watchdog routed off-host turns silence into the failure signal.]]></summary></entry><entry><title type="html">Replacing my Raspberry Pis with used thin clients</title><link href="https://www.paulsprogrammingnotes.com/2026/07/thin-clients-vs-raspberry-pi.html" rel="alternate" type="text/html" title="Replacing my Raspberry Pis with used thin clients" /><published>2026-07-04T15:00:00+00:00</published><updated>2026-07-14T15:00:00+00:00</updated><id>https://www.paulsprogrammingnotes.com/2026/07/thin-clients-vs-raspberry-pi</id><content type="html" xml:base="https://www.paulsprogrammingnotes.com/2026/07/thin-clients-vs-raspberry-pi.html"><![CDATA[<p>Raspberry Pis have gotten expensive enough that I’ve started replacing them with used corporate thin clients. These two came off eBay: a Dell Wyse 5070 (Celeron J4105) for $43 (+$15 for a power adapter it didn’t include), and a Dell OptiPlex 3000 thin client (Pentium Silver N6005) for $84. Both are fanless, both idle at 5W or less, and both cost a fraction of a Pi 5.</p>

<p>A Pi 5 16GB lists at <a href="https://www.raspberrypi.com/news/1gb-raspberry-pi-5-now-available-at-45-and-memory-driven-price-rises/">$299.99</a> these days, and adding a power supply, a case, an SD card, and an NVMe HAT pushes it well past what I paid for either box. The OptiPlex came with 16GB of RAM and its power adapter for $84, with an M.2 slot for a real NVMe drive. Jeff Geerling called the hobbyist SBC market <a href="https://www.jeffgeerling.com/blog/2026/dram-pricing-is-killing-the-hobbyist-sbc-market/">“dying, or at least on life support”</a> in April 2026, and for a low-power box that sits in a closet the thin client is just better value now. However, the OptiPlex’s N6005 is slower than a Pi 5 in Geekbench 6, <a href="https://browser.geekbench.com/processors/intel-pentium-silver-n6005">544/1421</a> to the Pi’s <a href="https://www.raspberrypi.com/news/benchmarking-raspberry-pi-5/">764/1604</a>.</p>

<p>These boxes are old (the Wyse 5070 can be 8+ years old, the OptiPlex 3000 4+), so run <a href="https://www.memtest.org/">memtest86+</a> before you trust one. The Wyse is why I say that: both its 4GB SK Hynix sticks failed memtest, so I swapped in one of the OptiPlex’s 8GB sticks.</p>

<p>My favorite thing about the Wyse 5070 is the USB-C port. It normally runs headless, but for setup and troubleshooting I can plug it into a USB-C hub and get video and USB over one cable.</p>

<p>The OptiPlex 3000 is the more annoying of the two. No USB-C, and its M.2 slot officially only takes a short 2230 SSD. A full-length 2280 fits, but I had to desolder a little standoff to make room. I tried to dodge the soldering iron with a Sintech M.2 extender cable first, and that was a mistake: the extra length wrecked PCIe signal integrity and dmesg filled with correctable errors:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AER: Correctable error message received
PCIe Bus Error: severity=Correctable, type=Physical Layer
</code></pre></div></div>

<p>What the OptiPlex has going for it is the newer processor. These make a capable little web server: with a low-latency home connection you can expose Docker containers to the public internet through a <a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/">Cloudflare Tunnel</a> with no port forwarding or static IP. (<a href="https://bitingbytes.de/posts/2024/dell-5070-linux-homeserver-fanless-mini-pc/">Other people run the 5070 as a fanless Linux home server too</a>.)</p>]]></content><author><name>Pawl</name></author><category term="raspberry-pi" /><category term="homelab" /><category term="hardware" /><summary type="html"><![CDATA[A used Dell Wyse 5070 at $43 and an OptiPlex 3000 at $84 beat a Pi 5 on price for closet servers. Benchmarks, the memtest warning, and the M.2 fit problems.]]></summary></entry></feed>