<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://devin.fitzsky.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://devin.fitzsky.com/" rel="alternate" type="text/html" /><updated>2026-06-07T09:14:12-07:00</updated><id>https://devin.fitzsky.com/feed.xml</id><title type="html">Devin Bernosky</title><subtitle>Move fast and break things. As declaratively and atomically as possible.</subtitle><author><name>Devin</name></author><entry><title type="html">Free Datadog for the fleet in one Nix module</title><link href="https://devin.fitzsky.com/free-datadog-for-the-fleet-in-one-nix-module/" rel="alternate" type="text/html" title="Free Datadog for the fleet in one Nix module" /><published>2026-06-07T00:00:00-07:00</published><updated>2026-06-07T00:00:00-07:00</updated><id>https://devin.fitzsky.com/free-datadog-for-the-fleet-in-one-nix-module</id><content type="html" xml:base="https://devin.fitzsky.com/free-datadog-for-the-fleet-in-one-nix-module/"><![CDATA[<p>There’s a pattern in this blog: something that should eat a weekend turns out to eat an afternoon, because Nix and comin do most of the work. This one’s another.</p>

<p>I’d been meaning to put SigNoz on the fleet for months. Fleetwide host telemetry felt like the kind of project that needed a full weekend. Sunday morning, with coffee and Claude, I gave it a shot.</p>

<p>Two pieces. Netdata for the realtime side: “carbon’s CPU is pegged right now, what process?” Its per-process accounting is excellent, and its 18-model ML detector flags anomalies automatically. SigNoz for the historical side: “search journald across every host for that error from Tuesday.” ClickHouse-backed log search, structured fields, OTLP ingest. Both self-hostable. Both with sane Docker compositions.</p>

<h2 id="the-stack">The stack</h2>

<p>Both run as Compose Manager projects on my always-on Unraid box, racer5. The compose files come straight from upstream with two tweaks: ports bind to my Tailscale IP only (no LAN exposure), and bind-mounts point at <code class="language-plaintext highlighter-rouge">/mnt/user/appdata/...</code> because Unraid’s <code class="language-plaintext highlighter-rouge">/boot</code> is a vfat partition that refuses real Unix permissions. ClickHouse runs as UID 101 inside its container, tried to read its config off the vfat mount, got <code class="language-plaintext highlighter-rouge">Access to file denied</code>, and the fix was moving the configs onto the actual storage array.</p>

<p>The compose configs are checked in alongside everything else in <code class="language-plaintext highlighter-rouge">/boot/config/plugins/compose.manager/projects/</code>. Once they’re up, racer5 has a Netdata Parent on <code class="language-plaintext highlighter-rouge">:19999</code> and a SigNoz on <code class="language-plaintext highlighter-rouge">:3301</code>, both reachable only over Tailscale.</p>

<h2 id="the-nix-module">The Nix module</h2>

<p>The host side is one file, <code class="language-plaintext highlighter-rouge">modules/nixos/observability.nix</code>, that does the whole thing:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">config</span> <span class="o">=</span> <span class="nv">lib</span><span class="o">.</span><span class="nv">mkIf</span> <span class="nv">cfg</span><span class="o">.</span><span class="nv">enable</span> <span class="p">{</span>
  <span class="nv">sops</span><span class="o">.</span><span class="nv">secrets</span><span class="o">.</span><span class="nv">netdata_stream_api_key</span> <span class="o">=</span> <span class="p">{};</span>
  <span class="nv">sops</span><span class="o">.</span><span class="nv">templates</span><span class="o">.</span><span class="s2">"netdata-stream.conf"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">owner</span> <span class="o">=</span> <span class="s2">"netdata"</span><span class="p">;</span>
    <span class="nv">content</span> <span class="o">=</span> <span class="s2">''</span><span class="err">
</span><span class="s2">      [stream]</span><span class="err">
</span><span class="s2">          enabled = yes</span><span class="err">
</span><span class="s2">          destination = </span><span class="si">${</span><span class="nv">cfg</span><span class="o">.</span><span class="nv">netdataParent</span><span class="si">}</span><span class="err">
</span><span class="s2">          api key = </span><span class="si">${</span><span class="nv">config</span><span class="o">.</span><span class="nv">sops</span><span class="o">.</span><span class="nv">placeholder</span><span class="o">.</span><span class="nv">netdata_stream_api_key</span><span class="si">}</span><span class="err">
</span><span class="s2">    ''</span><span class="p">;</span>
  <span class="p">};</span>

  <span class="nv">services</span><span class="o">.</span><span class="nv">netdata</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">enable</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
    <span class="nv">configDir</span><span class="o">.</span><span class="s2">"stream.conf"</span> <span class="o">=</span> <span class="nv">config</span><span class="o">.</span><span class="nv">sops</span><span class="o">.</span><span class="nv">templates</span><span class="o">.</span><span class="s2">"netdata-stream.conf"</span><span class="o">.</span><span class="nv">path</span><span class="p">;</span>
  <span class="p">};</span>

  <span class="nv">environment</span><span class="o">.</span><span class="nv">etc</span><span class="o">.</span><span class="s2">"otel-collector/config.yaml"</span><span class="o">.</span><span class="nv">text</span> <span class="o">=</span> <span class="s2">''</span><span class="err">
</span><span class="s2">    receivers:</span><span class="err">
</span><span class="s2">      hostmetrics: {...}</span><span class="err">
</span><span class="s2">      journald: {directory: /var/log/journal, all: true}</span><span class="err">
</span><span class="s2">    exporters:</span><span class="err">
</span><span class="s2">      otlphttp/signoz:</span><span class="err">
</span><span class="s2">        endpoint: </span><span class="si">${</span><span class="nv">cfg</span><span class="o">.</span><span class="nv">signozEndpoint</span><span class="si">}</span><span class="err">
</span><span class="s2">    ...</span><span class="err">
</span><span class="s2">  ''</span><span class="p">;</span>

  <span class="nv">systemd</span><span class="o">.</span><span class="nv">services</span><span class="o">.</span><span class="nv">otel-collector</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nv">ExecStart</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span><span class="nv">pkgs</span><span class="o">.</span><span class="nv">opentelemetry-collector-contrib</span><span class="si">}</span><span class="s2">/bin/otelcol-contrib --config=/etc/otel-collector/config.yaml"</span><span class="p">;</span>
      <span class="c"># ...sandboxed per my hardening rule</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<p>One import in <code class="language-plaintext highlighter-rouge">modules/nixos/common.nix</code>, and every NixOS laptop and workstation in the flake suddenly streams metrics to the Parent and ships journald to SigNoz. No per-host setup, no installer to run, no agent to babysit. The two appliance VMs (hermes and herqules, my agent gateways) are lean one-off <code class="language-plaintext highlighter-rouge">nixosSystem</code> entries that skip <code class="language-plaintext highlighter-rouge">common.nix</code>, so I added the same import directly to their module lists in <code class="language-plaintext highlighter-rouge">flake.nix</code>. Three lines total to bring them in.</p>

<h2 id="the-fan-out">The fan-out</h2>

<p>I push the commit. Every host running comin, my GitOps deploy daemon polling main every 60 seconds, pulls it, rebuilds itself, and starts streaming. From <code class="language-plaintext highlighter-rouge">git push</code> to gram, hermes, and herqules all appearing in the Netdata Parent’s host picker took about four minutes, most of which was waiting for comin to poll. Nix and comin made this absurdly easy.</p>

<h2 id="making-it-laptop-friendly">Making it laptop-friendly</h2>

<p>Out of the box, Netdata samples everything at one second and burns about 14% of one core. On the desktop that’s fine. On a laptop already short on battery it’s a problem. Two changes brought it down to under 5%:</p>

<ul>
  <li><strong>Bump <code class="language-plaintext highlighter-rouge">update every</code> from 1 to 5 seconds.</strong> Single biggest lever. Applies to the daemon and most plugins. The per-host page still feels live because every metric is fresh within 5 seconds, and the “which process is eating CPU right now” answer is unchanged.</li>
  <li><strong>Drop plugins that earn nothing on a workstation.</strong> <code class="language-plaintext highlighter-rouge">debugfs</code> reads ZSWAP/BTRFS/intel-rapl out of <code class="language-plaintext highlighter-rouge">/sys/kernel/debug</code>; I run ZRAM, have no BTRFS on the laptops, and <code class="language-plaintext highlighter-rouge">hostmetrics</code> already covers rapl. <code class="language-plaintext highlighter-rouge">go.d</code> ships postgres/redis/nginx/k8s collectors, none of which any laptop runs. <code class="language-plaintext highlighter-rouge">systemd-journal</code> is the Netdata UI’s journal tail, fully redundant once SigNoz has the full journald firehose. <code class="language-plaintext highlighter-rouge">otel-signal-viewer</code> is for using Netdata as an OTel sink, which I don’t. <code class="language-plaintext highlighter-rouge">freeipmi</code> is for server BMCs, and on a laptop it doesn’t just sit idle, it spams <code class="language-plaintext highlighter-rouge">internal error</code> into the system journal.</li>
</ul>

<p>I kept <code class="language-plaintext highlighter-rouge">apps.plugin</code>, the per-process accounting, because it’s the killer feature, the reason I’d reach for Netdata over an alternative in the first place. The disables and the polling bump are all in the same <code class="language-plaintext highlighter-rouge">services.netdata.config</code> block, so it’s one commit. Comin propagates it everywhere.</p>

<h2 id="why-nix-did-most-of-the-work">Why Nix did most of the work</h2>

<p>The bit that always lands twice in my own posts about Nix is that the same module is what wires up a workstation, a Proxmox VM, and an LG Gram. There’s no “configuration management” beyond writing the file and importing it. The whole fleet converges by polling and rebuilding.</p>

<p>The other piece is that the observability module is itself a small file alongside a much larger system config. The hardening section sits next to the Netdata block. The sops secret is declared in three lines. The dedicated user, the systemd unit, the sandboxing, all in the same file. When I want to know what observability does to a host, there’s exactly one place to look.</p>

<p>The compose files were written by SigNoz and Netdata. The streaming protocol was written by Netdata. The hostmetrics receiver was written by the OpenTelemetry project. The fan-out was written by comin. All I really did was the small glue module. The Nix part is what makes the glue stick to every machine I own at the same time, over a single cup of coffee.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[Netdata + SigNoz on Unraid, host telemetry from every NixOS box. A Sunday morning coffee project; Nix and comin made it absurdly easy.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Agentic memory: a shared brain for my coding agents</title><link href="https://devin.fitzsky.com/agentic-memory-with-qdrant/" rel="alternate" type="text/html" title="Agentic memory: a shared brain for my coding agents" /><published>2026-06-03T00:00:00-07:00</published><updated>2026-06-03T00:00:00-07:00</updated><id>https://devin.fitzsky.com/agentic-memory-with-qdrant</id><content type="html" xml:base="https://devin.fitzsky.com/agentic-memory-with-qdrant/"><![CDATA[<p>The thing that makes coding agents feel disposable is that they forget everything. Every session starts from nothing. Claude Code works out some detail about my setup, a port already taken by another container, a quirk in how a service boots, and then the session ends and that knowledge is gone. Next time it rediscovers the same thing from scratch, burning tokens and my patience. If I switch to Codex, it never knew in the first place. Three agents, three separate cases of amnesia.</p>

<p>I wanted one memory they all share. Local, so it stays private and free. Persistent, so what one agent learns sticks around. Shared, so a fact Claude figures out is there for Codex and Hermes too. This was the bigger sibling to the <a href="/delegating-to-my-assistant/">scheduler bridge</a> I wrote about separately, and the part I learned the most from.</p>

<h2 id="the-store">The store</h2>

<p>The memory itself is unglamorous, which is the point. It’s a vector database (Qdrant) running on a box I already keep on, with a small server in front of it that speaks MCP, the protocol these CLIs use to reach external tools. It exposes two operations: store a piece of text, and search for the text most relevant to a query. Each of my agents points at the same endpoint. One writes, the others read.</p>

<p>The question I expected to wrestle with was how to decide what’s worth storing. The common answer is to run a language model over each session and have it pull out the important facts. I didn’t want a paid API in the loop, and then I realized I didn’t need one. The agent doing the writing is already a language model. Whatever it chooses to save is already distilled. The memory layer doesn’t have to be smart, it has to store text and find it again by meaning. The intelligence is the agent I’m already paying for, so the memory itself costs nothing to run.</p>

<h2 id="what-a-hook-is-and-why-the-memory-needs-them">What a hook is, and why the memory needs them</h2>

<p>A memory the agents can reach is not automatically a memory they use. Agents are focused on the task; they don’t stop to take notes. To make the memory part of the loop, I leaned on hooks.</p>

<p>A hook is a script your CLI runs on its own at a specific moment: when a session starts, when the agent finishes a turn, before it runs a tool, and so on. The CLI passes the script some JSON about what’s going on, and the script can pass text back that becomes part of the conversation. It wraps the model in a bit of scripted scaffolding without touching the model itself, which is how you get deterministic behavior out of something that’s otherwise improvising.</p>

<p>I wired up two. One runs when a session starts: it asks the memory for anything relevant to the project I’m in and drops it into the context, so the agent opens already knowing what earlier sessions worked out. The other runs when the agent finishes a turn: it reads the final message and saves anything I marked with a <code class="language-plaintext highlighter-rouge">&lt;remember&gt;...&lt;/remember&gt;</code> tag. The agent chooses what’s worth keeping, and the hook guarantees the saving actually happens instead of relying on the agent to call a tool it might forget.</p>

<p>My first marker was <code class="language-plaintext highlighter-rouge">[REMEMBER: ...]</code>. It broke instantly, because facts are full of brackets, an array index here, a regex there, and the script kept slicing each fact off at the first <code class="language-plaintext highlighter-rouge">]</code>. The XML-style tag has no such problem, so I switched.</p>

<h2 id="why-nix-makes-this-practical">Why Nix makes this practical</h2>

<p>Wiring a memory into three different agents on every machine I own sounds like the kind of fiddly per-host setup that never stays consistent. In Nix it’s a single description. One entry in my config defines the memory server, and it emits the correct configuration for both Claude Code and Codex on its own. The hooks are scripts pinned to exact versions of their dependencies, so they behave the same everywhere instead of depending on whatever Python happens to be on the machine. I commit it once, and every host wires its agents to the same brain.</p>

<p>The store itself, the database on my always-on box, is the one stateful piece that lives outside Nix. Everything that connects to it, every agent, every hook, every credential, is declared and version-controlled. If I rebuild a laptop from scratch, it rejoins the shared memory automatically, because rejoining is just part of what the config already says the machine is.</p>

<h2 id="what-i-expect-to-learn">What I expect to learn</h2>

<p>This is the part I’m genuinely unsure about, and that’s why I built it instead of theorizing. The open question is whether a memory like this compounds into something useful or just fills with noise. Does the store gradually become a real shared base of knowledge about my systems, or a junk drawer of half-duplicated facts? I already know some of the limits. There’s no deduplication yet, so the same fact saved twice in different words shows up twice. And coverage is partial by design, since it only remembers what an agent thought to flag, which means it catches explicit decisions well and lets the ambient stuff slip by.</p>

<p>The honest test is time. In a few weeks I’ll be able to tell whether sessions actually feel more continuous, whether recall surfaces things I’m glad to see or things I scroll past, and whether the gaps annoy me enough to add the next layer. That layer would be a job that runs a local model over old sessions to backfill what got missed, still on my own hardware, still free. I set myself a reminder to check in, by handing it to my assistant, which felt like the right way to close the loop.</p>

<p>Either way, the foundation is the part I’m confident about. The agents share one local memory, it costs nothing to run, and the whole thing is described in a config I can read top to bottom. What grows on top of that is the experiment.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[A local, no-cost memory that Claude Code, Codex, and my assistant all share, kept current with hooks and wired up in Nix.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A scheduler skill: delegating to my assistant from the terminal</title><link href="https://devin.fitzsky.com/delegating-to-my-assistant/" rel="alternate" type="text/html" title="A scheduler skill: delegating to my assistant from the terminal" /><published>2026-06-01T00:00:00-07:00</published><updated>2026-06-01T00:00:00-07:00</updated><id>https://devin.fitzsky.com/delegating-to-my-assistant</id><content type="html" xml:base="https://devin.fitzsky.com/delegating-to-my-assistant/"><![CDATA[<p>I get my best ideas for things to remember while I’m in the middle of something else. I’ll be deep in a refactor and think “I should check whether that upstream fix landed next week,” and the only way to capture it is to stop, switch to my phone or a calendar, type it out, and climb back into what I was doing. The context switch is small and it happens constantly, and small constant friction is exactly the kind I want to remove.</p>

<p>I already have an assistant that’s good at this. Hermes is a personal agent on a small VM that I talk to over Telegram. It sets reminders and runs scheduled jobs. The problem was that the agents I spend my day with, Claude Code and Codex in the terminal, had no way to reach it. So I built a bridge, and the more interesting half of the story is how Nix makes that bridge something I never have to think about again.</p>

<h2 id="the-bridge">The bridge</h2>

<p>Hermes already knew how to schedule work. It just needed a door other programs could knock on. Most agent frameworks can expose an HTTP API, so I turned Hermes’ on, put a bearer key in front of it, and limited it to my Tailscale network. Reachable from my laptops, invisible to the public internet.</p>

<p>Then I gave Claude Code a skill: when I ask for a reminder or a scheduled task, send a plain-English instruction to that API. I don’t teach the coding agent anything about cron syntax or job formats. I hand Hermes a sentence and it works out the rest with its own scheduling tool.</p>

<p>So now I type “remind me in two weeks to check on the new setup” into the same terminal I’m already working in. Claude passes the sentence along, Hermes makes the job, and two weeks later my phone buzzes. One agent doing the part it’s good at, handing the rest to an agent that’s good at something else.</p>

<p>The reminders don’t have to be dumb timers. The same day I built this, I had a few temporary workarounds in my config that I wanted to remove once the upstream fixes shipped. Instead of a reminder that just nags me on a date, I had Claude set up a recurring job that checks whether each fix has actually landed and only messages me when one is ready to pull out. A reminder that does the legwork before deciding to bother me.</p>

<h2 id="why-nix-is-doing-the-heavy-lifting">Why Nix is doing the heavy lifting</h2>

<p>This is the part that makes it more than a one-off script. Every piece of that bridge is declared in my Nix config: turning on the API, the firewall rule that keeps it on the tailnet, the encrypted secret for the bearer key, and the skill itself. None of it is something I set up by hand on one machine and hope I remember to repeat on the next.</p>

<p>The skill lives in my config as a directory that gets discovered automatically and handed to both Claude Code and Codex. When I push it to my repo, every machine I own picks it up and rebuilds itself. The laptop I set up next month gets the same skill, wired to the same assistant, without me touching it. The config is the setup, and the config is the documentation.</p>

<p>That matters more than it sounds. The failure mode for this kind of personal automation is that it works great on the machine you built it on and quietly rots everywhere else. You tweak a dotfile here, forget to copy it there, and six months later you can’t remember how any of it was wired. Describing it in Nix instead means the wiring is one source of truth that applies identically across every machine. Rather than configuring each host by hand, I describe what I want once and let them all converge on it.</p>

<h2 id="why-youd-want-this-and-what-i-expect-to-learn">Why you’d want this, and what I expect to learn</h2>

<p>The immediate payoff is the friction that disappears, but the part I’m actually curious about is the pattern underneath. This is one specialized agent calling another in plain language, and it works because each side is good at a different thing. The coding agent is where my attention already is; the assistant is where scheduling and notifications already live. Letting them talk turns two separate tools into something closer to a team.</p>

<p>I don’t know yet how far that goes, which is most of why I find it interesting. Does plain natural language stay good enough as the interface between agents, or do I start wanting more structure once they hand each other more than reminders? Does this stay a one-way bridge, or do I end up with the assistant delegating back into the coding tools? How much does erasing that little context switch actually change how often I capture a thought, versus letting it slip? I’ll have a better sense after a few weeks of living with it.</p>

<p>If you want to try the same thing, the shape is small: expose your assistant’s API, lock it to a private network, and teach your coding tool to send it natural language. The scheduling smarts already exist on the assistant side. You’re just giving it a sentence, and if you describe the whole thing declaratively, giving it to every machine you own at once.</p>

<p>I also gave these agents a shared memory, which turned out to be the bigger project. That’s the <a href="/agentic-memory-with-qdrant/">next post</a>.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[Letting my coding CLIs hand reminders and tasks to my Telegram assistant, wired up declaratively with Nix.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hands-off NixOS across my laptops with Attic + comin</title><link href="https://devin.fitzsky.com/hands-off-nixos-with-attic-and-comin/" rel="alternate" type="text/html" title="Hands-off NixOS across my laptops with Attic + comin" /><published>2026-05-25T00:00:00-07:00</published><updated>2026-05-25T00:00:00-07:00</updated><id>https://devin.fitzsky.com/hands-off-nixos-with-attic-and-comin</id><content type="html" xml:base="https://devin.fitzsky.com/hands-off-nixos-with-attic-and-comin/"><![CDATA[<p>Nix’s whole appeal is reproducibility: declare a system once and rebuild it anywhere to get the same machine, the same configuration everywhere. The catch is that reproducibility is a property of one build on one machine. It says nothing about keeping a fleet in step, or about sharing build work so the same package doesn’t get compiled on each box. That’s the gap I wanted to close across my laptops.</p>

<p>I run NixOS on three laptops, an LG Gram, an ASUS Zenbook Duo, and a ThinkPad, all built from a single flake. I only ever use one at a time, whichever suits my mood that day.</p>

<p>The goal: whichever laptop I open should already be running my latest config, and any builds it needs should come from a local cache instead of compiling on the machine. No rebuild waiting when I lift the lid, and no battery burned on something another machine already built.</p>

<p>Two pieces get there. A self-hosted <a href="https://github.com/zhaofengli/attic">Attic</a> binary cache holds the builds, and <a href="https://github.com/nlewo/comin">comin</a> handles automatic switching as a GitOps pull-deploy.</p>

<h2 id="the-problem-catch-up-rebuilds-eat-laptop-battery">The problem: catch-up rebuilds eat laptop battery</h2>

<p>Before this, picking a laptop meant catching it up. Whichever one I grabbed had fallen behind whatever I’d changed since I last used it, so I’d rebuild, and on a laptop some of those rebuilds are a real battery and thermal event.</p>

<p>The reason is that some packages have to be built locally no matter what. Unfree packages aren’t on the public Hydra cache by policy, and veracrypt is a good example. Its license was historically marked unfree, so Hydra hasn’t built it since 2021. Every flake bump that perturbs one of its transitive deps (wxGTK, fuse, lvm2, gtk3) means a local rebuild of around ten minutes. Electron apps are worse, often 30 to 60 minutes from source. Pinning to an older nixpkgs doesn’t help, because no commit has a cached binary either.</p>

<p>On a desktop that’s just annoying. On a laptop it costs you fan noise and a chunk of charge. And because I rotate between machines, the same ten-minute veracrypt build happened independently on each one, three separate compiles for a single config change, spread out over whenever I happened to pick each laptop up.</p>

<h2 id="attic-build-once-pull-everywhere">Attic: build once, pull everywhere</h2>

<p>Attic is a self-hosted Nix binary cache. Mine runs as a native container on my always-on Unraid box, reachable over Tailscale. Pull is unauthenticated over the tailnet, since it’s only ever reachable on my private network.</p>

<p>The point is that nothing builds twice across the fleet. Every host pushes new store paths as it produces them, so the first machine to rebuild after a change seeds the cache and the others just pull. There’s no dedicated builder. Whichever laptop I’m on when I first rebuild eats that compile once, and my desktop (a 5950X with an RTX 3090) chips in its CUDA builds on the occasions I wake it over Wake-on-LAN for GPU work, like running a 27b model in opencode. Pushing is an async systemd service rather than a build hook:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">systemd</span><span class="o">.</span><span class="nv">services</span><span class="o">.</span><span class="nv">attic-watch-store</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">description</span> <span class="o">=</span> <span class="s2">"Push new nix store paths to the racer5 attic cache"</span><span class="p">;</span>
  <span class="nv">wantedBy</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"multi-user.target"</span><span class="p">];</span>
  <span class="nv">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">ExecStart</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span><span class="nv">pkgs</span><span class="o">.</span><span class="nv">attic-client</span><span class="si">}</span><span class="s2">/bin/attic watch-store nix-config"</span><span class="p">;</span>
    <span class="nv">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
    <span class="nv">RestartSec</span> <span class="o">=</span> <span class="mi">30</span><span class="p">;</span>
  <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">watch-store</code> runs in the background and never blocks a build. I tried a <code class="language-plaintext highlighter-rouge">nix</code> post-build-hook first and it was the wrong tool, since it’s synchronous and pushes whole closures, which stalled deploys. Attic filters out <code class="language-plaintext highlighter-rouge">cache.nixos.org</code> paths automatically, so only genuinely uncached stuff gets stored, and it’s content addressed, so two hosts pushing the same path store it once. One caveat: keep <code class="language-plaintext highlighter-rouge">-j</code> at 5 or below, because atticd’s SQLite serializes writes and a higher count exhausts the connection pool.</p>

<p>The payoff is exactly the problem above. That veracrypt build now happens once, anywhere, and every other machine substitutes the binary.</p>

<h2 id="comin-push-to-main-laptops-switch-themselves">comin: push to main, laptops switch themselves</h2>

<p>The cache solves what gets built. comin solves who triggers the build. It’s a GitOps pull-deploy: each host polls the repo and rebuilds its own config, selected by hostname, whenever main advances, with automatic rollback if the new generation fails.</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">services</span><span class="o">.</span><span class="nv">comin</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">enable</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
  <span class="nv">remotes</span> <span class="o">=</span> <span class="p">[{</span>
    <span class="nv">name</span> <span class="o">=</span> <span class="s2">"origin"</span><span class="p">;</span>
    <span class="nv">url</span> <span class="o">=</span> <span class="s2">"https://github.com/devindudeman/nix-config.git"</span><span class="p">;</span>
    <span class="nv">branches</span><span class="o">.</span><span class="nv">main</span><span class="o">.</span><span class="nv">name</span> <span class="o">=</span> <span class="s2">"main"</span><span class="p">;</span>
    <span class="nv">auth</span><span class="o">.</span><span class="nv">access_token_path</span> <span class="o">=</span> <span class="nv">config</span><span class="o">.</span><span class="nv">sops</span><span class="o">.</span><span class="nv">secrets</span><span class="o">.</span><span class="nv">github_pat</span><span class="o">.</span><span class="nv">path</span><span class="p">;</span>
    <span class="nv">poller</span><span class="o">.</span><span class="nv">period</span> <span class="o">=</span> <span class="mi">60</span><span class="p">;</span>  <span class="c"># new commits land within 60s</span>
  <span class="p">}];</span>
<span class="p">};</span>
</code></pre></div></div>

<p>No control node pushing anywhere; each laptop pulls for itself. comin runs as a service, and the result is that whichever machine I open is already current. It caught up in the background the last time it was awake. If it ever gets intrusive on battery, I can raise <code class="language-plaintext highlighter-rouge">poller.period</code> or require a deploy confirmation.</p>

<h2 id="every-device-on-the-same-config-all-the-time">Every device on the same config, all the time</h2>

<p>This is the part I didn’t expect to love so much. Because every host converges on main automatically, the laptop I’m not using stays just as current as the one I am. Switching machines on a whim stops being a “let me rebuild first” moment. That matters most for the things I tweak constantly:</p>

<ul>
  <li>Agent skills. My Claude Code and Codex skills are declared in the flake. I add a skill in one place, push, and the next time each laptop is awake it has it. No copying files around, no wondering which machine has the good version.</li>
  <li>MCP servers. Same story. The declarative MCP config lands on every host, so an agent behaves identically whether I’m on the Gram or the ThinkPad.</li>
  <li>Everything else. Shell, editor, fonts, secrets wiring. The same on whichever machine I reach for, continuously, instead of being the same only on the day I last remembered to rebuild it.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">just update</code> ties it together: bump flake inputs, rebuild locally, auto-commit, push, and the bump fans out to every laptop via comin.</p>

<h2 id="whats-next-ci-that-builds-each-host-ahead-of-time">What’s next: CI that builds each host ahead of time</h2>

<p>Today the cache only warms when a machine happens to build first, and that machine is usually a laptop eating the compile I was trying to avoid. The next step is to make seeding deliberate, with a CI pipeline that builds every host’s full configuration on each push to main and pushes the results to Attic before any laptop polls.</p>

<p>That flips the timing. Instead of the first machine I happen to open eating the rebuild and seeding the cache for the rest, the cache would already be warm by the time any host checks for changes. Every laptop would do a pure substitute with no local compilation at all, even for the unfree and Electron packages. It also turns a broken commit into a red build instead of a failed deploy I notice later. Garnix or a small self-hosted runner pointed at the same Attic cache would both do the job.</p>

<h2 id="gotchas-worth-knowing">Gotchas worth knowing</h2>

<ul>
  <li>comin watches main, so merge before you activate comin on a new host, or it’ll happily deploy the old main.</li>
  <li>Pull is unauthenticated over the tailnet by design. It’s only reachable on the private network, so don’t expose that port.</li>
  <li>Keep <code class="language-plaintext highlighter-rouge">-j</code> at 5 or below on pushes. A higher count exhausts atticd’s SQLite connection pool.</li>
</ul>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[How I keep three NixOS laptops on the same config without thinking about it, with a self-hosted Attic binary cache so they pull expensive builds instead of recompiling, and comin for GitOps pull-deploys.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Just Plane Mosher: Real-Time Flights on a 7-Color E-Ink Display</title><link href="https://devin.fitzsky.com/just-plane-mosher-flights-on-e-ink/" rel="alternate" type="text/html" title="Just Plane Mosher: Real-Time Flights on a 7-Color E-Ink Display" /><published>2026-04-10T00:00:00-07:00</published><updated>2026-04-10T00:00:00-07:00</updated><id>https://devin.fitzsky.com/just-plane-mosher-flights-on-e-ink</id><content type="html" xml:base="https://devin.fitzsky.com/just-plane-mosher-flights-on-e-ink/"><![CDATA[<p>A friend of mine, Mosher, lives in San Francisco. He’s into planes. I wanted to build him something that would show what’s flying overhead right now, updated every few minutes, displayed on something that looks good sitting on a shelf. Not a phone app, not a web dashboard. A physical thing.</p>

<p>The result is <a href="https://github.com/devindudeman/just-plane-mosher">just-plane-mosher</a>: a Raspberry Pi Zero 2 W connected to a Pimoroni Inky Impression 7.3” e-ink display. It pulls live aircraft positions from free ADS-B APIs, plots them on a Stamen Watercolor map centered on the Haight, and renders the whole thing to a 7-color 800x480 screen. Planes show up as little colored arrows pointing in their heading direction, labeled with callsigns and routes. An info bar along the bottom shows the flight count and last update time.</p>

<p>The display refreshes every five minutes. Between refreshes it draws zero power from the screen. The whole thing runs headless off a micro USB cable, tucked into a <a href="https://www.printables.com/model/585713-inky-impression-73-e-paper-framecase/files">3D-printed black frame</a> that makes it look like a small picture frame.</p>

<h2 id="the-display">The display</h2>

<p>The Inky Impression is a 7-color ACeP (Advanced Color ePaper) panel. “7-color” means it can show black, white, red, orange, yellow, green, and blue. That’s it. Every pixel on the screen is exactly one of those seven colors. No gradients, no alpha blending, no antialiasing. If you want to show a photograph or a watercolor map, you have to quantize the entire image down to seven values per pixel and use dithering to fake the rest.</p>

<p>This is where the interesting problem starts.</p>

<h2 id="two-layer-rendering">Two-layer rendering</h2>

<p>Floyd-Steinberg dithering does a good job of making a 7-color image look like it has a much wider palette. The watercolor map tiles come back from Stadia Maps as full RGB, and after dithering they look beautiful on the display. Soft blues for the bay, warm tans for land, the kind of thing you’d actually want on a shelf.</p>

<p>But dithering destroys small details. Text becomes unreadable. Thin lines dissolve into noise. A callsign label like “SWA2046” rendered onto the map before dithering comes out as a smeared mess of scattered pixels. The dithering algorithm doesn’t know that those pixels are supposed to be letters. It just sees color values and spreads the quantization error around.</p>

<p>The fix is to never dither the things that need to be crisp. The renderer works in two passes:</p>

<p><strong>Layer 1</strong> renders the watercolor map and a 10-nautical-mile range ring as a normal RGB image, then quantizes it to the 7-color palette with Floyd-Steinberg dithering. This produces a beautiful, soft background.</p>

<p><strong>Layer 2</strong> draws directly onto the palette-indexed result using exact palette indices. Aircraft arrows, callsign labels, the altitude legend, and the info bar are all placed after dithering, pixel by pixel, in pure palette colors. Black text on white backgrounds. Colored arrows with black borders for contrast.</p>

<p>The key insight is that the Inky library skips its own internal dithering when it receives a pre-quantized palette image. So the crisp Layer 2 content passes through to the hardware untouched. Text stays sharp. Arrows stay clean. The map underneath stays beautifully dithered. Two rendering strategies on one screen, and the display driver doesn’t need to know about either of them.</p>

<h2 id="flight-data">Flight data</h2>

<p>Aircraft positions come from <a href="https://www.adsb.lol/">ADSB.lol</a>, which aggregates data from volunteer-run ADS-B receivers worldwide. The API is free, requires no authentication, and returns every aircraft within a configurable radius of a lat/lon point. Each aircraft record includes position, altitude, heading, ground speed, callsign, registration, and aircraft type.</p>

<p>Callsigns alone aren’t that interesting. “UAL875” tells you it’s a United flight but not where it’s going. So each flight gets enriched with route data from <a href="https://www.adsbdb.com/">ADSBdb</a>, another free API that maps callsigns to airline names and origin/destination airports. The label for a United flight becomes two lines: “UAL875” on top, “SFO&gt;NRT” underneath. Now you’re looking at a map and you can see that one is headed to Tokyo.</p>

<p>ADSBdb gets rate-limited to one request every 200ms, and results are cached for an hour. Callsigns that return 404 (charter flights, military, private aviation) get cached as misses so they don’t keep hammering the API.</p>

<h2 id="altitude-as-color">Altitude as color</h2>

<p>The seven available colors map naturally to altitude bands:</p>

<ul>
  <li><strong>Red</strong>: below 5,000 feet (departures, arrivals, low approaches)</li>
  <li><strong>Orange</strong>: 5,000–15,000 feet (climbing, descending)</li>
  <li><strong>Yellow</strong>: 15,000–30,000 feet (mid-altitude)</li>
  <li><strong>Blue</strong>: above 30,000 feet (cruise)</li>
</ul>

<p>Against the watercolor map, this works well. You can glance at the display and immediately tell which planes are coming or going (red/orange near SFO and OAK) versus which are passing through at cruise altitude (blue dots crossing the bay). Aircraft without heading data render as circles instead of arrows, which usually means they’re on the ground or the receiver has incomplete data.</p>

<h2 id="labels-that-dont-collide">Labels that don’t collide</h2>

<p>Thirteen flights over San Francisco means thirteen labels, and they overlap. The renderer checks each label’s bounding box against every previously placed label. If there’s a collision, it shifts the new label down. If the label would run off the right edge of the screen, it flips to the left side of the arrow. It’s simple box collision, not a layout solver, but it handles the common case of three planes stacked on the SFO approach without turning the display into an unreadable mess.</p>

<h2 id="map-tiles-and-caching">Map tiles and caching</h2>

<p>The background map is assembled from <a href="http://maps.stamen.com/">Stamen</a> tiles fetched through Stadia Maps. The tiles are 256x256 PNGs that get stitched together and cropped to fit the display’s viewport. Three styles are available (Watercolor, Toner, and Terrain), and you can cycle between them with the buttons on the back of the display.</p>

<p>Map tiles are cached to disk. Stamen’s tile set is static (the watercolor paintings aren’t going to change), so the cache effectively never expires. The setup script pre-fetches all the tiles needed for the configured location and zoom level, so the first boot doesn’t have to wait for network requests before it can render.</p>

<h2 id="change-detection">Change detection</h2>

<p>E-ink refreshes are slow. The 7-color ACeP panel takes about 40 seconds for a full refresh. You can watch the colors settle in waves across the screen. You don’t want to do that if nothing has changed.</p>

<p>Before pushing a frame to the display, the renderer computes a SHA-256 hash of the image buffer and compares it to the last one sent. If the hash matches, it skips the refresh entirely. Late at night when air traffic drops off, the display might go an hour without updating. During the morning departure rush, it refreshes every cycle.</p>

<h2 id="buttons">Buttons</h2>

<p>The Inky Impression has four physical buttons on the back, exposed via GPIO. Two of them are wired up:</p>

<ul>
  <li><strong>Button A</strong>: force an immediate refresh (wakes the main loop from its sleep)</li>
  <li><strong>Button B</strong>: cycle through map styles</li>
</ul>

<p>The button listener is interrupt-driven using <code class="language-plaintext highlighter-rouge">gpiod</code> edge detection, so it burns zero CPU while waiting. A press just sets a flag and nudges the main loop.</p>

<h2 id="running-it">Running it</h2>

<p>The whole thing runs as a systemd service on Raspbian. A setup script handles SPI/I2C configuration, Python venv creation, dependency installation, tile pre-caching, and service registration. After setup, it starts on boot and restarts automatically on failure with exponential backoff.</p>

<p>The project is <a href="https://github.com/devindudeman/just-plane-mosher">on GitHub</a>. It’s built for one specific display and one specific location, but the location is configurable via <code class="language-plaintext highlighter-rouge">.env</code> and the rendering approach would work for any 7-color e-ink panel. If you have an Inky Impression and want to watch planes, it’s a <code class="language-plaintext highlighter-rouge">git clone</code> and a setup script away.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[I built a flight tracker for a friend in San Francisco. A Raspberry Pi, a 7-color e-ink display, and some free APIs. The hard part was making text readable on a dithered watercolor map.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Streaming Expedition 33 from a Headless NixOS Desktop</title><link href="https://devin.fitzsky.com/streaming-expedition-33-from-a-headless-nixos-desktop/" rel="alternate" type="text/html" title="Streaming Expedition 33 from a Headless NixOS Desktop" /><published>2026-04-07T00:00:00-07:00</published><updated>2026-04-07T00:00:00-07:00</updated><id>https://devin.fitzsky.com/streaming-expedition-33-from-a-headless-nixos-desktop</id><content type="html" xml:base="https://devin.fitzsky.com/streaming-expedition-33-from-a-headless-nixos-desktop/"><![CDATA[<p>I wanted to play Expedition 33 well, and the Steam Deck couldn’t do it. The game is built on Unreal Engine 5 and it asks for a lot of GPU. On the Deck it launched as “Unsupported,” eventually got upgraded to “Playable,” and even after the post-launch optimization update the recommended settings cap you at 30fps with the rendering preset cranked all the way down. Combat still drops below 25fps in some areas. People have written entire performance mods just to make it tolerable. The Deck does its best, but the APU is being asked to do something it can’t.</p>

<p>The display side was already fine. I plug a pair of <a href="https://www.viture.com/">Viture Pro XR glasses</a> into the Deck over USB-C and get a 1080p 120Hz virtual screen at around 135 inches floating in front of me. The Deck plus the glasses is a great portable display setup. The rendering is what falls over.</p>

<p>Meanwhile, my actual gaming desktop (5950X, 3090) sits in another room, doing nothing most of the time, and I’d rather play a JRPG on the couch than at a desk. So: Sunshine on the desktop, Moonlight on the Deck, full-fat NVENC streaming over the LAN. The 3090 renders Expedition 33 at high settings and a real frame rate, NVENC encodes the result, the Deck decodes it and pipes it straight to the glasses. The Deck stops trying to be a renderer and goes back to what it’s actually good at: receiving input, decoding video, and sitting in my hands.</p>

<p>The catch is that the desktop has to behave like a normal GNOME session with no human and no monitor present, because there isn’t one. Every weird piece of this config exists because of that constraint.</p>

<h2 id="lying-to-the-gpu">Lying to the GPU</h2>

<p>The 3090 won’t bring up a video output without EDID data on the wire. With no monitor plugged in, GNOME boots into a dummy headless mode and Sunshine has nothing to capture. The fix is a passive HDMI EDID emulator: a <a href="https://www.amazon.com/dp/B0D4Z7MR9G">$10 dongle from Amazon</a> that returns a fake “I am a 4K monitor” handshake. The kernel and NVIDIA driver bring up DP-1 with real modes, GNOME boots a normal session, and Sunshine has something to capture.</p>

<p>This is the cheapest part of the build. Without it, the rest of this config has nothing to point at.</p>

<h2 id="lying-to-gdm">Lying to GDM</h2>

<p>For Sunshine to capture a session, a session has to exist. So GDM auto-logs me in on boot:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">services</span><span class="o">.</span><span class="nv">displayManager</span><span class="o">.</span><span class="nv">autoLogin</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">enable</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
  <span class="nv">user</span> <span class="o">=</span> <span class="s2">"devinbernosky"</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>A few extra knobs disable the foot-guns:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">services.xserver.displayManager.gdm.autoSuspend = false</code>. By default GDM suspends the box at the login screen if nobody moves a mouse, which would defeat the entire point.</li>
  <li>The <code class="language-plaintext highlighter-rouge">gdm-autologin</code> PAM stack gets <code class="language-plaintext highlighter-rouge">enableGnomeKeyring = true</code> so the keyring unlocks without typing a password. Otherwise Steam, browsers, and everything else spam keyring prompts forever.</li>
  <li>Screen lock and idle activation are off in dconf, but I left inactive suspend after 30 minutes alone to save power. Wake-on-LAN handles the rest. Moonlight has built-in WoL support, so the Deck can cold-start the box from the couch.</li>
</ul>

<h2 id="lying-to-bwrap-about-sunshines-parent">Lying to bwrap about Sunshine’s parent</h2>

<p>This was the hardest part to figure out and the most worth writing about.</p>

<p>Sunshine on Wayland captures via KMS, and KMS capture needs <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>. The NixOS module exposes that as one knob:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">services</span><span class="o">.</span><span class="nv">sunshine</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">capSysAdmin</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
  <span class="nv">package</span> <span class="o">=</span> <span class="nv">pkgs</span><span class="o">.</span><span class="nv">sunshine</span><span class="o">.</span><span class="nv">override</span> <span class="p">{</span> <span class="nv">cudaSupport</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span> <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cudaSupport = true</code> flips Sunshine onto NVENC, which is the entire reason a 3090 is worth using as a streaming source. It also means this build can’t come from the binary cache, it has to compile locally.</p>

<p>The non-obvious part is that <code class="language-plaintext highlighter-rouge">capSysAdmin = true</code> is poison for bubblewrap. Steam (and anything else sandboxed via bwrap) refuses to launch from a process tree that carries elevated capabilities, which is reasonable on bwrap’s part but breaks every Sunshine “launch app” entry that just calls <code class="language-plaintext highlighter-rouge">steam</code>. I tracked this down through nixpkgs#463989 after spending way too long on Steam launching and immediately dying with no useful error.</p>

<p>The fix is that any command Sunshine launches has to drop back to a normal user context first. My “Steam Big Picture” entry looks like this:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo</span> <span class="nt">-u</span> devinbernosky setsid steam <span class="nt">-bigpicture</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">sudo -u</code> strips the inherited capabilities. <code class="language-plaintext highlighter-rouge">setsid</code> detaches the new process from Sunshine’s process group, so closing the stream from the Deck doesn’t kill Steam along with it. Two small flags, a lot of pain saved.</p>

<h2 id="lying-to-mutter-about-which-monitor-it-has">Lying to mutter about which monitor it has</h2>

<p>The desktop’s actual panel, when one is plugged in, is a 5120x1440 ultrawide at 120 Hz. The Steam Deck with the Viture glasses attached asks for 1080p at 120 Hz, which is what the glasses want. I don’t want games launching at ultrawide resolutions and getting downscaled, and I don’t want the Deck and the glasses negotiating with weird non-standard modes.</p>

<p>Sunshine has a <code class="language-plaintext highlighter-rouge">global_prep_cmd</code> setting that runs a script when a client connects and an “undo” script when it disconnects. Mine uses <code class="language-plaintext highlighter-rouge">gdctl</code>, the new GNOME display CLI that ships with mutter 49+, to actually reconfigure the compositor on the fly:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">sunshine-switch</code> reads <code class="language-plaintext highlighter-rouge">$SUNSHINE_CLIENT_WIDTH</code> and <code class="language-plaintext highlighter-rouge">$SUNSHINE_CLIENT_HEIGHT</code>, asks <code class="language-plaintext highlighter-rouge">gdctl show -v</code> for a matching mode on DP-1, and switches to it.</li>
  <li><code class="language-plaintext highlighter-rouge">sunshine-restore</code> puts the desktop back to 5120x1440@119.999 when the stream ends.</li>
</ul>

<p>The wrinkle is that <code class="language-plaintext highlighter-rouge">gdctl</code> has to talk to the user’s mutter over D-Bus, and Sunshine’s user service doesn’t inherit that environment cleanly. Each call gets wrapped:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo</span> <span class="nt">-u</span> devinbernosky <span class="nv">DBUS_SESSION_BUS_ADDRESS</span><span class="o">=</span>unix:path<span class="o">=</span>/run/user/1000/bus gdctl ...
</code></pre></div></div>

<p>Same <code class="language-plaintext highlighter-rouge">sudo -u</code> trick as the Steam launcher, doing double duty: dropping caps and re-entering the right session bus.</p>

<h2 id="cleaning-up-after-suspend">Cleaning up after suspend</h2>

<p>Resuming from suspend leaves NVENC in a strange state. Sunshine keeps running but its encoder handles are stale, so streams fail to start until I restart the service manually. A small oneshot fixes this automatically:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">systemd</span><span class="o">.</span><span class="nv">services</span><span class="o">.</span><span class="nv">sunshine-resume</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nv">after</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"systemd-suspend.service"</span> <span class="s2">"nvidia-resume.service"</span> <span class="p">];</span>
  <span class="nv">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"suspend.target"</span> <span class="s2">"hibernate.target"</span> <span class="s2">"hybrid-sleep.target"</span> <span class="p">];</span>
  <span class="nv">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nv">Type</span> <span class="o">=</span> <span class="s2">"oneshot"</span><span class="p">;</span>
    <span class="nv">ExecStartPre</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span><span class="nv">pkgs</span><span class="o">.</span><span class="nv">coreutils</span><span class="si">}</span><span class="s2">/bin/sleep 5"</span><span class="p">;</span>
    <span class="nv">ExecStart</span> <span class="o">=</span> <span class="s2">"systemctl --user --machine=devinbernosky@.host restart sunshine.service"</span><span class="p">;</span>
  <span class="p">};</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--machine=devinbernosky@.host</code> flag is what lets a system unit poke the user’s systemd instance. It’s the cleanest way to bounce a user service from PID 1 without writing a polkit rule.</p>

<p>There’s also an <code class="language-plaintext highlighter-rouge">ExecStartPre = sleep 10</code> on the Sunshine user service itself, to give GNOME enough time to bring DP-1 up before Sunshine probes for displays on first boot. Without it, Sunshine occasionally latches onto a “no monitors” state and just sulks.</p>

<h2 id="input">Input</h2>

<p><code class="language-plaintext highlighter-rouge">hardware.uinput.enable = true</code>, plus adding my user to the <code class="language-plaintext highlighter-rouge">input</code> group, is the workaround for nixpkgs#455737. Sunshine needs to write to <code class="language-plaintext highlighter-rouge">/dev/uinput</code> to inject controller and keyboard events from the Deck. Without it the stream connects fine but the controller does nothing, which is its own kind of frustrating.</p>

<h2 id="desktop-only-gaming-polish">Desktop-only gaming polish</h2>

<p>A bunch of this stuff doesn’t belong on my travel laptops. They shouldn’t be opening Steam Remote Play firewall ports or disabling OpenSnitch. So I split it out into <code class="language-plaintext highlighter-rouge">hosts/desktop/gaming.nix</code> and only the desktop pulls it in:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">proton-ge-bin</code> and <code class="language-plaintext highlighter-rouge">protontricks</code> for games that need community Proton builds. Expedition 33 was a GE-fork target early on.</li>
  <li><code class="language-plaintext highlighter-rouge">programs.gamemode</code> enabled with <code class="language-plaintext highlighter-rouge">renice = 10</code> so launched games get <code class="language-plaintext highlighter-rouge">nice -10</code>.</li>
  <li>OpenSnitch off, because per-connection prompts will absolutely ruin a streaming session.</li>
  <li><code class="language-plaintext highlighter-rouge">programs.steam.remotePlay.openFirewall = true</code> and <code class="language-plaintext highlighter-rouge">localNetworkGameTransfers.openFirewall = true</code>.</li>
</ul>

<p>And on the Home Manager side, in <code class="language-plaintext highlighter-rouge">hosts/desktop/home.nix</code>:</p>

<ul>
  <li>Tiling Shell instead of Pop Shell. Pop’s tiling assumes 16:9-ish geometry and is miserable at 5120x1440. Tiling Shell lets me draw custom snap zones for the ultrawide.</li>
  <li>NVIDIA shader cache pinned to 10 GB with <code class="language-plaintext highlighter-rouge">__GL_SHADER_DISK_CACHE_SIZE</code> and <code class="language-plaintext highlighter-rouge">__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1</code>. The driver’s default tiny cache evicts compiled shaders mid-game and you get stutter every time it has to recompile.</li>
  <li>An XDG autostart entry that launches <code class="language-plaintext highlighter-rouge">steam -silent</code> on login. The moment GDM auto-logs in, Steam is already sitting in the tray waiting for Moonlight to connect.</li>
</ul>

<h2 id="the-little-things">The little things</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">boot.kernelPackages = pkgs.linuxPackages_latest</code> and <code class="language-plaintext highlighter-rouge">boot.initrd.systemd.enable = true</code> for a fast headless boot. I dropped Plymouth because there’s nobody to look at the splash screen.</li>
  <li><code class="language-plaintext highlighter-rouge">hardware.nvidia.open = true</code> because Ampere is on the open kernel modules now per NVIDIA’s recommendation.</li>
  <li><code class="language-plaintext highlighter-rouge">services.ollama.acceleration = "cuda"</code>. The same 24 GB of VRAM that streams Expedition 33 also runs local LLMs when nobody is gaming.</li>
  <li><code class="language-plaintext highlighter-rouge">networking.interfaces.enp39s0.wakeOnLan.enable = true</code> so the Deck can wake the box from a cold suspend.</li>
  <li>CoolerControl for the NZXT Kraken AIO, so the 5950X doesn’t thermal-throttle mid-session.</li>
</ul>

<h2 id="does-it-work">Does it work?</h2>

<p>Yes, perfectly. The desktop sits suspended most of the time. When I want to play, I put on the glasses, pick up the Deck, and open Moonlight. Moonlight’s built-in Wake-on-LAN wakes the box, GDM auto-logs in, Sunshine comes up, the resolution switches to 1080p120 to match what the glasses want, and Steam launches into Big Picture. The 3090 renders Expedition 33, NVENC encodes the stream, the Deck decodes it, and the glasses show me the result. When I close the stream the desktop drops back to idle, hits the 30-minute inactivity timeout, and goes back to sleep on its own.</p>

<p>The Wake-on-LAN piece matters more than it might sound. A 3090 at idle still pulls real wattage, and the whole system sitting up 24/7 just to be “available” would burn 80-100W around the clock for nothing. With WoL doing the heavy lifting, the desktop is at near-zero power most of the day. The Deck wakes it on demand, I get full 3090 performance for as long as I want, and then it puts itself back to sleep without any thought from me.</p>

<p>The whole thing is in my NixOS config. If you find this post by searching for some variant of “Sunshine launches Steam and Steam immediately dies on Wayland,” the answer is <code class="language-plaintext highlighter-rouge">sudo -u $USER setsid &lt;command&gt;</code>. The bubblewrap-vs-<code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> interaction took me longer to track down than I’d like to admit, and I’m leaving this here so the next person doesn’t have to.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[I wanted to play Expedition 33 well, and the Steam Deck couldn't do it. So I turned my desktop into a headless Sunshine box and streamed it to XR glasses through the Deck.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">What Does Claude Code Actually Do? Building laudec to Find Out</title><link href="https://devin.fitzsky.com/what-does-claude-code-actually-do-building-laudec-to-find-out/" rel="alternate" type="text/html" title="What Does Claude Code Actually Do? Building laudec to Find Out" /><published>2026-03-26T00:00:00-07:00</published><updated>2026-03-26T00:00:00-07:00</updated><id>https://devin.fitzsky.com/what-does-claude-code-actually-do-building-laudec-to-find-out</id><content type="html" xml:base="https://devin.fitzsky.com/what-does-claude-code-actually-do-building-laudec-to-find-out/"><![CDATA[<p>If you use Claude Code, you’ve probably had the thought: <em>what is actually happening right now?</em></p>

<p>You type a prompt, Claude does… something, files change, tokens get burned, and you pay for it. But you can’t really see what happened between your prompt and the result. How many API calls did that take? What did the system prompt look like? Did it spawn subagents? How fast is the context window filling up? What tools did it decide to use, and which did it reject?</p>

<p>I wanted to know. So I built <a href="https://github.com/devindudeman/laudec">laudec</a>.</p>

<h2 id="claude-code-is-more-transparent-than-you-think">Claude Code is more transparent than you think</h2>

<p>Claude Code already exposes a lot about its own operation. The surface area is there, it’s just that nobody has wired it all together in one place. There are three channels worth knowing about.</p>

<h3 id="1-the-api-proxy-surface">1. The API proxy surface</h3>

<p>Claude Code reads the <code class="language-plaintext highlighter-rouge">ANTHROPIC_BASE_URL</code> environment variable. If set, all API traffic routes through that URL instead of going directly to <code class="language-plaintext highlighter-rouge">api.anthropic.com</code>. This is a first-class configuration point, not a hack. It means you can place anything you want between Claude Code and Anthropic’s API: a logging proxy, a cache, a rate limiter, an audit trail.</p>

<p>Every request that flows through this proxy carries the full conversation context: the system prompt, the message history, the tool definitions, the model parameters. Every response carries token usage, cache statistics, rate limit headers, and the complete model output (streamed as SSE events). This is the richest data source available, and capturing it requires nothing more than an HTTP server and an environment variable.</p>

<h3 id="2-opentelemetry">2. OpenTelemetry</h3>

<p>Claude Code ships with native OpenTelemetry support. Set a few environment variables and it starts emitting structured telemetry over gRPC:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CLAUDE_CODE_ENABLE_TELEMETRY=1
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:14317
</code></pre></div></div>

<p>Two categories of data come out. Metrics are counters and gauges exported on a regular interval: <code class="language-plaintext highlighter-rouge">session.count</code>, <code class="language-plaintext highlighter-rouge">token.usage</code>, <code class="language-plaintext highlighter-rouge">cost.usage</code>, <code class="language-plaintext highlighter-rouge">active_time.total</code>, <code class="language-plaintext highlighter-rouge">lines_of_code.count</code>, <code class="language-plaintext highlighter-rouge">commit.count</code>, <code class="language-plaintext highlighter-rouge">pull_request.count</code>, and <code class="language-plaintext highlighter-rouge">code_edit_tool.decision</code> (tracking accept/reject rates on edits). Events are point-in-time log records for discrete actions (<code class="language-plaintext highlighter-rouge">user_prompt</code>, <code class="language-plaintext highlighter-rouge">api_request</code>, <code class="language-plaintext highlighter-rouge">tool_decision</code>, <code class="language-plaintext highlighter-rouge">tool_result</code>). Each event carries a <code class="language-plaintext highlighter-rouge">session.id</code> attribute that ties everything back to a single Claude Code session, and a <code class="language-plaintext highlighter-rouge">prompt.id</code> that links all the events triggered by a single user prompt: the API calls it caused, the tools it invoked, the decisions that were made. This is the correlation key that makes it possible to trace a single prompt through the entire chain of actions it triggered.</p>

<p>This is a different view than the proxy gives you. The proxy sees raw HTTP traffic. OTEL sees Claude Code’s internal model of what happened: “I decided to use the Read tool,” “the tool succeeded in 120ms,” “that API call cost $0.0043.” You want both.</p>

<h3 id="3-settings-and-hooks">3. Settings and hooks</h3>

<p>Claude Code reads project-level configuration from <code class="language-plaintext highlighter-rouge">.claude/settings.local.json</code>. This file can set environment variables, sandbox rules, and tool permissions. It’s the glue that connects the first two channels: you write a settings file that points Claude Code’s OTEL exporter at your collector and its API base URL at your proxy, and everything starts flowing.</p>

<p>But Claude Code also has a full lifecycle hook system. Hooks are shell commands, HTTP endpoints, or even LLM prompts that fire at specific points during a session. There are 20+ hook events: <code class="language-plaintext highlighter-rouge">SessionStart</code>, <code class="language-plaintext highlighter-rouge">PreToolUse</code>, <code class="language-plaintext highlighter-rouge">PostToolUse</code>, <code class="language-plaintext highlighter-rouge">PermissionRequest</code>, <code class="language-plaintext highlighter-rouge">Stop</code>, <code class="language-plaintext highlighter-rouge">SubagentStart</code>, <code class="language-plaintext highlighter-rouge">SubagentStop</code>, <code class="language-plaintext highlighter-rouge">PreCompact</code>, <code class="language-plaintext highlighter-rouge">Notification</code>, and more. Each one receives structured JSON about what’s happening and can respond with decisions (allow, deny, block, modify the tool input, inject context into the conversation).</p>

<p>A <code class="language-plaintext highlighter-rouge">PreToolUse</code> hook can inspect every Bash command before it runs and block destructive operations. A <code class="language-plaintext highlighter-rouge">PostToolUse</code> hook can auto-format every file after Claude edits it. A <code class="language-plaintext highlighter-rouge">SessionStart</code> hook can inject git status and open TODOs into the conversation at the top of every session. A <code class="language-plaintext highlighter-rouge">Stop</code> hook can run your test suite before letting Claude declare it’s done, and force it to keep working if tests fail (exit code 2).</p>

<p>These hooks are deterministic. They don’t depend on the model remembering your instructions. They fire every time.</p>

<p>For observability purposes, hooks are a third channel alongside the proxy and OTEL. You could log every tool call, capture every permission decision, track subagent lifecycle events, and feed all of it into whatever backend you want. laudec doesn’t use hooks yet, just the settings file to wire up the proxy and OTEL collector. But the hook system is sitting right there as a future extension point for even finer-grained visibility.</p>

<h2 id="what-laudec-does-with-all-of-this">What laudec does with all of this</h2>

<p>laudec is a single Rust binary that wires up all three channels and gives you a place to look at the results. When you run <code class="language-plaintext highlighter-rouge">laudec .</code> in a project directory, it:</p>

<ol>
  <li>Starts a local HTTP proxy on port 18080</li>
  <li>Starts a gRPC OTEL collector on port 14317</li>
  <li>Writes a temporary <code class="language-plaintext highlighter-rouge">.claude/settings.local.json</code> that routes Claude Code’s traffic through both</li>
  <li>Launches Claude Code as a child process</li>
  <li>Serves a web dashboard on port 18384</li>
  <li>Stores everything in a single SQLite database</li>
</ol>

<p>When the session ends, it restores the original settings file, computes a session summary (duration, cost, tokens, git diff, tool usage), and prints it to the terminal.</p>

<p>No Docker, no external services, no configuration for the default case. The proxy, collector, dashboard, and database are all in the same binary.</p>

<h3 id="the-proxy-view">The proxy view</h3>

<p>The proxy tab shows Claude Code’s actual API conversations. Every call is classified by type:</p>

<p><strong>MAIN</strong> calls are the primary conversation turns, where Claude Code sends the full context window with extended thinking enabled. These are labeled by turn number so you can track the flow of a session.</p>

<p><strong>SUBAGENT</strong> calls are spawned by Claude Code’s internal delegation system. When it decides a subtask is better handled by a focused agent, it creates a new API call with a specialized system prompt and a constrained tool set. laudec detects these by inspecting the request body and tags them by role: EXPLORE (file search), WEB SEARCH, CC GUIDE, and so on.</p>

<p><strong>QUOTA</strong> calls are lightweight checks (<code class="language-plaintext highlighter-rouge">max_tokens=1</code>) that Claude Code uses to verify API access before committing to an expensive request.</p>

<p><strong>TOKEN COUNT</strong> calls hit the <code class="language-plaintext highlighter-rouge">count_tokens</code> endpoint to measure context size without generating a response.</p>

<p>For each call, you see the user query and model response rendered as markdown, the tool usage summary (e.g., “Read x3, Edit x2”), token counts, cache statistics, latency, and the raw request/response bodies with syntax highlighting. System-injected blocks like <code class="language-plaintext highlighter-rouge">&lt;system-reminder&gt;</code> and <code class="language-plaintext highlighter-rouge">&lt;tool-use-rules&gt;</code> are parsed out and displayed in collapsible sections so you can see exactly what Claude Code appends to your messages behind the scenes.</p>

<h3 id="the-otel-view">The OTEL view</h3>

<p>The events tab groups telemetry by conversation turn, anchored by each user prompt. Within a turn, you see the chain of decisions Claude Code made: which API calls it fired, which tools it considered, which it used, whether they succeeded, and how long they took.</p>

<p>Cost visibility comes from this channel. Each <code class="language-plaintext highlighter-rouge">api_request</code> event carries the exact cost breakdown from Claude Code’s own accounting: input tokens, output tokens, cache read tokens, cache creation tokens, and the computed USD cost. The proxy can tell you token counts from the response headers, but only the OTEL data gives you the cost as Claude Code calculated it.</p>

<h3 id="insights">Insights</h3>

<p>The insights tab derives higher-order patterns from the raw data:</p>

<p><strong>Context growth</strong> shows input tokens per API call over the session. <strong>Cache analysis</strong> shows hit rate and estimated cost savings. <strong>Rate limits</strong> track <code class="language-plaintext highlighter-rouge">x-ratelimit-remaining-requests</code> and <code class="language-plaintext highlighter-rouge">x-ratelimit-remaining-tokens</code> from Anthropic’s response headers per call. <strong>Stop reasons</strong> aggregate why each API call ended: <code class="language-plaintext highlighter-rouge">end_turn</code>, <code class="language-plaintext highlighter-rouge">tool_use</code>, or <code class="language-plaintext highlighter-rouge">max_tokens</code>.</p>

<h2 id="what-i-learned-by-watching">What I learned by watching</h2>

<p>Building laudec was partly about the tool and partly about what it showed me. I spent a lot of time staring at the dashboard during real sessions, and some of the behavior I found was not what I expected.</p>

<h3 id="the-system-prompt-is-not-one-thing">The system prompt is not one thing</h3>

<p>Claude Code doesn’t have a single monolithic system prompt. What you see in the proxy is a composite assembled from dozens of modular pieces at runtime. Thanks to projects like <a href="https://github.com/Piebald-AI/claude-code-system-prompts">Piebald-AI/claude-code-system-prompts</a>, which extracts and catalogs these pieces from each Claude Code release, we know the current version (v2.1.x) contains over 110 distinct prompt strings that get composed based on context.</p>

<p>The pieces include: the core system section, tool descriptions for each of the 20+ builtin tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, TodoWrite, and others), behavioral guidelines for tone and output style, task-doing instructions (avoid over-engineering, read before modifying, no premature abstractions, no unnecessary error handling, minimize file creation), git safety rules, sandbox policy, fork/subagent delegation guidelines, and whatever CLAUDE.md context exists in your project.</p>

<p>The tool descriptions alone are substantial. The Bash tool description is assembled from over 30 fragments covering sandboxing policy, sleep behavior, git commit conventions, parallel command execution, when to prefer builtin tools over shell equivalents, and more. The TodoWrite tool description runs over 2,000 tokens. These aren’t decorative. They’re the behavioral contract that shapes how Claude Code wields each tool.</p>

<p>When you open a session, all of this gets packed into the first API call and cached. Watching it happen in the proxy, you can see the <code class="language-plaintext highlighter-rouge">system</code> field span tens of thousands of tokens. Then on the second call, <code class="language-plaintext highlighter-rouge">cache_read_input_tokens</code> lights up and <code class="language-plaintext highlighter-rouge">cache_creation_input_tokens</code> drops to zero. The entire system prompt is served from cache at a fraction of the cost for every subsequent call in the session.</p>

<h3 id="system-reminders-are-injected-into-your-messages">System reminders are injected into your messages</h3>

<p>This surprised me. Claude Code doesn’t just set a system prompt at the start of the conversation. It actively injects content into subsequent user messages as the session progresses. These show up as XML-tagged blocks appended to what you typed.</p>

<p><code class="language-plaintext highlighter-rouge">&lt;system-reminder&gt;</code> blocks carry context-sensitive instructions: file-was-modified-externally notifications, TodoWrite reminders, token usage stats, plan mode activation (which alone is over 1,000 tokens of multi-phase planning instructions). <code class="language-plaintext highlighter-rouge">&lt;tool-use-rules&gt;</code> blocks remind the model about tool constraints. <code class="language-plaintext highlighter-rouge">&lt;available-deferred-tools&gt;</code> lists tools that can be loaded on demand.</p>

<p>The catalog of known system reminders is extensive. There are ~40 distinct reminder types covering everything from “file exists but is empty” warnings, to hook success/failure notifications, to LSP diagnostic alerts, to team coordination instructions for multi-agent swarm mode. These are injected conditionally based on session state. You might never see most of them, but the ones that fire directly shape what the model does next.</p>

<p>In laudec’s proxy tab, these blocks are parsed out and displayed in collapsible sections beneath your actual message. You can see exactly what Claude Code appends on your behalf, and how much context budget it eats.</p>

<h3 id="subagents-are-a-parallel-conversation-you-cant-see">Subagents are a parallel conversation you can’t see</h3>

<p>A single user prompt can spawn half a dozen API calls. When Claude Code decides to explore a codebase, it doesn’t do it in the main conversation thread. It launches an Explore subagent with its own system prompt, a read-only tool set (Read, Glob, Grep, LS), and its own conversation history. The subagent does its work, returns a summary, and the main agent incorporates the result.</p>

<p>The subagent architecture goes deeper than just Explore. Claude Code has specialized agents for planning (Plan mode, with its own enhanced prompt), web fetching (a summarizer agent that distills verbose page content), bash command risk assessment (a policy spec evaluator that classifies command prefix risk levels), conversation compaction (for summarizing history when context gets long), session title generation, CLAUDE.md creation, security review, verification, and even “dream memory consolidation” for cross-session knowledge synthesis.</p>

<p>In the proxy, this looks like a MAIN call, then two or three SUBAGENT calls firing in quick succession with noticeably smaller context windows (no extended thinking, focused system prompts, limited tool sets), then the main conversation resuming. The subagent calls are often cheaper per-call, but they add up. A complex refactoring prompt might generate 15+ API calls total, and you’d never know from the terminal output.</p>

<p>laudec tags each subagent by role by inspecting the system prompt content. “file search specialist” becomes EXPLORE. “web search tool use” becomes WEB SEARCH. “Claude Code Guide” becomes CC GUIDE. These heuristics are fragile (they depend on prompt wording that Anthropic changes between releases), but they make the multi-agent orchestration visible.</p>

<h3 id="tool-decisions-happen-before-tool-results">Tool decisions happen before tool results</h3>

<p>The OTEL telemetry separates <code class="language-plaintext highlighter-rouge">tool_decision</code> events from <code class="language-plaintext highlighter-rouge">tool_result</code> events. This means you can see what Claude Code <em>considered</em> doing, not just what it did. A <code class="language-plaintext highlighter-rouge">tool_decision</code> fires when Claude Code evaluates whether to allow a tool the model requested, capturing the accept/reject outcome and the decision source (config rule, hook, user approval). A <code class="language-plaintext highlighter-rouge">tool_result</code> fires when the tool actually executes. The gap between them is where permission checks, sandbox validation, and user approval happen.</p>

<p>In the events tab, you can trace the full chain: user_prompt → api_request → tool_decision (Read) → tool_result (success, 45ms) → tool_decision (Edit) → tool_result (success, 12ms) → api_request → … You can see exactly where time goes. In sessions with many tool calls, the cumulative tool execution time can rival or exceed the API latency.</p>

<p>The <code class="language-plaintext highlighter-rouge">tool_result</code> events also carry a <code class="language-plaintext highlighter-rouge">success</code> boolean, and when <code class="language-plaintext highlighter-rouge">OTEL_LOG_TOOL_DETAILS=1</code> is set, they include the <code class="language-plaintext highlighter-rouge">tool_input</code> with file paths, search patterns, and command arguments (truncated to ~4K characters). This means you can see not just <em>that</em> a tool was used, but <em>what it was asked to do</em> and <em>whether it worked</em>. Failed tool calls show up in laudec’s metrics tab as red failure counts next to each tool name.</p>

<h3 id="context-growth-is-predictable-mostly">Context growth is predictable, mostly</h3>

<p>In a typical session, input tokens grow roughly linearly. Each turn adds your prompt, the model’s response, and any tool results to the conversation history. The context growth chart in laudec’s insights tab makes this staircase pattern visible.</p>

<p>But there are disruptions. A large file read (the Read tool pulling in a 2,000-line source file) causes a sudden spike. Claude Code’s internal conversation compaction, which fires when context approaches the model’s limit, causes a sharp drop. And subagent calls don’t grow the main context at all since they have their own isolated conversation.</p>

<p>Worth paying attention to: the relationship between cache reads and context size. As the session progresses and the context window fills, the ratio of cached tokens to fresh input tokens increases. The system prompt and early conversation history stay cached while only the newest messages are “fresh.” Longer sessions are actually more cost-efficient per-turn than short ones, up to the point where compaction fires and reshuffles the cache.</p>

<h3 id="the-quota-check">The quota check</h3>

<p>Before the first real API call in a session, Claude Code sends a request with <code class="language-plaintext highlighter-rouge">max_tokens: 1</code>. This is a quota check: a near-zero-cost probe to verify that the API key is valid and rate limits haven’t been hit before committing tokens to a real call.</p>

<p>You can see these in the proxy tab as QUOTA-type calls. They return almost instantly (usually under 200ms) and consume negligible tokens. If you’re troubleshooting authentication or rate limit issues, these are the first calls to inspect.</p>

<h3 id="rate-limit-headroom">Rate limit headroom</h3>

<p>Anthropic’s API responses include headers like <code class="language-plaintext highlighter-rouge">x-ratelimit-remaining-requests</code> and <code class="language-plaintext highlighter-rouge">x-ratelimit-remaining-tokens</code>. Claude Code doesn’t surface this information anywhere in its UI. But the proxy captures every response header, and laudec’s insights tab tracks these values over time.</p>

<p>In normal usage, rate limits are a non-issue. But in heavy sessions, especially those with many subagent calls, you can watch the remaining-requests counter drop. If you’re running multiple Claude Code instances or using agentic orchestration tools that spawn parallel sessions, this visibility matters. laudec’s threshold warnings (red highlights when remaining requests drop below 10 or remaining tokens below 10,000) make it possible to anticipate rate limit problems rather than discovering them mid-session.</p>

<h3 id="stop-reasons-tell-you-how-the-model-is-being-used">Stop reasons tell you how the model is being used</h3>

<p>Every API call ends with a <code class="language-plaintext highlighter-rouge">stop_reason</code>: <code class="language-plaintext highlighter-rouge">end_turn</code> (the model finished its response), <code class="language-plaintext highlighter-rouge">tool_use</code> (the model wants to call a tool and is yielding control), or <code class="language-plaintext highlighter-rouge">max_tokens</code> (the response hit the token limit).</p>

<p>In a healthy session, you’ll see a mix of <code class="language-plaintext highlighter-rouge">tool_use</code> and <code class="language-plaintext highlighter-rouge">end_turn</code> stops. <code class="language-plaintext highlighter-rouge">tool_use</code> stops dominate during active work (the model is in a loop of reading, editing, running commands), and <code class="language-plaintext highlighter-rouge">end_turn</code> appears when the model reports back to you.</p>

<p>A session full of <code class="language-plaintext highlighter-rouge">max_tokens</code> stops tells a different story. It means the model is repeatedly hitting the output ceiling, which usually indicates the context window is nearing its limit and responses are getting truncated. Watching the stop reason distribution in laudec’s insights tab alongside the context growth chart gives you early warning that a session is running hot.</p>

<h3 id="cost-scales-with-decisions-not-prompts">Cost scales with decisions, not prompts</h3>

<p>What you pay has almost nothing to do with how many prompts you type. It depends on what Claude Code decides to do with each one. A 10-prompt session where each prompt triggers a single tool call costs far less than a 3-prompt session where each prompt triggers a multi-step tool chain with subagent exploration, file reads, edits, and verification.</p>

<p>The OTEL <code class="language-plaintext highlighter-rouge">api_request</code> events make this visible. Each event carries a <code class="language-plaintext highlighter-rouge">cost_usd</code> attribute calculated by Claude Code itself. Sorting sessions by cost and comparing them to prompt count reveals that the biggest cost driver is usually one or two prompts that trigger deep exploration or complex multi-file edits. The “fix the tests” prompt that spawns 8 subagent calls and reads 15 files costs more than the rest of the session combined.</p>

<p>Once you see this, it affects how you write prompts. Specific, well-scoped requests (“fix the type error in <code class="language-plaintext highlighter-rouge">parser.rs</code> line 42”) generate simple tool chains. Broad requests (“refactor the authentication system”) trigger deep subagent exploration. Both are fine. But without visibility into the actual call graph, you can’t know what each one costs or why.</p>

<h2 id="try-it">Try it</h2>

<p>laudec is <a href="https://github.com/devindudeman/laudec">on GitHub</a>. It’s MIT licensed, written in Rust with a Svelte dashboard, and I’d welcome feedback on what’s useful and what’s missing.</p>

<p>The point of laudec is to learn. I wanted to see what was actually happening when I handed my project to an AI coding agent and said “fix the tests.” Now I can. If you’re curious about the same thing, give it a try.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[If you use Claude Code, you've probably had the thought — what is actually happening right now? I built laudec to find out.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">NixClaw: Declarative AI Agents on NixOS</title><link href="https://devin.fitzsky.com/nixclaw-declarative-ai-agents-on-nixos/" rel="alternate" type="text/html" title="NixClaw: Declarative AI Agents on NixOS" /><published>2026-03-11T00:00:00-07:00</published><updated>2026-03-11T00:00:00-07:00</updated><id>https://devin.fitzsky.com/nixclaw-declarative-ai-agents-on-nixos</id><content type="html" xml:base="https://devin.fitzsky.com/nixclaw-declarative-ai-agents-on-nixos/"><![CDATA[<p>I wanted AI agents I could spin up per-project, each with its own workspace and chat channel, running on infrastructure I control. A proper declarative system where the entire machine — disk layout, services, secrets, agent bindings — lives in version-controlled Nix.</p>

<p>NixClaw is what I ended up building. It’s a dedicated NixOS VM on Proxmox that runs an <a href="https://github.com/openclaw/openclaw">OpenClaw</a> agent gateway connected to my self-hosted Mattermost. Each agent gets a private Gitea repo, a Mattermost channel, and a workspace that auto-syncs every 15 minutes. The whole thing deploys from my MacBook in one command.</p>

<h2 id="the-stack">The Stack</h2>

<p>The pieces:</p>

<ul>
  <li><strong>Proxmox 8.x</strong> — QEMU/KVM hypervisor, already running my homelab</li>
  <li><strong>NixOS 25.11</strong> — the OS, declared in ~300 lines of Nix</li>
  <li><strong>nixos-anywhere + disko</strong> — remote provisioning, declarative disk partitioning</li>
  <li><strong>OpenClaw</strong> — the agent gateway, MIT licensed, Mattermost-native</li>
  <li><strong>Mattermost</strong> — self-hosted chat at <code class="language-plaintext highlighter-rouge">mattermost.fitzsky.com</code></li>
  <li><strong>Gitea</strong> — self-hosted git at <code class="language-plaintext highlighter-rouge">gitea.fitzsky.com</code>, HTTPS auth</li>
  <li><strong>sops-nix + age</strong> — encrypted secrets, one key, one file</li>
  <li><strong>Tailscale</strong> — SSH access, zero open ports</li>
  <li><strong>Podman</strong> — rootless containers for agent tool sandboxing</li>
  <li><strong>Brave Search API</strong> — gives agents web search as a built-in tool</li>
</ul>

<p>Everything is Nix except the mutable agent bindings file (<code class="language-plaintext highlighter-rouge">agents.json5</code>), which the gateway hot-reloads. That’s intentional — I don’t want to <code class="language-plaintext highlighter-rouge">nixos-rebuild</code> every time I create an agent.</p>

<h2 id="architecture">Architecture</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Proxmox VM "nixclaw" (NixOS 25.11, x86_64-linux)
├─ devinbernosky (admin) — SSH over Tailscale
└─ openclaw (service user)
   ├─ openclaw-gateway — HM user service, loopback-only :18789
   ├─ Mattermost bot "Operator" — routes messages to agents
   ├─ Per-project workspaces — git repos on Gitea
   ├─ Shared files — USER.md + TOOLS.md symlinked into all workspaces
   ├─ Podman sandbox — rootless containers for tool execution
   ├─ Brave web search — built-in tool
   └─ git-sync timer — auto-commits every 15min
</code></pre></div></div>

<p>Two users. The admin (<code class="language-plaintext highlighter-rouge">devinbernosky</code>) SSHs in over Tailscale and manages config. The service user (<code class="language-plaintext highlighter-rouge">openclaw</code>) runs the gateway as a Home Manager user service and owns all the workspaces. Clean separation.</p>

<h2 id="prerequisites">Prerequisites</h2>

<p>Before touching Nix, I needed three external services ready.</p>

<p><strong>Mattermost:</strong> Create a bot account (I called mine “Operator”), save the token. Grab your team ID:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-s</span> https://mattermost.fitzsky.com/api/v4/teams <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Authorization: Bearer &lt;bot-token&gt;"</span> | jq <span class="s1">'.[0].id'</span>
</code></pre></div></div>

<p><strong>Gitea:</strong> Create a “NixClaw” organization, a <code class="language-plaintext highlighter-rouge">skills</code> repo for shared agent skills, and an access token. One thing to know: if Gitea runs behind Docker, SSH won’t work because the host’s sshd grabs port 22 first. Everything goes over HTTPS.</p>

<p><strong>Brave Search:</strong> Grab an API key from <a href="https://brave.com/search/api/">brave.com/search/api</a>. That’s it.</p>

<h2 id="the-vm">The VM</h2>

<p>In Proxmox, create a VM with UEFI (OVMF), q35 machine type, VirtIO SCSI, and QEMU agent enabled. I gave mine 256GB disk, 10 CPU threads, and 8GB RAM. That’s probably overkill for what amounts to a gateway process, but I had the headroom.</p>

<p>Boot the NixOS 25.11 minimal ISO. At the boot menu, pick “Linux LTS” — that’s a kernel option in the boot menu, not a separate ISO.</p>

<h2 id="deploying-in-one-shot">Deploying in One Shot</h2>

<p>This is where it gets fun. nixos-anywhere lets you go from a live ISO to a fully configured NixOS install in one command, from your local machine.</p>

<p>On the VM console, set a temp root password and note the IP:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>passwd root
ip addr
</code></pre></div></div>

<p>On your Mac, stage the age key so sops-nix can decrypt secrets on the new system:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> <span class="nt">-p</span> /tmp/nixclaw-extra/root/.config/sops/age
<span class="nb">cp</span> ~/.config/sops/age/keys.txt /tmp/nixclaw-extra/root/.config/sops/age/keys.txt
<span class="nb">chmod </span>600 /tmp/nixclaw-extra/root/.config/sops/age/keys.txt
</code></pre></div></div>

<p>Then deploy:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nix run github:nix-community/nixos-anywhere <span class="nt">--</span> <span class="se">\</span>
  <span class="nt">--flake</span> <span class="s2">"path:</span><span class="nv">$HOME</span><span class="s2">/Github/nix-config#nixclaw"</span> <span class="se">\</span>
  <span class="nt">--extra-files</span> /tmp/nixclaw-extra <span class="se">\</span>
  root@&lt;VM_IP&gt;
</code></pre></div></div>

<p>This partitions the disk (via disko), installs NixOS from the flake, copies the age key into place, and reboots. The whole system — users, services, secrets, agent gateway — materializes from the flake definition.</p>

<p>One subtlety: nixos-anywhere SSHs into the live ISO’s sshd, which allows root login by default. The <code class="language-plaintext highlighter-rouge">PermitRootLogin = "no"</code> in my system config only takes effect after install. No conflict.</p>

<h2 id="post-boot">Post-Boot</h2>

<p>SSH in as the admin user with the default password:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh devinbernosky@&lt;VM_IP&gt;
<span class="c"># password: changeme</span>
</code></pre></div></div>

<p><strong>Tailscale first:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>tailscale up <span class="nt">--ssh</span>
passwd  <span class="c"># change from default immediately</span>
</code></pre></div></div>

<p>After Tailscale is up, <code class="language-plaintext highlighter-rouge">ssh nixclaw</code> works from any device on the tailnet. No passwords, no port forwarding, no DNS records. From here on, everything goes through Tailscale.</p>

<p><strong>Clone the nix-config repo.</strong> On a fresh system, Home Manager hasn’t activated yet, so <code class="language-plaintext highlighter-rouge">gh</code> isn’t on PATH. Bootstrap with <code class="language-plaintext highlighter-rouge">nix run</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>nix run nixpkgs#gh <span class="nt">--</span> auth login
nix run nixpkgs#gh <span class="nt">--</span> repo clone devindudeman/nix-config ~/nix-config
</code></pre></div></div>

<p><strong>Rebuild:</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd</span> ~/nix-config/hosts/nixclaw
just deploy
</code></pre></div></div>

<p>This activates everything — sops secrets get decrypted, the gateway starts, <code class="language-plaintext highlighter-rouge">GH_TOKEN</code> lands in fish shell, SSH sessions start auto-cd’ing to the host config directory. Reconnect and verify:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh nixclaw
just status   <span class="c"># gateway should be active</span>
just logs     <span class="c"># should show "connected as Operator"</span>
</code></pre></div></div>

<p>One more thing — clone the shared skills repo:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>just clone-skills
</code></pre></div></div>

<h2 id="creating-agents">Creating Agents</h2>

<p>This is the daily workflow. One command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>just new-agent &lt;project-name&gt;
</code></pre></div></div>

<p>Behind the scenes, this:</p>

<ol>
  <li>Creates a private Gitea repo in the NixClaw org</li>
  <li>Initializes a workspace from template (real files, not symlinks)</li>
  <li>Symlinks shared <code class="language-plaintext highlighter-rouge">USER.md</code> and <code class="language-plaintext highlighter-rouge">TOOLS.md</code> into the workspace</li>
  <li>Pushes initial commit to Gitea</li>
  <li>Creates a public Mattermost channel (or restores it if soft-deleted)</li>
  <li>Adds the bot and my user as channel members</li>
  <li>Patches <code class="language-plaintext highlighter-rouge">agents.json5</code> — the gateway hot-reloads, no restart needed</li>
</ol>

<p>Send a message in the new channel. The agent responds immediately, no @mention required (<code class="language-plaintext highlighter-rouge">chatmode: "onmessage"</code>).</p>

<p>Tearing one down is just as clean:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>just delete-agent &lt;project-name&gt;
</code></pre></div></div>

<h2 id="day-to-day">Day-to-Day</h2>

<p>SSH sessions land in the host config directory automatically. Everything runs through the justfile:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Gateway</span>
just status     <span class="c"># is it running?</span>
just logs       <span class="c"># tail the log</span>
just restart    <span class="c"># bounce it</span>

<span class="c"># Config updates</span>
just pull       <span class="c"># git pull --rebase</span>
just deploy     <span class="c"># nixos-rebuild switch</span>
just push       <span class="c"># push changes back</span>

<span class="c"># Agents</span>
just new-agent &lt;name&gt;
just delete-agent &lt;name&gt;
just <span class="nb">sync</span>       <span class="c"># trigger manual git sync</span>
</code></pre></div></div>

<p>Config changes go through the normal Nix workflow: edit, rebuild, push. Agent lifecycle is entirely outside Nix — just the justfile and the mutable <code class="language-plaintext highlighter-rouge">agents.json5</code>.</p>

<h2 id="whats-automated-whats-not">What’s Automated, What’s Not</h2>

<p><strong>Automated:</strong></p>
<ul>
  <li>Gateway starts on boot (systemd lingering)</li>
  <li>Mattermost config injected via <code class="language-plaintext highlighter-rouge">ExecStartPre</code></li>
  <li>Secrets decrypted from sops at service start</li>
  <li>Workspaces sync to Gitea every 15 minutes</li>
  <li><code class="language-plaintext highlighter-rouge">GH_TOKEN</code> available in shell from sops</li>
</ul>

<p><strong>Manual:</strong></p>
<ul>
  <li>Initial <code class="language-plaintext highlighter-rouge">gh auth login</code> (chicken-and-egg with <code class="language-plaintext highlighter-rouge">GH_TOKEN</code> on first deploy)</li>
  <li>One-time Tailscale auth</li>
  <li>Agent creation (<code class="language-plaintext highlighter-rouge">just new-agent</code>)</li>
  <li>Updating shared USER.md and TOOLS.md content</li>
</ul>

<p>The line between automated and manual is intentional. Agent creation is a human decision. Everything after that decision is automated.</p>

<h2 id="design-decisions-worth-noting">Design Decisions Worth Noting</h2>

<p><strong>Why not SSH for Gitea?</strong> I run Gitea in Docker, and the host’s sshd intercepts port 22. I could remap ports, but HTTPS with token auth works fine and is one less thing to debug.</p>

<p><strong>Why a mutable agents.json5?</strong> I didn’t want agent creation to require a full Nix rebuild. The gateway watches this file and hot-reloads when it changes. Nix manages the system, the justfile manages agents.</p>

<p><strong>Why public Mattermost channels?</strong> I want to be able to browse agent conversations from any device. The Mattermost instance is self-hosted and private anyway — “public” just means visible within the team.</p>

<p><strong>Why file-based gateway logs?</strong> OpenClaw logs to <code class="language-plaintext highlighter-rouge">/tmp/openclaw/openclaw-gateway.log</code>, not journald. That’s how the upstream packages it. <code class="language-plaintext highlighter-rouge">just logs</code> wraps <code class="language-plaintext highlighter-rouge">tail -f</code> on that path.</p>

<p><strong>Why Podman, not Docker?</strong> Rootless. The <code class="language-plaintext highlighter-rouge">openclaw</code> user runs containers without root privileges. This matters when you’re giving AI agents the ability to execute code.</p>

<h2 id="what-id-do-differently">What I’d Do Differently</h2>

<p>Honestly, not much. The deploy story with nixos-anywhere is excellent — going from bare VM to running agents in one command still feels like magic. If I were starting over, I might explore running the gateway in a container itself for even more isolation, but the current setup with a dedicated service user and rootless Podman for tool execution is clean enough.</p>

<p>The biggest friction point is the initial <code class="language-plaintext highlighter-rouge">gh auth login</code> bootstrap. On a completely fresh system, you need <code class="language-plaintext highlighter-rouge">gh</code> to clone the repo that provides <code class="language-plaintext highlighter-rouge">gh</code>. The <code class="language-plaintext highlighter-rouge">nix run nixpkgs#gh</code> workaround handles it, but it’s one of those things that makes you appreciate the chicken-and-egg problems in declarative systems.</p>

<p>If you’re running OpenClaw or thinking about self-hosted AI agents, the NixOS approach is worth the investment. Declarative config means I can blow away the VM and rebuild it from scratch in minutes. Every decision is documented in code. And when something breaks, <code class="language-plaintext highlighter-rouge">just logs</code> is one command away from the answer.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[How I built a single-purpose NixOS VM that runs AI agents with their own git-backed workspaces, deployed in one command from a MacBook to Proxmox.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Setting Up This Blog</title><link href="https://devin.fitzsky.com/setting-up-this-blog/" rel="alternate" type="text/html" title="Setting Up This Blog" /><published>2026-03-05T00:00:00-08:00</published><updated>2026-03-05T00:00:00-08:00</updated><id>https://devin.fitzsky.com/setting-up-this-blog</id><content type="html" xml:base="https://devin.fitzsky.com/setting-up-this-blog/"><![CDATA[<p>Been meaning to set one of these up for a while. I’ve always liked GitHub Pages, so I went with that. I’d never used Jekyll before, but it felt like the right path for a personal site where the main job is writing and publishing cleanly.</p>

<h2 id="the-structure">The Structure</h2>

<p>I started from an empty repo and scaffolded a basic Jekyll structure — layouts, includes, posts, a few pages. The first design pass was a little too playful, so I tightened it into something minimal. Sharper typography, cleaner spacing, less decoration. The style should stay out of the way of the writing.</p>

<p>The config is intentionally small:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">title</span><span class="pi">:</span> <span class="s">Devin Bernosky</span>
<span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://devin.fitzsky.com"</span>
<span class="na">permalink</span><span class="pi">:</span> <span class="s">/:title/</span>
<span class="na">markdown</span><span class="pi">:</span> <span class="s">kramdown</span>
<span class="na">plugins</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">jekyll-feed</span>
  <span class="pi">-</span> <span class="s">jekyll-seo-tag</span>
<span class="na">defaults</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">scope</span><span class="pi">:</span>
      <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
      <span class="na">type</span><span class="pi">:</span> <span class="s2">"</span><span class="s">posts"</span>
    <span class="na">values</span><span class="pi">:</span>
      <span class="na">layout</span><span class="pi">:</span> <span class="s">post</span>
      <span class="na">author</span><span class="pi">:</span> <span class="s">Devin</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">permalink: /:title/</code> is key — URLs are just the slug with no date prefix. Posts live in <code class="language-plaintext highlighter-rouge">_posts/</code> as <code class="language-plaintext highlighter-rouge">YYYY-MM-DD-title.md</code>, but the URL comes out clean: <code class="language-plaintext highlighter-rouge">devin.fitzsky.com/setting-up-this-blog/</code>.</p>

<h2 id="deploy">Deploy</h2>

<p>Deploys run through GitHub Actions on pushes to <code class="language-plaintext highlighter-rouge">main</code>. The workflow builds the Jekyll site and ships it to Pages automatically.</p>

<p>I hit one gotcha early: <code class="language-plaintext highlighter-rouge">configure-pages</code> returned a <code class="language-plaintext highlighter-rouge">Not Found</code> response on the first run. Turns out GitHub Pages isn’t enabled on the repo until you either flip it on manually in Settings, or — what I did — set <code class="language-plaintext highlighter-rouge">enablement: true</code> in the action:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Pages</span>
  <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/configure-pages@v5.0.0</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">enablement</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>That bootstraps Pages on the first deploy. After that it’s invisible.</p>

<h2 id="nix-dev-environment">Nix Dev Environment</h2>

<p>I use Nix and devenv everywhere, so this was an easy choice. The repo has a flake with a devenv shell that pins Ruby, Bundler, and the system dependencies:</p>

<div class="language-nix highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span> <span class="nv">pkgs</span><span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="p">{</span>
  <span class="nv">packages</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nv">pkgs</span><span class="o">.</span><span class="nv">ruby_3_4</span>
    <span class="nv">pkgs</span><span class="o">.</span><span class="nv">bundler</span>
    <span class="nv">pkgs</span><span class="o">.</span><span class="nv">libyaml</span>
    <span class="nv">pkgs</span><span class="o">.</span><span class="nv">gnumake</span>
  <span class="p">];</span>

  <span class="nv">scripts</span><span class="o">.</span><span class="nv">setup</span><span class="o">.</span><span class="nv">exec</span> <span class="o">=</span> <span class="s2">"bundle install"</span><span class="p">;</span>
  <span class="nv">scripts</span><span class="o">.</span><span class="nv">serve</span><span class="o">.</span><span class="nv">exec</span> <span class="o">=</span> <span class="s2">"bundle exec jekyll serve --livereload --host 0.0.0.0 --port 4000"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Enter the shell, run <code class="language-plaintext highlighter-rouge">devenv run setup</code> once, then <code class="language-plaintext highlighter-rouge">devenv run serve</code>. Same result on every machine.</p>

<p>This mattered fast. GitHub Pages gems currently require <code class="language-plaintext highlighter-rouge">commonmarker</code>, which caps at Ruby &lt; 4.0. I pinned Ruby 3.4.8 and locked the <code class="language-plaintext highlighter-rouge">github-pages</code> gem at version 232. Without the pin, you’ll hit dependency resolution failures the moment Ruby 4.x shows up on your system.</p>

<p>The Gemfile is two lines:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">source</span> <span class="s2">"https://rubygems.org"</span>
<span class="n">gem</span> <span class="s2">"github-pages"</span><span class="p">,</span> <span class="s2">"= 232"</span><span class="p">,</span> <span class="ss">group: :jekyll_plugins</span>
<span class="n">gem</span> <span class="s2">"webrick"</span><span class="p">,</span> <span class="s2">"= 1.9.2"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">webrick</code> is there because Ruby 3.x dropped it from stdlib and Jekyll’s local server needs it.</p>

<h2 id="dns-and-domain">DNS and Domain</h2>

<p>DNS lives in Cloudflare for all things Fitzsky. Most of it routes through Cloudflare tunnels to self-hosted services — Mattermost, Gitea, that kind of thing. The blog is the exception: it’s just a CNAME pointing at GitHub Pages.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>devin  CNAME  devindudeman.github.io
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">CNAME</code> file in the repo root tells GitHub Pages to serve the custom domain, and HTTPS enforcement is on in the repo settings.</p>

<h2 id="writing-flow">Writing Flow</h2>

<p>This is the best part. Open a file, write Markdown, push. That’s it.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># new post</span>
hx _posts/2026-03-11-whatever-im-writing-about.md

<span class="c"># front matter</span>
<span class="nt">---</span>
title: Whatever I<span class="s1">'m Writing About
description: One-line summary.
---

# write, commit, push
git add . &amp;&amp; git commit -m "New post" &amp;&amp; git push
</span></code></pre></div></div>

<p>Live in about 90 seconds. No build step to think about, no deploy to trigger. Push to <code class="language-plaintext highlighter-rouge">main</code> and it’s on the internet.</p>

<p>Let’s see how well I can keep this updated.</p>]]></content><author><name>Devin</name></author><summary type="html"><![CDATA[Jekyll, GitHub Pages, Nix, and Cloudflare — how devin.fitzsky.com came together.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://devin.fitzsky.com/assets/images/og-default.png" /><media:content medium="image" url="https://devin.fitzsky.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>