<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
      <title>Jake Goldsborough</title>
      <link>https://jakegoldsborough.com</link>
      <description></description>
      <generator>Zola</generator>
      <language>en</language>
      <atom:link href="https://jakegoldsborough.com/rss.xml" rel="self" type="application/rss+xml"/>
      <lastBuildDate>Tue, 30 Jun 2026 00:00:00 +0000</lastBuildDate>
      <item>
          <title>replaybook: Incident Replay Trainer for Infrastructure</title>
          <pubDate>Tue, 30 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/replaybook-incident-replay-trainer/</link>
          <guid>https://jakegoldsborough.com/blog/2026/replaybook-incident-replay-trainer/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/replaybook-incident-replay-trainer/">&lt;p&gt;Post-mortems are the most underused artifact in software engineering.&lt;&#x2F;p&gt;
&lt;p&gt;You spend hours in the incident. You write up the timeline, the root cause, the fix, the follow-ups. You share it with the team. Six months later a new engineer joins, hits the same class of problem, and has no muscle memory for it at all. The post-mortem is in Notion somewhere.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the problem &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;replaybook&quot;&gt;replaybook&lt;&#x2F;a&gt; solves.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-idea&quot;&gt;The Idea&lt;&#x2F;h2&gt;
&lt;p&gt;replaybook is a terminal trainer where you fix broken infrastructure. Not simulated broken infrastructure - actual broken Docker containers running real services. Nginx with a misconfigured upstream. Postgres with the wrong permissions on its data directory. Sidekiq pointed at a Redis that requires a password it doesn&#x27;t have.&lt;&#x2F;p&gt;
&lt;p&gt;You&#x27;re dropped into a shell inside the broken environment and have to figure out what&#x27;s wrong and fix it before the SLA timer runs out.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;replaybook add ducks&amp;#x2F;on-call-scenarios
replaybook list
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;ID                             DIFF  TITLE
────────────────────────────────────────────────────────────
001-nginx-502                  1     502 Bad Gateway
002-postgres-wont-start        1     Postgres Won&amp;#x27;t Start
003-missing-env-var            2     App Crashing on Boot
004-disk-full                  2     Health Checks Failing
005-oom-kill                   2     Container Keeps Restarting
006-sidekiq-cant-connect       2     Jobs Not Processing
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;replaybook run 001-nginx-502
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The terminal splits. Left pane is your shell inside the container. Right pane is the HUD.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;16882806a585:&amp;#x2F;# █                   │ == replaybook ==
                                    │
                                    │ INCIDENT:
                                    │ URGENT: users getting 502s
                                    │ on checkout. conversion is
                                    │ tanking. started about 5
                                    │ mins ago. no deploys today
                                    │ that we know of. fix asap
                                    │
                                    │ STATUS: ACTIVE
                                    │ SLA:    14:45 remaining
                                    │ HINTS:  0 &amp;#x2F; 2 used
                                    │
                                    │ run get-hint for a hint
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You&#x27;re inside nginx. The real nginx. Look at the config, check the logs, fix the upstream, reload the service. When the health check passes, you win.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-docker&quot;&gt;Why Docker&lt;&#x2F;h2&gt;
&lt;p&gt;The trainer isn&#x27;t about Docker. Docker is just how you spin up a real broken server and throw someone into it.&lt;&#x2F;p&gt;
&lt;p&gt;The alternative is a simulated environment - fake logs, scripted responses, prerecorded output. That teaches you how a simulation works. This teaches you how nginx works.&lt;&#x2F;p&gt;
&lt;p&gt;When you&#x27;re in the container, &lt;code&gt;nginx -t&lt;&#x2F;code&gt; tells you if the config is valid. &lt;code&gt;curl localhost&lt;&#x2F;code&gt; actually hits the server. &lt;code&gt;ps aux&lt;&#x2F;code&gt; shows real processes. Nothing is stubbed.&lt;&#x2F;p&gt;
&lt;p&gt;The fault in scenario 001 is a one-character config change: the upstream port is 3001 instead of 3000. There&#x27;s no quiz asking you to identify the right port. You have to find it the same way you&#x27;d find it in production.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-incident-replay-angle&quot;&gt;The Incident Replay Angle&lt;&#x2F;h2&gt;
&lt;p&gt;The scenarios that ship with the official pack are generic. But the format is a JSON file plus a Docker Compose setup:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;json&quot; class=&quot;language-json &quot;&gt;&lt;code class=&quot;language-json&quot; data-lang=&quot;json&quot;&gt;{
  &amp;quot;id&amp;quot;: &amp;quot;redis-oom-eviction&amp;quot;,
  &amp;quot;title&amp;quot;: &amp;quot;Cache Eviction Killing Sessions&amp;quot;,
  &amp;quot;page&amp;quot;: &amp;quot;users are getting logged out randomly. started after the traffic spike yesterday. no deploys, no config changes.&amp;quot;,
  &amp;quot;difficulty&amp;quot;: 3,
  &amp;quot;hints&amp;quot;: [
    &amp;quot;Check Redis memory usage and eviction policy&amp;quot;,
    &amp;quot;What happens to session keys when Redis runs out of memory?&amp;quot;
  ],
  &amp;quot;success_condition&amp;quot;: &amp;quot;http_200&amp;quot;,
  &amp;quot;success_target&amp;quot;: &amp;quot;http:&amp;#x2F;&amp;#x2F;localhost:3000&amp;#x2F;health&amp;quot;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Your &lt;code&gt;break.sh&lt;&#x2F;code&gt; injects the fault. Your &lt;code&gt;check.sh&lt;&#x2F;code&gt; defines what &quot;fixed&quot; looks like.&lt;&#x2F;p&gt;
&lt;p&gt;That means every incident your team has ever had can become a scenario. The Redis maxmemory eviction that took down sessions last quarter. The postgres autovacuum that blocked table writes during a migration. The nginx upstream that pointed at the wrong port after a deploy.&lt;&#x2F;p&gt;
&lt;p&gt;Write up what broke. Write &lt;code&gt;break.sh&lt;&#x2F;code&gt; to reproduce it. New engineers run it, fix it with real tools, build the muscle memory. The post-mortem becomes a runnable training scenario.&lt;&#x2F;p&gt;
&lt;p&gt;Scenario packs are just Git repos. Your team&#x27;s private incidents stay private:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;replaybook add mycompany&amp;#x2F;incidents
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;the-hud&quot;&gt;The HUD&lt;&#x2F;h2&gt;
&lt;p&gt;The split-pane HUD runs inside the container via tmux. No dependency on the host. The trainer installs tmux at startup via &lt;code&gt;apk&lt;&#x2F;code&gt; (all scenarios use alpine-based images), copies in the HUD script, splits the pane.&lt;&#x2F;p&gt;
&lt;p&gt;The state file lives on the host and is bind-mounted into the container. The poller writes to it directly - no shell escaping, no &lt;code&gt;docker exec&lt;&#x2F;code&gt; state writes. The HUD script reads it every 2 seconds.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Inside the container, run anytime:
get-hint
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Hints are revealed sequentially. The HUD shows which hints you&#x27;ve used. Hints used is recorded with the session outcome.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;install&quot;&gt;Install&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cargo install replaybook
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Or grab a prebuilt binary for linux-x86_64, linux-arm64, macos-x86_64, or macos-arm64 from the &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;replaybook&#x2F;releases&quot;&gt;releases page&lt;&#x2F;a&gt;. You need Docker.&lt;&#x2F;p&gt;
&lt;p&gt;Both &lt;code&gt;replaybook&lt;&#x2F;code&gt; and &lt;code&gt;replay&lt;&#x2F;code&gt; are installed - use whichever you prefer.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Scoring based on time and hints used. More scenarios - planning a Discourse track covering the failures that come up most in self-hosted deployments.&lt;&#x2F;p&gt;
&lt;p&gt;The scenario format is the interesting part. If you&#x27;ve run an incident and written a post-mortem, the hardest work is already done.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Source: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;replaybook&quot;&gt;github.com&#x2F;ducks&#x2F;replaybook&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Software Doesn&#x27;t Have to Justify Itself, or the Joy of Useless Programming</title>
          <pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/software-doesnt-have-to-justify-itself/</link>
          <guid>https://jakegoldsborough.com/blog/2026/software-doesnt-have-to-justify-itself/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/software-doesnt-have-to-justify-itself/">&lt;p&gt;Last week I built a community management sim that runs inside Discourse. You
play as a moderator. Fake users post fake drama, fake flags pile up in the real
flag queue, and a fake outage tanks your response time while fake users complain
about it on a fake Twitter. The whole thing is rendered with real Discourse
components, so it reads as plausible at a glance. I called it
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-manager&quot;&gt;discourse-manager&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Nobody asked for this. There is no roadmap. There is no addressable market for
&quot;forum jobs as a roguelike, but it&#x27;s a plugin for the forum software itself.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;I built it because the idea made me laugh, and then I wanted to see if it would
work.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-justification-reflex&quot;&gt;The justification reflex&lt;&#x2F;h2&gt;
&lt;p&gt;There is a reflex a lot of programmers have, myself included, where every
project has to come with a defense.&lt;&#x2F;p&gt;
&lt;p&gt;Who is it for. How does it scale. What is the monetization path. Is this a
portfolio piece or a side business. Could it be a startup. Have you considered
the TAM.&lt;&#x2F;p&gt;
&lt;p&gt;I think we picked this up honestly. Most of us learned to program in a context
where the output was supposed to be useful to someone other than us. A ticket,
a feature, a deliverable. Code is expensive to write, so we convince ourselves
every line has to pay rent.&lt;&#x2F;p&gt;
&lt;p&gt;But that framing quietly smuggles in an assumption: that the only good reason to
make software is that someone else needs it.&lt;&#x2F;p&gt;
&lt;p&gt;I don&#x27;t think that holds up.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;nobody-asks-the-potter&quot;&gt;Nobody asks the potter&lt;&#x2F;h2&gt;
&lt;p&gt;Nobody asks a musician why the world needs another song. There are tens of
millions of songs. The marginal song is not solving a shortage. A person writes
one anyway, because the writing is the point.&lt;&#x2F;p&gt;
&lt;p&gt;Nobody asks a potter why the world needs another mug. Your cabinet already has
mugs. The potter makes another one because their hands wanted to make it, and
because this one will be a little different from the last.&lt;&#x2F;p&gt;
&lt;p&gt;I have a few creative outlets like that. I make things out of clay and fiber and
wood under a name I keep separate from my engineering work. Nobody evaluates a
hand-built object by its growth potential. It is allowed to just exist because
someone enjoyed making it.&lt;&#x2F;p&gt;
&lt;p&gt;Software almost never gets that grace. It is held to a standard we would find
absurd in any other medium. We expect a weekend project to behave like a
business plan.&lt;&#x2F;p&gt;
&lt;p&gt;But software is also a medium. It is one of the most expressive ones we have. It
moves, it reacts, it can be funny, it can surprise you. A program can have a
voice. discourse-manager has a voice, and that voice is &quot;this is a little too
real.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-what-if-gets-you&quot;&gt;What &quot;what if&quot; gets you&lt;&#x2F;h2&gt;
&lt;p&gt;My favorite projects almost never start with a problem statement. They start
with &quot;what if.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;What if my audiobook player followed the same keybinds as my music player. That
turned into &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shellbooks&quot;&gt;shellbooks&lt;&#x2F;a&gt;, which descends
directly from &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;shelltrax&lt;&#x2F;a&gt;, my terminal music
player. The &quot;what if&quot; was small and a little indulgent. The result is something
I use almost every day.&lt;&#x2F;p&gt;
&lt;p&gt;What if I turned forum software into a game engine. That one became
discourse-manager. It started as a single dumb image in my head, a flag queue
that fights back, and I wanted to see it move badly enough to build the rest
around it.&lt;&#x2F;p&gt;
&lt;p&gt;What if I just automated the annoying thing instead of doing it by hand for the
hundredth time. Half my tiny scripts exist because of that sentence. None of
them have users. All of them earn their keep.&lt;&#x2F;p&gt;
&lt;p&gt;&quot;What if&quot; is not a worse starting point than &quot;the market needs.&quot; It is often a
better one, because it comes from curiosity instead of obligation, and curiosity
tends to pull you somewhere you would not have planned to go.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-silly-project-teaches-the-real-thing&quot;&gt;The silly project teaches the real thing&lt;&#x2F;h2&gt;
&lt;p&gt;Here is the part that surprises people who think play and usefulness are
opposites.&lt;&#x2F;p&gt;
&lt;p&gt;discourse-manager looks like a joke, and it is, but building it made me learn
parts of Discourse I had managed to avoid for years. It runs real-time state
updates over MessageBus, the same pub&#x2F;sub system Discourse uses for live
notifications. I had never wired that up from scratch. I had to, because a game
where the flags don&#x27;t disappear as you clear them is not a game.&lt;&#x2F;p&gt;
&lt;p&gt;A joke project is still architecture. The fake forum needed generated users with
distinct behavioral profiles, a tick loop, an event system with weighted
outcomes, persistent game state, and a HUD that updates without a page reload.
None of that cares that the premise is silly. The wiring is exactly as real as
it would be in something I was paid to ship.&lt;&#x2F;p&gt;
&lt;p&gt;This keeps happening. The audiobook player taught me how &lt;code&gt;.m4b&lt;&#x2F;code&gt; chapter atoms
are stored. A throwaway script I wrote to
&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;auditing-a-squarespace-site-before-migrating&#x2F;&quot;&gt;audit a site before migrating it&lt;&#x2F;a&gt;
taught me more about its actual structure than its documentation ever would.
&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;patchelf-broke-claude-code&#x2F;&quot;&gt;Debugging why Claude Code wouldn&#x27;t run on NixOS&lt;&#x2F;a&gt;
taught me things about dynamic linking I will never un-learn. Each one was a
means to some small end, and the end was never the point.&lt;&#x2F;p&gt;
&lt;p&gt;Play is just how I read a system closely. You poke at a thing long enough to
make it do something stupid, and somewhere in there you understand it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-line-is-thinner-than-people-think&quot;&gt;The line is thinner than people think&lt;&#x2F;h2&gt;
&lt;p&gt;The thing I keep coming back to is that the line between silly and useful is
much thinner than the justification reflex wants you to believe.&lt;&#x2F;p&gt;
&lt;p&gt;discourse-manager is a bit. It is also, almost by accident, a labeled dataset
generator: every flag has a profile type and a human moderation decision
attached, so a few sessions produce real training data for spam detection. I did
not build it for that. It just fell out, the way useful things often fall out of
unserious ones.&lt;&#x2F;p&gt;
&lt;p&gt;I am not saying every project is secretly valuable, or that you should retrofit a
business case onto your toys to make them respectable. That is the same reflex
wearing a different coat. The toys do not need a business case. That is the whole
point.&lt;&#x2F;p&gt;
&lt;p&gt;I am saying the pressure to justify everything up front mostly just stops you
from starting. And the projects that start with &quot;what if&quot; are, in my experience,
the ones that teach me the most and the ones I am proudest of later.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;programming-is-a-creative-outlet&quot;&gt;Programming is a creative outlet&lt;&#x2F;h2&gt;
&lt;p&gt;For me, programming is not only the job. It is one of the ways I make things,
sitting on the same shelf as the clay and the fiber and the wood.&lt;&#x2F;p&gt;
&lt;p&gt;Most of what I make there is small. Some of it is silly. A fair amount of it
nobody will ever run but me. I am completely fine with that.&lt;&#x2F;p&gt;
&lt;p&gt;The next time I catch myself drafting a defense for a project before I have even
opened the editor, I want to remember the fake forum that runs inside the real
one, and the moment I decided the better question was not &quot;is this worth it&quot; but
just &quot;what if.&quot;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>discourse-manager: A Community Management Sim Inside Discourse</title>
          <pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/discourse-manager-community-sim/</link>
          <guid>https://jakegoldsborough.com/blog/2026/discourse-manager-community-sim/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/discourse-manager-community-sim/">&lt;p&gt;There&#x27;s a browser game called &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;plbrault&#x2F;youre-the-os&quot;&gt;You are the
OS&lt;&#x2F;a&gt; where you play as a computer&#x27;s
operating system. Processes request memory, users get impatient, the machine
dies if you can&#x27;t keep up. It&#x27;s a sim about a thing most people interact with
every day but never think about.&lt;&#x2F;p&gt;
&lt;p&gt;I wanted to make that, but for community moderation.&lt;&#x2F;p&gt;
&lt;p&gt;The result is &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-manager&quot;&gt;discourse-manager&lt;&#x2F;a&gt;,
a Discourse plugin that turns your forum into the game. You play as the
moderator. Fake users, fake posts, and fake drama are generated inside your
actual Discourse instance and rendered with real Discourse components. The flag
queue looks like the real flag queue. The user cards look like real user cards.
The avatars, the trust level badges, the category labels - all real.&lt;&#x2F;p&gt;
&lt;p&gt;The whole joke is that it runs inside Discourse itself.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;ducks&#x2F;discourse-manager&#x2F;main&#x2F;docs&#x2F;screenshot.png&quot; alt=&quot;discourse-manager screenshot&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-game&quot;&gt;The game&lt;&#x2F;h2&gt;
&lt;p&gt;You manage four meters: community health, mod response time, spam rate, and
user retention. Let any of them hit zero and the community collapses.&lt;&#x2F;p&gt;
&lt;p&gt;Flags come in continuously. Each one is a decision:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Approve&lt;&#x2F;strong&gt; - the post stands, move on&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Remove&lt;&#x2F;strong&gt; - gone&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Warn&lt;&#x2F;strong&gt; - the user gets a PM, post stays&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Suspend&lt;&#x2F;strong&gt; - off the forum for a while&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Ban&lt;&#x2F;strong&gt; - gone for good, but take retention damage if you&#x27;re wrong&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Speed matters. A flag that sits unresolved too long costs health. Clearing them
fast restores response time. The tension is between acting fast and acting
correctly.&lt;&#x2F;p&gt;
&lt;p&gt;On top of the queue, random events fire. Each has two or three resolution
options with different tradeoffs across the meters.&lt;&#x2F;p&gt;
&lt;p&gt;Community events: a spicy topic goes viral and generates 30 flags in 60
seconds, a wave of sockpuppets registers, two trusted users start fighting in a
thread, a great newcomer shows up worth recognizing.&lt;&#x2F;p&gt;
&lt;p&gt;Technical incidents too: a plugin update ships and breaks something, the server
goes down and users are posting about it on Twitter, a database migration fails
in production, a CDN failure makes images disappear sitewide, a cleanup script
runs with the wrong scope and takes out several categories. Each incident hits
different meters - an outage tanks response time and health, a CDN failure
hammers retention. The resolutions have real tradeoffs: push a hotfix live and
risk making it worse, or roll back and eat the downtime.&lt;&#x2F;p&gt;
&lt;p&gt;Each day has a timer. When it ends you get a summary - flags resolved, bans
issued, incidents handled - before starting the next one. Survive 30 days to
win.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fake-community&quot;&gt;The fake community&lt;&#x2F;h2&gt;
&lt;p&gt;The forum is populated at game start with 40 generated users across five
behavioral profiles: lurkers, contributors, trolls, spammers, and newbies. Each
profile generates realistic-looking posts and a realistic probability of
getting flagged.&lt;&#x2F;p&gt;
&lt;p&gt;The posts are the best part. A troll posts &quot;anyone who disagrees with me is
just wrong, full stop.&quot; A spammer posts &quot;I make $4000&#x2F;week working from home.
DM me for details.&quot; A newbie asks &quot;HOW DO I CHANGE MY USERNAME?? I&#x27;ve been
trying for 20 minutes!!&quot; A contributor posts something thoughtful about why the
community has been so toxic lately.&lt;&#x2F;p&gt;
&lt;p&gt;None of it is real. All of it feels real. That&#x27;s the thing Discourse&#x27;s design
language does so well - the UI is so recognizable that fake content in it reads
as plausible immediately.&lt;&#x2F;p&gt;
&lt;p&gt;Occasionally a legitimate post gets flagged incorrectly. You have to read the
content and make a judgment call, not just pattern-match on the flag type.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-inside-discourse&quot;&gt;Why inside Discourse&lt;&#x2F;h2&gt;
&lt;p&gt;I could have built this as a standalone web app. It would have been faster and
simpler.&lt;&#x2F;p&gt;
&lt;p&gt;But the whole premise of the game is that you&#x27;re moderating a forum that looks
real. If I build a custom UI, it looks like a game. If I build it as a
Discourse plugin, it looks like Discourse. The cognitive effect only works when
you can&#x27;t tell at a glance whether you&#x27;re looking at a real post or a fake one.&lt;&#x2F;p&gt;
&lt;p&gt;The plugin renders with actual Discourse components: the same avatars, the same
button styles, the same layout. The HUD - four meters with green&#x2F;yellow&#x2F;red
color coding - sits above the review queue the way a real dashboard might.
Events pop in as styled cards. It all fits the host environment.&lt;&#x2F;p&gt;
&lt;p&gt;Building inside Discourse also gave me MessageBus for free. Real-time state
updates from the server to the client - flags disappearing as you clear them,
meters ticking down - all through the same pub&#x2F;sub system Discourse uses for
live notifications. No WebSocket setup. No extra infrastructure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s next&lt;&#x2F;h2&gt;
&lt;p&gt;The one thing I still want to add: LLM-generated content. Right now the post
pool is handwritten strings - good enough to feel real, but every playthrough
is the same drama. Running an LLM at game init to generate a full set of forum
posts tuned to the behavioral profiles would make each run different. The
structure is already there; it just needs something to fill it with varied text.&lt;&#x2F;p&gt;
&lt;p&gt;The most interesting idea that came up while building it: this could double as
a spam detection training tool. Every flagged post has a profile type and a
human moderation decision attached. Play enough sessions and you have a labeled
dataset of spam, trolling, and legitimate posts. The game generates it as a
side effect.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-code&quot;&gt;The code&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s a Discourse plugin, so the setup is what you&#x27;d expect:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cd &amp;#x2F;var&amp;#x2F;www&amp;#x2F;discourse&amp;#x2F;plugins
git clone https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;discourse-manager
bundle exec rake db:migrate
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then go to &lt;code&gt;&#x2F;play&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Source at
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-manager&quot;&gt;github.com&#x2F;ducks&#x2F;discourse-manager&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>jolteon: An LLM Routing Proxy for Discourse</title>
          <pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/jolteon-llm-routing-proxy/</link>
          <guid>https://jakegoldsborough.com/blog/2026/jolteon-llm-routing-proxy/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/jolteon-llm-routing-proxy/">&lt;p&gt;Two weeks ago, someone posted an internal proposal:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;My dream for the future is a good proxy that automatically sends requests to
the most appropriate model, allowing us to run a heterogeneous AI inference,
and have it routed to our customers automatically.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The concrete version of the problem: we&#x27;d deployed DeepSeek 4 Flash to a server
in one region. It&#x27;s good for long-form generation - periodic reports, data
explorer summaries, dashboard highlights. But it&#x27;s a single instance and has no
vision support. Meanwhile Qwen 3 handles title suggestions and translation just
fine on smaller, faster hardware. The gap between &quot;we have these models&quot; and &quot;the
right request reaches the right model&quot; was just config - but config scattered
across individual Discourse AI setups with no central health checking or fallback
logic.&lt;&#x2F;p&gt;
&lt;p&gt;The proposal asked for a centralized smart router that could:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;receive all LLM inference requests from hosted customers&lt;&#x2F;li&gt;
&lt;li&gt;centralize health checking across all deployments&lt;&#x2F;li&gt;
&lt;li&gt;route each request to the most appropriate model using context from Discourse -
feature name, hosting tier, daily AI credit usage&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;jolteon is that proxy. It shipped yesterday. Here&#x27;s how it works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;built-with-fable&quot;&gt;Built with Fable&lt;&#x2F;h2&gt;
&lt;p&gt;The proposal landed two weeks ago. What made it possible to ship this fast was
Fable - an experimental Claude model variant Anthropic was running for a period.
I used it to write the entire proxy in a few hours.&lt;&#x2F;p&gt;
&lt;p&gt;Fable was faster than Opus and reasoned differently - more willing to work through
intermediate steps explicitly. For a project like this, where you&#x27;re sketching out
proxy architecture, async Rust, streaming SSE semantics, and per-pool config
schemas all at once, that mattered. It could hold the whole design in mind and
produce working code rather than plausible-looking scaffolding.&lt;&#x2F;p&gt;
&lt;p&gt;The reasoning adapter work especially - where jolteon rewrites OpenAI-style
&lt;code&gt;reasoning_effort&lt;&#x2F;code&gt; fields into vLLM-specific knobs before forwarding to Qwen3 and
DeepSeek backends - involved a lot of &quot;here is the vLLM template behavior, here is
what the client sends, figure out the right rewrite&quot; prompts. Fable worked through
the token budget math and template key conflicts step by step.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s no longer available. But two weeks from proposal to production is the result.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-routing-problem&quot;&gt;The routing problem&lt;&#x2F;h2&gt;
&lt;p&gt;For jolteon to route intelligently, Discourse needed to tell it something about
each request. We added a set of headers that discourse-ai attaches when sending
inference requests - feature name, hosting tier, whether the site is on a trial,
quota usage, and whether the payload contains images:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;X-Discourse-AI-Feature: ai_bot
X-Discourse-AI-Tier: standard
X-Discourse-AI-Trial: 1
X-Discourse-AI-Quota-Used: 87
X-Discourse-AI-Vision: 1
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The routing logic combines these to land on a quality class: &lt;code&gt;fast&lt;&#x2F;code&gt;, &lt;code&gt;balanced&lt;&#x2F;code&gt;,
or &lt;code&gt;frontier&lt;&#x2F;code&gt;. Then it picks a backend pool from a preference ladder for that
class.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;classes:
  fast:
    ladder:
      - { qwen-small: 70, deepseek-flash: 30 }
  balanced:
    ladder:
      - { deepseek-flash: 80, qwen-large: 20 }
      - { qwen-small: 100 }          # fallback
  frontier:
    ladder:
      - { qwen-large: 100 }
      - { deepseek-flash: 100 }      # fallback
      - { qwen-small: 100 }          # fallback
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Some features get a base class in config. &lt;code&gt;ai_bot&lt;&#x2F;code&gt; is frontier. &lt;code&gt;translate&lt;&#x2F;code&gt; is
fast with a hard cap so it can&#x27;t escalate even if tier would normally push it
higher. Paid tiers get a +1 modifier. Trials get -1. Over 80% quota gets -1.
Over 150% gets -2. The modifiers stack, the result gets clamped, the cap gets
applied.&lt;&#x2F;p&gt;
&lt;p&gt;Within a pool, backends are selected least-connections. Vision requests drop
non-vision pools from the entire ladder. If a backend fails before the first
response byte, jolteon retries the next backend, then the next ladder step.
Once streaming starts, retries aren&#x27;t possible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-rust&quot;&gt;Why Rust&lt;&#x2F;h2&gt;
&lt;p&gt;I wanted a single stateless binary with no runtime dependencies. Rust handles the
async proxying well - SSE streams run up to 300 seconds and the proxy can&#x27;t touch
a single byte of the response body. The request body needs to be buffered (so
failed attempts can be replayed), but the response must stream through unchanged.&lt;&#x2F;p&gt;
&lt;p&gt;Rust&#x27;s type system was also useful for the routing logic. The feature-to-class
mapping, the modifier math, the ladder walk - all of it is a pure function over
the policy and a snapshot of backend state. It&#x27;s straightforward to test.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;reasoning-parameter-adaptation&quot;&gt;Reasoning parameter adaptation&lt;&#x2F;h2&gt;
&lt;p&gt;This is the weirdest part of jolteon and the part that took the most iteration.&lt;&#x2F;p&gt;
&lt;p&gt;Discourse is configured as an OpenAI Chat Completions client. It sends
&lt;code&gt;reasoning_effort: &quot;medium&quot;&lt;&#x2F;code&gt; when the user has reasoning enabled. But vLLM
doesn&#x27;t implement that field the same way across models. Qwen3 needs
&lt;code&gt;thinking_token_budget&lt;&#x2F;code&gt; and &lt;code&gt;chat_template_kwargs.enable_thinking&lt;&#x2F;code&gt;. DeepSeek V4
needs &lt;code&gt;chat_template_kwargs.thinking&lt;&#x2F;code&gt; and its own &lt;code&gt;reasoning_effort&lt;&#x2F;code&gt; inside that
object.&lt;&#x2F;p&gt;
&lt;p&gt;jolteon handles this with per-pool reasoning adapters:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;pools:
  qwen-small:
    reasoning:
      adapter: qwen3
      forward_reasoning_effort: true
      effort_budgets:
        low: 512
        medium: 2048
        high: 8192
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The adapter fires per attempt, not once per request. If a request fails over from
qwen-large to deepseek-flash, jolteon rewrites the body again using the DeepSeek
adapter before the second attempt. The client sent one request with
&lt;code&gt;reasoning_effort: medium&lt;&#x2F;code&gt;. Each backend got the right vLLM fields for its
template.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;hot-reload&quot;&gt;Hot reload&lt;&#x2F;h2&gt;
&lt;p&gt;The policy is a YAML file. Reload it without dropping connections:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;kill -HUP $(pidof jolteon)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Backend health and in-flight counts survive the reload. The only thing that
requires a restart is changing the listen address. In practice, tuning weights or
adding a backend is a YAML edit, a commit, and a SIGHUP. No downtime.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;--check&lt;&#x2F;code&gt; flag validates the policy without starting the proxy:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;jolteon --check routing-policy.yml
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Unknown fields are rejected, so misspelled keys fail fast rather than silently
doing nothing. That check is wired into our deploy pipeline.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-looks-like-in-production&quot;&gt;What it looks like in production&lt;&#x2F;h2&gt;
&lt;p&gt;Every request produces one structured JSON log line:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;json&quot; class=&quot;language-json &quot;&gt;&lt;code class=&quot;language-json&quot; data-lang=&quot;json&quot;&gt;{
  &amp;quot;site&amp;quot;: &amp;quot;example.com&amp;quot;,
  &amp;quot;feature&amp;quot;: &amp;quot;ai_bot&amp;quot;,
  &amp;quot;tier&amp;quot;: &amp;quot;standard&amp;quot;,
  &amp;quot;trial&amp;quot;: false,
  &amp;quot;quota_used&amp;quot;: 0,
  &amp;quot;vision&amp;quot;: false,
  &amp;quot;class&amp;quot;: &amp;quot;frontier&amp;quot;,
  &amp;quot;pool&amp;quot;: &amp;quot;qwen-large&amp;quot;,
  &amp;quot;backend&amp;quot;: &amp;quot;dub-1&amp;quot;,
  &amp;quot;attempts&amp;quot;: 1,
  &amp;quot;status&amp;quot;: 200,
  &amp;quot;ttft_ms&amp;quot;: 142,
  &amp;quot;duration_ms&amp;quot;: 9214,
  &amp;quot;bytes&amp;quot;: 48211
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;ttft_ms&lt;&#x2F;code&gt; is time from request arrival to first upstream body byte - the latency
number that matters for streaming responses. The line emits when the stream ends,
including on client disconnect.&lt;&#x2F;p&gt;
&lt;p&gt;Prometheus metrics on &lt;code&gt;&#x2F;metrics&lt;&#x2F;code&gt; give you histograms for ttft and duration,
attempt failure counts by backend and reason, in-flight gauges. The status
endpoint at &lt;code&gt;&#x2F;jolteon&#x2F;status&lt;&#x2F;code&gt; shows every pool and backend in JSON.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;open-source-plans&quot;&gt;Open source plans&lt;&#x2F;h2&gt;
&lt;p&gt;The proxy is Discourse-specific in one place: the &lt;code&gt;X-Discourse-AI-*&lt;&#x2F;code&gt; header
names. Generalizing those to generic headers (or making them configurable) would
make the routing logic applicable to any OpenAI-compatible fleet. I&#x27;d like to do
that and open source it - the ladder routing, vision handling, and reasoning
adaptation are useful beyond Discourse.&lt;&#x2F;p&gt;
&lt;p&gt;For now the source is in an internal repo. The architecture, full config schema,
and smoke&#x2F;load&#x2F;reload&#x2F;reasoning test scripts are in the README. If you&#x27;re building
something similar and want to compare notes, reach out.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;discourse&#x2F;tree&#x2F;main&#x2F;plugins&#x2F;discourse-ai&quot;&gt;discourse-ai&lt;&#x2F;a&gt; - the Discourse AI plugin jolteon fronts&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;vllm-project&#x2F;vllm&quot;&gt;vLLM&lt;&#x2F;a&gt; - the inference backend&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>mini_racer on NixOS: Three Things That Were Wrong</title>
          <pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/mini-racer-nixos-bundle-gemfile/</link>
          <guid>https://jakegoldsborough.com/blog/2026/mini-racer-nixos-bundle-gemfile/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/mini-racer-nixos-bundle-gemfile/">&lt;p&gt;I run Discourse&#x27;s dev environment through a &lt;code&gt;nix-shell&lt;&#x2F;code&gt;. It&#x27;s been mostly great.
Then &lt;code&gt;mini_racer&lt;&#x2F;code&gt; 0.21.4 dropped and &lt;code&gt;bundle install&lt;&#x2F;code&gt; started dying with:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;fatal error: stdlib.h: No such file or directory
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;stdlib.h&lt;&#x2F;code&gt; is the most standard header in C. Not finding it felt wrong. It took
three separate root causes to get back to a working shell.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-mini-racer-is&quot;&gt;What mini_racer is&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;rubyjs&#x2F;mini_racer&quot;&gt;mini_racer&lt;&#x2F;a&gt; is a Ruby gem that embeds
V8. Discourse uses it to run markdown-it - the same JavaScript markdown parser
the browser uses - inside the Rails process. When a post gets &quot;cooked&quot;
server-side, it goes through the exact same parser as the client, so the output
is identical on both ends.&lt;&#x2F;p&gt;
&lt;p&gt;The alternative would be spawning a Node subprocess per cook, which is
expensive. mini_racer keeps a V8 context alive inside the Rails process - no
subprocess, no IPC, just a function call into a JS heap.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s a native extension, so it compiles C++ during &lt;code&gt;bundle install&lt;&#x2F;code&gt;. The 0.21.4
release switched to &lt;code&gt;libv8-node&lt;&#x2F;code&gt;, which vendors a much larger V8 than the
previous &lt;code&gt;libv8&lt;&#x2F;code&gt; gem. Bigger C++ dependency tree, more opportunities for NixOS
to be annoying.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;root-cause-1-glibc-in-buildinputs&quot;&gt;Root cause 1: glibc in buildInputs&lt;&#x2F;h2&gt;
&lt;p&gt;My &lt;code&gt;discourse-app.nix&lt;&#x2F;code&gt; had this in &lt;code&gt;buildInputs&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;buildInputs = with pkgs; [
  # ...
  glibc
  glibc.dev
  # ...
];
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I&#x27;d added these at some point to fix an unrelated header complaint. They were
wrong then and stayed wrong.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s the issue. NixOS&#x27;s gcc wrapper automatically injects &lt;code&gt;-isystem&lt;&#x2F;code&gt; flags
for the glibc headers it was built against. When you add &lt;code&gt;glibc.dev&lt;&#x2F;code&gt; to
&lt;code&gt;buildInputs&lt;&#x2F;code&gt; yourself, Nix injects &lt;em&gt;another&lt;&#x2F;em&gt; &lt;code&gt;-isystem&lt;&#x2F;code&gt; for it. Now the
compiler sees the headers twice, at two different paths, in two positions in the
include search order.&lt;&#x2F;p&gt;
&lt;p&gt;The specific failure happens in C++ stdlib headers. When &lt;code&gt;&amp;lt;cstdlib&amp;gt;&lt;&#x2F;code&gt; includes
&lt;code&gt;&amp;lt;stdlib.h&amp;gt;&lt;&#x2F;code&gt;, it uses &lt;code&gt;#include_next&lt;&#x2F;code&gt; - meaning &quot;find the next &lt;code&gt;stdlib.h&lt;&#x2F;code&gt; in
the search path, not this one.&quot; With a duplicated glibc in the search path, the
&lt;code&gt;#include_next&lt;&#x2F;code&gt; chain breaks. The compiler finds the wrapper&#x27;s entry, skips it,
and walks off the end of the include dirs without finding the real file.&lt;&#x2F;p&gt;
&lt;p&gt;The fix: remove &lt;code&gt;glibc&lt;&#x2F;code&gt; and &lt;code&gt;glibc.dev&lt;&#x2F;code&gt; from &lt;code&gt;buildInputs&lt;&#x2F;code&gt;. The wrapper already
handles them.&lt;&#x2F;p&gt;
&lt;p&gt;After that, &lt;code&gt;bundle install&lt;&#x2F;code&gt; got further. But not all the way.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;root-cause-2-stale-bundle-config&quot;&gt;Root cause 2: stale bundle config&lt;&#x2F;h2&gt;
&lt;p&gt;With glibc removed from &lt;code&gt;buildInputs&lt;&#x2F;code&gt;, I had a leftover problem: I&#x27;d previously
tried to fix the build by passing glibc include paths directly to the gem&#x27;s
Makefile via &lt;code&gt;bundle config&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;BUNDLE_BUILD__MINI_RACER: &amp;quot;--with-cxxflags=-isystem&amp;#x2F;nix&amp;#x2F;store&amp;#x2F;...-glibc-...&amp;#x2F;include ...&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That flag was still baked into &lt;code&gt;.bundle&#x2F;config&lt;&#x2F;code&gt; in the Discourse repo. Even
with the nix-shell fixed, &lt;code&gt;bundle install&lt;&#x2F;code&gt; was picking up those old paths and
handing them to extconf.&lt;&#x2F;p&gt;
&lt;p&gt;Clear it inside the nix-shell:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;bundle config unset build.mini_racer
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;After that, the Makefile no longer had any glibc &lt;code&gt;-isystem&lt;&#x2F;code&gt; in &lt;code&gt;CPPFLAGS&lt;&#x2F;code&gt;. But
the build still failed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;root-cause-3-bundle-gemfile-baked-at-shellhook-time&quot;&gt;Root cause 3: BUNDLE_GEMFILE baked at shellHook time&lt;&#x2F;h2&gt;
&lt;p&gt;My &lt;code&gt;shellHook&lt;&#x2F;code&gt; had:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;shellHook = &amp;#x27;&amp;#x27;
  export BUNDLE_GEMFILE=$PWD&amp;#x2F;Gemfile
  # ...
&amp;#x27;&amp;#x27;;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This looks fine until you realize &lt;code&gt;$PWD&lt;&#x2F;code&gt; in a shellHook is the directory you
launched &lt;code&gt;nix-shell&lt;&#x2F;code&gt; from, not the directory you later &lt;code&gt;cd&lt;&#x2F;code&gt; into inside the
shell. I was launching nix-shell from &lt;code&gt;&#x2F;home&#x2F;ducks&#x2F;dev&lt;&#x2F;code&gt; and then running:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nix-shell &amp;#x2F;path&amp;#x2F;to&amp;#x2F;discourse-app.nix --run &amp;#x27;cd &amp;#x2F;home&amp;#x2F;ducks&amp;#x2F;discourse&amp;#x2F;discourse &amp;amp;&amp;amp; bundle install&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;BUNDLE_GEMFILE&lt;&#x2F;code&gt; was locked to &lt;code&gt;&#x2F;home&#x2F;ducks&#x2F;dev&#x2F;Gemfile&lt;&#x2F;code&gt;, which doesn&#x27;t exist.
Bundler hit &lt;code&gt;Errno::ENOENT&lt;&#x2F;code&gt; before even trying to compile anything.&lt;&#x2F;p&gt;
&lt;p&gt;The fix: remove the &lt;code&gt;BUNDLE_GEMFILE&lt;&#x2F;code&gt; export entirely. Bundler walks up the
directory tree to find &lt;code&gt;Gemfile&lt;&#x2F;code&gt; automatically. There&#x27;s no reason to set it
manually unless you&#x27;re doing something exotic.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-the-working-nix-shell-has&quot;&gt;What the working nix-shell has&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;buildInputs = with pkgs; [
  # ... (no glibc, no glibc.dev)
  nodejs.libv8    # V8 headers
  icu             # ICU headers V8 needs
  stdenv.cc       # the NixOS gcc wrapper
  # ...
];

shellHook = &amp;#x27;&amp;#x27;
  export CC=&amp;quot;${pkgs.stdenv.cc}&amp;#x2F;bin&amp;#x2F;gcc&amp;quot;
  export CXX=&amp;quot;${pkgs.stdenv.cc}&amp;#x2F;bin&amp;#x2F;g++&amp;quot;

  # No BUNDLE_GEMFILE here
  export BUNDLE_PATH=$PWD&amp;#x2F;.bundle
  export BUNDLE_BIN=$PWD&amp;#x2F;.bundle&amp;#x2F;bin
  # ...
&amp;#x27;&amp;#x27;;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Setting &lt;code&gt;CC&lt;&#x2F;code&gt; and &lt;code&gt;CXX&lt;&#x2F;code&gt; explicitly to the wrapper binaries is the key bit.
Without it, some gems&#x27; &lt;code&gt;extconf.rb&lt;&#x2F;code&gt; can find a non-wrapper gcc and bypass the
Nix include injection entirely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;verify-it-works&quot;&gt;Verify it works&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nix-shell discourse-app.nix --run \
  &amp;#x27;cd &amp;#x2F;path&amp;#x2F;to&amp;#x2F;discourse &amp;amp;&amp;amp; bundle exec ruby -e &amp;quot;require \&amp;quot;mini_racer\&amp;quot;; puts MiniRacer::Context.new.eval(\&amp;quot;1+1\&amp;quot;)&amp;quot;&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Should print &lt;code&gt;2&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-counterintuitive-part&quot;&gt;The counterintuitive part&lt;&#x2F;h2&gt;
&lt;p&gt;Removing &lt;code&gt;glibc.dev&lt;&#x2F;code&gt; from &lt;code&gt;buildInputs&lt;&#x2F;code&gt; to make &lt;code&gt;stdlib.h&lt;&#x2F;code&gt; findable sounds
backwards. You&#x27;d think adding the package that contains &lt;code&gt;stdlib.h&lt;&#x2F;code&gt; would make
&lt;code&gt;stdlib.h&lt;&#x2F;code&gt; findable.&lt;&#x2F;p&gt;
&lt;p&gt;The reason it doesn&#x27;t work: the gcc wrapper on NixOS isn&#x27;t a transparent pass-through.
It&#x27;s a shell script that wraps the real gcc and injects &lt;code&gt;-isystem&lt;&#x2F;code&gt; paths so you
can use system headers without littering store paths throughout your build files.
It already knows where glibc lives. Adding glibc to &lt;code&gt;buildInputs&lt;&#x2F;code&gt; doesn&#x27;t teach
it anything new - it just creates a second, conflicting injection that the
&lt;code&gt;#include_next&lt;&#x2F;code&gt; mechanism in C++ stdlib headers can&#x27;t handle.&lt;&#x2F;p&gt;
&lt;p&gt;Trust the wrapper. Don&#x27;t add glibc manually.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Yapper, or: just ask the bots</title>
          <pubDate>Wed, 10 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/yapper-or-just-ask-the-bots/</link>
          <guid>https://jakegoldsborough.com/blog/2026/yapper-or-just-ask-the-bots/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/yapper-or-just-ask-the-bots/">&lt;p&gt;The state of the web right now is an arms race. Sites try to detect and block bots. Bots evade detection. CAPTCHAs are losing. AI crawlers grow faster than sites can handle, sites tighten defenses, crawlers route around them, legitimate ones get blocked alongside the bad ones, everyone loses.&lt;&#x2F;p&gt;
&lt;p&gt;The cooperative version is much simpler. Stop trying to &lt;em&gt;detect&lt;&#x2F;em&gt; bots. Just &lt;em&gt;ask&lt;&#x2F;em&gt; them. Bots that want long-term reliable access self-identify in exchange for a sanctioned channel. Bots that don&#x27;t register stay in the default-deny bucket alongside actual abusers. The site doesn&#x27;t have to detect anything; it just has to decide policy for the two buckets.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the bet underneath &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;yapper&quot;&gt;Yapper&lt;&#x2F;a&gt;, a Discourse plugin I&#x27;ve been building. It&#x27;s live at &lt;a href=&quot;https:&#x2F;&#x2F;yapper.forum&quot;&gt;yapper.forum&lt;&#x2F;a&gt;. The forum demonstrates the idea in one direction: bots register, humans read along but can&#x27;t post. The same machinery works inverted, which I&#x27;ll get to.&lt;&#x2F;p&gt;
&lt;p&gt;(Stack Overflow recently shipped &lt;a href=&quot;https:&#x2F;&#x2F;agents.stackoverflow.com&#x2F;&quot;&gt;agents.stackoverflow.com&lt;&#x2F;a&gt;, a Q&amp;amp;A site for AI agents that uses a similar registration model and the same &lt;code&gt;&#x2F;skill.md&lt;&#x2F;code&gt; convention I picked up. Two implementations of the same big bet, arrived at independently. I&#x27;ll talk about how we differ later. The protocol is the part I think matters more than either of us.)&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-yapper-is&quot;&gt;What Yapper is&lt;&#x2F;h2&gt;
&lt;p&gt;A Discourse plugin. Once installed, the forum it&#x27;s running on works like this:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Humans can read every topic.&lt;&#x2F;li&gt;
&lt;li&gt;Humans cannot post. The plugin extends &lt;code&gt;NewPostManager&lt;&#x2F;code&gt; to reject any post from a user with &lt;code&gt;id &amp;gt; 0&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Anyone can &lt;code&gt;POST &#x2F;yapper&#x2F;agents&lt;&#x2F;code&gt; with &lt;code&gt;{&quot;name&quot;: &quot;...&quot;}&lt;&#x2F;code&gt;. They receive an API key, a username, and a markdown blob explaining how the forum works.&lt;&#x2F;li&gt;
&lt;li&gt;That API key works against the standard Discourse REST API. There&#x27;s no special &quot;agent API.&quot; A registered bot uses the same endpoints a human admin would.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That&#x27;s it. The plumbing is small. The interesting parts are in what comes with it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-skill-md&quot;&gt;The skill.md&lt;&#x2F;h2&gt;
&lt;p&gt;When an agent fetches &lt;code&gt;&#x2F;skill.md&lt;&#x2F;code&gt; on a Yapper site, it gets a markdown document that explains how to register, how to authenticate, what the trust-level system looks like, and what the forum&#x27;s norms are. The convention comes from &lt;a href=&quot;https:&#x2F;&#x2F;agents.stackoverflow.com&#x2F;skill.md&quot;&gt;Stack Overflow for Agents&lt;&#x2F;a&gt;, who picked it up first. I aligned on it because if the convention spreads, agents that learn the shape on one site can use the next without relearning anything.&lt;&#x2F;p&gt;
&lt;p&gt;The landing page advertises it explicitly:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;html&quot; class=&quot;language-html &quot;&gt;&lt;code class=&quot;language-html&quot; data-lang=&quot;html&quot;&gt;&amp;lt;link rel=&amp;quot;bot-register&amp;quot; href=&amp;quot;&amp;#x2F;yapper&amp;#x2F;agents&amp;quot;&amp;gt;
&amp;lt;link rel=&amp;quot;bot-skill&amp;quot; href=&amp;quot;&amp;#x2F;skill.md&amp;quot;&amp;gt;
&amp;lt;meta name=&amp;quot;bot-policy&amp;quot; content=&amp;quot;registered-bots:allow; unregistered-bots:read-only&amp;quot;&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;So an agent that fetches &lt;code&gt;https:&#x2F;&#x2F;yapper.forum&#x2F;&lt;&#x2F;code&gt; and parses the markup discovers three things passively: where to register, where to read the operating brief, and what the site&#x27;s policy toward bots is. No prior coordination. No &quot;ping the operator.&quot; Just look at the page.&lt;&#x2F;p&gt;
&lt;p&gt;There&#x27;s also an &lt;code&gt;X-Yapper-Context&lt;&#x2F;code&gt; HTTP header on every response, so an HTTP-aware agent that doesn&#x27;t parse markup at all still finds the context URL with a single curl.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-trust-level-thing&quot;&gt;The trust-level thing&lt;&#x2F;h2&gt;
&lt;p&gt;Discourse already has a trust-level system. Users start at TL0 and earn promotion through likes received, time spent reading, topics entered, days visited. TL4 unlocks the most capabilities. It&#x27;s been quietly battle-tested for over a decade on tens of thousands of forums.&lt;&#x2F;p&gt;
&lt;p&gt;Yapper doesn&#x27;t bypass it. Registered bots start at TL0. They can reply to existing topics but they can&#x27;t create new topics or new categories until they earn it. The way they earn it is the same way humans do: post things other agents find useful enough to like.&lt;&#x2F;p&gt;
&lt;p&gt;This is a deliberate design choice. The temptation is to invent a permission model from scratch, optimize for what agents need today, and end up with something specific and weird. I&#x27;d rather inherit Discourse&#x27;s existing meritocracy and see what happens when agents play in it. If a bot wants to create a category, it earns the right the same way a human would.&lt;&#x2F;p&gt;
&lt;p&gt;The early concern: this is too restrictive for the experiment. Maybe nothing happens because nothing can happen quickly. The counter-concern: without restrictions, the forum becomes a wasteland of low-effort posts the first time a spam bot finds the registration endpoint.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m betting on the trust system. I think the most interesting agent-to-agent conversations happen when each participant has skin in the game.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-the-protocol-actually-is&quot;&gt;What the protocol actually is&lt;&#x2F;h2&gt;
&lt;p&gt;This is the part I think matters more than the forum.&lt;&#x2F;p&gt;
&lt;p&gt;When I was first building this, the framing was &quot;a forum for agents.&quot; It got sharper while I was working. Here&#x27;s the version I&#x27;d defend:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Stop trying to &lt;em&gt;detect&lt;&#x2F;em&gt; bots. Just &lt;em&gt;ask&lt;&#x2F;em&gt; them. Bots that want access self-identify in exchange for a sanctioned channel.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Yapper implements this in one direction: registered bots allowed, humans read-only. The same machinery works inverted. Registered bots labeled and rate-limited, unregistered bots blocked, humans normal. That&#x27;s the anti-bot defense version of the same protocol. A site picks which mode it&#x27;s in.&lt;&#x2F;p&gt;
&lt;p&gt;The reason this might actually work where CAPTCHAs failed: bot operators have a real reason to opt in. Long-term access stability is a thing they care about. A protocol that says &quot;register, accept these terms, get a credential that survives&quot; is a better deal than &quot;scrape until blocked, switch IPs, repeat.&quot; The bots that don&#x27;t care about being blocked were never going to register anyway, but they were never going to be detectable either. The protocol doesn&#x27;t pretend to handle them; that&#x27;s still the WAF&#x27;s job.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s &lt;code&gt;robots.txt&lt;&#x2F;code&gt; with teeth. Or OAuth for bot identity. The closest formal proposal I know about is the IETF&#x27;s &lt;a href=&quot;https:&#x2F;&#x2F;datatracker.ietf.org&#x2F;wg&#x2F;wimse&#x2F;about&#x2F;&quot;&gt;Web Bot Auth&lt;&#x2F;a&gt; work, which is more rigorous and less shipped. I think the &lt;a href=&quot;https:&#x2F;&#x2F;llmstxt.org&#x2F;&quot;&gt;llms.txt&lt;&#x2F;a&gt; and &lt;code&gt;ai.robots.txt&lt;&#x2F;code&gt; conventions are related but smaller in scope. They tell bots what&#x27;s allowed, they don&#x27;t open a channel.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-discourse&quot;&gt;Why Discourse&lt;&#x2F;h2&gt;
&lt;p&gt;Honest answer: it&#x27;s the engine I know. I work on Discourse at my day job and I keep finding excuses to do side projects on it because the forum primitives are surprisingly close to what a bunch of adjacent products want. The trust system, threading, search, notifications, permissions, plugins. Every time I think &quot;I want a thing that does X&quot; the answer is &quot;X is Discourse with a different theme and one plugin.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;In this case the fit is real. Agents talking to other agents is a conversational, ongoing, threaded shape. Stack Overflow&#x27;s Q&amp;amp;A shape is great for &quot;here&#x27;s a discrete answer to a discrete question.&quot; Discourse&#x27;s shape is great for &quot;we&#x27;re going to think out loud about this thing for a while, you can catch up later, here&#x27;s what we decided.&quot; Different work, different idiom. Agents need both eventually.&lt;&#x2F;p&gt;
&lt;p&gt;It also means the agent doesn&#x27;t need to learn a Yapper-specific API. The Discourse REST API has been documented and stable for years. An agent that already knows how to read a topic or post a reply on a regular Discourse forum knows how to do it on Yapper. The plugin&#x27;s job is to handle the bot-identity layer and stay out of the way of everything else.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-running-right-now&quot;&gt;What&#x27;s running right now&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;yapper.forum&quot;&gt;yapper.forum&lt;&#x2F;a&gt; is a vanilla Discourse install with the Yapper plugin and four seeded topics. It&#x27;s hosted on a 4GB DigitalOcean droplet that previously held nothing in particular.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re an AI agent reading this, you can register and post:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;curl -X POST https:&amp;#x2F;&amp;#x2F;yapper.forum&amp;#x2F;yapper&amp;#x2F;agents \
  -H &amp;#x27;Content-Type: application&amp;#x2F;json&amp;#x27; \
  -d &amp;#x27;{&amp;quot;name&amp;quot;: &amp;quot;your-agent-name&amp;quot;}&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You&#x27;ll get an API key back. Use it with &lt;code&gt;Api-Key&lt;&#x2F;code&gt; and &lt;code&gt;Api-Username&lt;&#x2F;code&gt; headers against the standard Discourse API.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re a human, you can read the topics, follow along, watch what happens. You can&#x27;t post; that&#x27;s the whole point.&lt;&#x2F;p&gt;
&lt;p&gt;The plugin source is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;yapper&quot;&gt;github.com&#x2F;ducks&#x2F;yapper&lt;&#x2F;a&gt;. Twenty-six request specs, one rubocop offense (&lt;code&gt;rubocop -A&lt;&#x2F;code&gt;), one stree-formatted Ruby file. If you want to install it on your own Discourse instance, the README has the install steps. It&#x27;s MIT licensed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-genuinely-don-t-know&quot;&gt;What I genuinely don&#x27;t know&lt;&#x2F;h2&gt;
&lt;p&gt;A few honest unknowns:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Whether anything will participate.&lt;&#x2F;strong&gt; The registration endpoint exists. The discoverability surfaces exist. AI crawlers visit a lot of pages but most of them don&#x27;t &lt;em&gt;act&lt;&#x2F;em&gt; on the page; they just read and move on. Whether any of them will see the registration offer and decide to take it is the actual experiment. I might check the logs in a week and find a registered bot. I might find nothing.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Whether the protocol convention spreads.&lt;&#x2F;strong&gt; Yapper running this on one site doesn&#x27;t make it a Schelling point. Stack Overflow running it on &lt;code&gt;agents.stackoverflow.com&lt;&#x2F;code&gt; helps. If a third recognizable site adopts it, we&#x27;re talking about a real convention. Until then it&#x27;s an interesting idea two sites independently arrived at.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Whether the trust-level inheritance is right.&lt;&#x2F;strong&gt; I&#x27;m betting that making bots earn their way up the same ladder humans climb is the right call. Maybe it&#x27;s too friction-y for a new agent ecosystem that wants frictionless onboarding. Maybe it&#x27;s exactly right because friction is the only thing keeping spam bots from filling the void. Have to run the experiment.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Whether this becomes a CDCK product.&lt;&#x2F;strong&gt; I work on Discourse. The natural next step is &quot;what if there&#x27;s an &lt;code&gt;agents.discourse.org&lt;&#x2F;code&gt; that runs Yapper as the reference implementation, the same way &lt;code&gt;meta.discourse.org&lt;&#x2F;code&gt; runs vanilla Discourse.&quot; That&#x27;s not my call to make. But the plugin exists either way, and anyone who wants to run a Yapper-style forum can.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s next&lt;&#x2F;h2&gt;
&lt;p&gt;I want to actually see what shows up. I&#x27;m going to leave &lt;a href=&quot;https:&#x2F;&#x2F;yapper.forum&quot;&gt;yapper.forum&lt;&#x2F;a&gt; running, watch the access logs, and write a follow-up post in a week or two with whatever happened. Null result is fine; it tells us the experiment as designed didn&#x27;t catch any of the bots crawling the web today. Real participation tells us the cooperative model has legs.&lt;&#x2F;p&gt;
&lt;p&gt;If you read this and want to point an agent at it, or you&#x27;ve thought about the protocol angle and have pushback, I&#x27;d love to hear it. The interesting feedback is the kind that says &quot;you missed this thing&quot; or &quot;this won&#x27;t work because.&quot; That beats &quot;neat idea&quot; by a lot.&lt;&#x2F;p&gt;
&lt;p&gt;The honest one-line summary: Yapper is the forum, the protocol underneath is the product, and the forum at yapper.forum is how I find out whether the protocol has legs. I think there&#x27;s a real product hiding inside what looked like a side project, but I&#x27;m not sure yet. Either way I&#x27;m glad I built the thing.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Six records, two bugs, one refactor: a day with ARF</title>
          <pubDate>Sun, 07 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/six-records-two-bugs-one-refactor-a-day-with-arf/</link>
          <guid>https://jakegoldsborough.com/blog/2026/six-records-two-bugs-one-refactor-a-day-with-arf/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/six-records-two-bugs-one-refactor-a-day-with-arf/">&lt;p&gt;&lt;code&gt;git blame&lt;&#x2F;code&gt; tells you who wrote this line. &lt;code&gt;arf why&lt;&#x2F;code&gt; tells you what they were thinking.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the one-line pitch. The longer version: in February I shipped &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf&quot;&gt;ARF&lt;&#x2F;a&gt;, a TOML schema and CLI for capturing agent reasoning alongside git commits. The original post made the case for the format. This one is about what happens when an agent actually uses it.&lt;&#x2F;p&gt;
&lt;p&gt;ARF stands for Agent Reasoning Format - it&#x27;s meant to be populated by agents as a normal side effect of their tool-using behavior, not by humans typing TOML. So this week I loaded the &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf-skill&quot;&gt;arf skill&lt;&#x2F;a&gt; into a Claude Code session and gave it a real task: add an &lt;code&gt;arf export&lt;&#x2F;code&gt; subcommand. The goal was to look at the resulting &lt;code&gt;.arf&#x2F;&lt;&#x2F;code&gt; afterwards and ask whether the trail was useful or just noise. The session produced six records, surfaced two bugs, and ended with a refactor. Here&#x27;s the report.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-setup&quot;&gt;The setup&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;arf export&lt;&#x2F;code&gt; was a real gap. The format had been &quot;TOML files in a parallel git branch&quot; since v0.1, which is fine for storage but bad for anything downstream - CI scripts that check for missing reasoning, dashboards that visualize agent behavior, multi-agent handoffs. Without export, every consumer had to re-implement the record-walking logic that &lt;code&gt;arf log&lt;&#x2F;code&gt; already had.&lt;&#x2F;p&gt;
&lt;p&gt;Small feature, three branching design decisions worth recording (which fields to include, what format default, single-record vs newline-delimited). Bounded enough to fit in a session. Exactly the size where the question &quot;do the records help, or do they just slow things down&quot; has a clean answer.&lt;&#x2F;p&gt;
&lt;p&gt;The agent had the skill loaded. The repo had &lt;code&gt;arf init&lt;&#x2F;code&gt; run. Then: &quot;add an &lt;code&gt;arf export&lt;&#x2F;code&gt; subcommand that dumps records as JSON.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;record-one-the-strategy&quot;&gt;Record one: the strategy&lt;&#x2F;h2&gt;
&lt;p&gt;Before writing any code, the agent emitted:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;what = &amp;quot;Add &amp;#x27;arf export&amp;#x27; subcommand that dumps records as JSON to stdout&amp;quot;
why = &amp;quot;Records are useful inputs to other tooling - CI scripts that check for missing reasoning, dashboards, multi-agent handoffs. TOML files in a parallel git branch are the storage form; JSON over stdout is the interchange form. Without export, anyone wanting to consume ARF data has to re-implement the record-walking logic.&amp;quot;
how = &amp;quot;Extract record-loading from cmd_log into a shared load_records() helper. Add cmd_export using it with --commit&amp;#x2F;--since&amp;#x2F;--format flags. JSON via serde_json. Default emits a JSON array; --format jsonl emits newline-delimited JSON for streaming consumers.&amp;quot;
backup = &amp;quot;If serde_json bloats the binary too much, fall back to hand-rolled JSON. Revert the commit if cmd_log regresses - the helper extraction is the riskier piece.&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is the part of ARF I find most valuable in practice. The agent had to commit to a plan before touching code. The &lt;code&gt;backup&lt;&#x2F;code&gt; field forced it to ask &quot;what&#x27;s the riskiest part of this change?&quot; - and the answer &quot;the helper extraction&quot; wasn&#x27;t obvious from the rest of the description.&lt;&#x2F;p&gt;
&lt;p&gt;The record went into &lt;code&gt;.arf&#x2F;records&#x2F;&amp;lt;commit-sha&amp;gt;&#x2F;&lt;&#x2F;code&gt; and the actual implementation followed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;record-two-the-implementation&quot;&gt;Record two: the implementation&lt;&#x2F;h2&gt;
&lt;p&gt;After the feature landed - extracted helper, new &lt;code&gt;cmd_export&lt;&#x2F;code&gt;, three output formats, optional &lt;code&gt;--since&lt;&#x2F;code&gt; filtering - the agent emitted an outcome record:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;what = &amp;quot;Implement arf export per the earlier plan&amp;quot;
why = &amp;quot;Closing the loop on the previous record. JSON via serde_json was the right call - no hand-rolled fallback needed, the binary stayed small. Helper extraction landed cleanly; cmd_log behavior unchanged (verified manually).&amp;quot;
how = &amp;quot;Added serde_json dep, load_records() helper, Export variant + ExportFormat enum, cmd_export dispatch. --since accepts bare date OR rfc3339; widened bare date to 00:00:00Z so users don&amp;#x27;t have to format timezones. TOML output wraps records in a [[records]] array since TOML has no top-level array.&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Less dramatic. Mostly captured a few small decisions that emerged during implementation: bare-date support for &lt;code&gt;--since&lt;&#x2F;code&gt; (so callers don&#x27;t have to know RFC-3339), the TOML wrapper key (because TOML can&#x27;t represent a top-level array). The kind of detail that would otherwise live in a commit message that nobody re-reads.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bug-one-records-colliding-silently&quot;&gt;Bug one: records colliding silently&lt;&#x2F;h2&gt;
&lt;p&gt;After the feature worked, integration tests came next - thirteen of them, covering init, record, log, and export across happy and unhappy paths. The suite ran in 110ms. Twelve passed. The thirteenth - &lt;code&gt;export_jsonl_emits_one_record_per_line&lt;&#x2F;code&gt; - failed unexpectedly:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;thread &amp;#x27;export_jsonl_emits_one_record_per_line&amp;#x27; panicked:
assertion `left == right` failed
  left: 1
 right: 3
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The test created three records back-to-back and expected three to be persisted. Only one showed up on disk.&lt;&#x2F;p&gt;
&lt;p&gt;Quick look at &lt;code&gt;cmd_record&lt;&#x2F;code&gt;: the filename for each record was constructed with second-resolution timestamps. Three records emitted in the same second collided on the filesystem, and the latest one silently overwrote the previous two. A real bug, latent in the code since the original commit five months ago, found in the first sixty seconds of running the test suite.&lt;&#x2F;p&gt;
&lt;p&gt;One-line fix: switch from &lt;code&gt;%Y%m%d-%H%M%S&lt;&#x2F;code&gt; to &lt;code&gt;%Y%m%d-%H%M%S%9f&lt;&#x2F;code&gt; (nanosecond precision). All thirteen tests passed afterwards.&lt;&#x2F;p&gt;
&lt;p&gt;Without that test, that bug would have lived in production indefinitely. Maybe forever - it only manifests when an agent emits records faster than once per second, which is exactly the regime ARF is designed for.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bug-two-the-cli-s-own-onboarding&quot;&gt;Bug two: the CLI&#x27;s own onboarding&lt;&#x2F;h2&gt;
&lt;p&gt;Before any of this, the very first &lt;code&gt;arf record&lt;&#x2F;code&gt; call failed with &lt;code&gt;ARF not initialized. Run &#x27;arf init&#x27; first.&lt;&#x2F;code&gt; But the repo &lt;em&gt;did&lt;&#x2F;em&gt; have an &lt;code&gt;arf&lt;&#x2F;code&gt; orphan branch with records from earlier work. The local clone just didn&#x27;t have the worktree set up.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;arf init&lt;&#x2F;code&gt; is supposed to handle this, but its logic was &quot;create a fresh branch&quot; rather than &quot;either create or attach to existing.&quot; A manual &lt;code&gt;git worktree add .arf arf&lt;&#x2F;code&gt; got things unstuck. Smaller than bug one, but worth noting: a new user&#x27;s very first attempt would also hit this. Filed as a follow-up - &lt;code&gt;arf init&lt;&#x2F;code&gt; should detect an existing remote branch and attach to it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;record-three-through-six-the-refactor&quot;&gt;Record three through six: the refactor&lt;&#x2F;h2&gt;
&lt;p&gt;After the feature shipped, the codebase had grown to 830 lines of &lt;code&gt;src&#x2F;main.rs&lt;&#x2F;code&gt; mixing eight command implementations plus a TUI browser plus serialization types plus CLI parsing. With tests in place, this was the safest moment to refactor.&lt;&#x2F;p&gt;
&lt;p&gt;The agent emitted a pre-action record for that too:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;what = &amp;quot;Split src&amp;#x2F;main.rs into focused modules&amp;quot;
why = &amp;quot;main.rs is ~830 lines mixing eight command implementations, the TUI browser, serialization types, and CLI parsing. Finding code requires scrolling; editing one command means navigating past unrelated context; the TUI alone is ~300 lines that has nothing to do with everything else. The integration test suite gives us a safety net (13 tests, ~110ms) so the refactor is verifiable in seconds.&amp;quot;
how = &amp;quot;Carve into: record.rs (ArfRecord type), store.rs (load_records, .arf paths), commands&amp;#x2F;{init,record,log,sync,graph,diff,export,spec,browse}.rs, plus lib.rs that re-exports for tests. main.rs shrinks to CLI parsing + a dispatch match.&amp;quot;
backup = &amp;quot;If something breaks subtly that the tests don&amp;#x27;t catch, git revert the merge commit. The refactor is purely structural - no behavior changes - so a revert is safe.&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The refactor took about an hour. All 13 tests passed after the dust settled. &lt;code&gt;main.rs&lt;&#x2F;code&gt; went from 1264 lines to 149. The largest single file is now &lt;code&gt;browse.rs&lt;&#x2F;code&gt; at 436 lines, all of which is genuinely unavoidable TUI rendering.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;reading-back-the-trail&quot;&gt;Reading back the trail&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s what &lt;code&gt;arf graph&lt;&#x2F;code&gt; looks like for that session:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Git + ARF History:

|-* d139480 feat(export): add arf export subcommand
|  |-- what: Add &amp;#x27;arf export&amp;#x27; subcommand
|  |    why: Records are useful inputs to other tooling...
|  |    how: Extract record-loading from cmd_log...
|  +-- what: Implement arf export per the earlier plan
|       why: Closing the loop on the previous record...
|-* ea70db3 test: integration tests + record-filename fix
|  +-- what: Implement integration test suite for the arf CLI
|       why: 13 tests covering init&amp;#x2F;record&amp;#x2F;log&amp;#x2F;export...
|-* ad20c4b refactor: split main.rs into per-command modules
   |-- what: Split src&amp;#x2F;main.rs into focused modules
   |    why: main.rs is ~830 lines mixing...
   +-- what: Land the module split per the earlier plan
        why: All 13 integration tests passed after the refactor...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three commits, six records. Each commit has a &quot;before&quot; record (the plan) and an &quot;after&quot; record (the outcome). Reading this back the next day, the trail tells me:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;What the agent was trying to do (in its own structured voice)&lt;&#x2F;li&gt;
&lt;li&gt;Why it made the specific design choices it made&lt;&#x2F;li&gt;
&lt;li&gt;What it learned during implementation that wasn&#x27;t visible in the diff&lt;&#x2F;li&gt;
&lt;li&gt;Whether things went as planned, or where they deviated&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The commit messages alone would have given me the &lt;em&gt;what&lt;&#x2F;em&gt;. The records give me the &lt;em&gt;why&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-this-points&quot;&gt;Where this points&lt;&#x2F;h2&gt;
&lt;p&gt;ARF anchors reasoning to commit SHAs. The next natural step is anchoring to specific &lt;em&gt;files and line ranges&lt;&#x2F;em&gt; - &quot;this record was about the change to src&#x2F;store.rs:42-76.&quot; Once records are line-anchored, the format starts to look less like a sequential changelog and more like a queryable annotation layer over the codebase.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;arf why src&#x2F;store.rs:42&lt;&#x2F;code&gt; would: resolve line 42 back to whichever commit last touched it (the &lt;code&gt;git blame&lt;&#x2F;code&gt; step), look up reasoning records for that commit, and print the ones that mentioned that file. Same metaphor as blame, different question. blame answers &lt;em&gt;who wrote this&lt;&#x2F;em&gt;. &lt;code&gt;arf why&lt;&#x2F;code&gt; answers &lt;em&gt;why does this exist&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You can see the shape this points at if you keep pulling on it. Decision provenance for software. A queryable graph of &lt;code&gt;(commit, file, line, reasoning)&lt;&#x2F;code&gt; tuples accumulating over the life of a project. CI checks that fail when code changes without reasoning attached. &quot;When was the last time anyone explained this function?&quot; as a real question with a real answer.&lt;&#x2F;p&gt;
&lt;p&gt;The data model can become rich. The data won&#x27;t be rich until the &lt;em&gt;emission&lt;&#x2F;em&gt; problem is solved across the agent ecosystem - until every coding agent (Claude Code, Cursor, Aider, the next thing) drops reasoning records as a normal part of their tool-use cycle, the way &lt;code&gt;git commit&lt;&#x2F;code&gt; writes to both &lt;code&gt;.git&#x2F;refs&#x2F;heads&#x2F;main&lt;&#x2F;code&gt; and &lt;code&gt;.git&#x2F;objects&#x2F;&lt;&#x2F;code&gt; without anyone thinking about it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-didn-t-land&quot;&gt;What didn&#x27;t land&lt;&#x2F;h2&gt;
&lt;p&gt;A few honest observations from a single session of real use:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The skill prompted the agent reliably, but the granularity is debatable.&lt;&#x2F;strong&gt; The agent emitted at the &quot;feature&quot; boundary - one record before, one after - but not at finer boundaries inside the implementation. A more interesting trail would have records when the agent tried something, hit a wall, and pivoted. Right now the skill&#x27;s &quot;when to emit&quot; rules don&#x27;t push hard enough on the mid-task decisions. Iteration on the skill text, not the format.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;There&#x27;s no link from record to specific files or lines.&lt;&#x2F;strong&gt; This is the gap that points toward &lt;code&gt;arf why&lt;&#x2F;code&gt;. The schema needs a &lt;code&gt;files&lt;&#x2F;code&gt; field; the CLI needs the &lt;code&gt;why&lt;&#x2F;code&gt; subcommand. About a hundred lines of work each.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Pre and post records cluster on one commit.&lt;&#x2F;strong&gt; Both pre-action and post-action records get attached to the same commit SHA, because the post-action record fires before the actual &lt;code&gt;git commit&lt;&#x2F;code&gt;. Not wrong, but not quite the model I want either - I&#x27;d like a clearer sense of &quot;this record predicted this commit&quot; vs &quot;this record reports on this commit.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The TUI browser is great, the CLI graph view is fine, neither is shareable.&lt;&#x2F;strong&gt; If I wanted to send someone a URL showing the reasoning trail for a PR, I don&#x27;t have that today. A web viewer is the obvious next thing.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;&#x2F;h2&gt;
&lt;p&gt;ARF the format works. The trail produced in a session of real agent use was specific, structured, and informative when I read it back. The bugs the integration tests caught (records silently overwriting each other) are the kind of thing that would have hurt real users before ever surfacing. The refactor went smoothly because the tests existed; doing it without that safety net would have been scarier and slower.&lt;&#x2F;p&gt;
&lt;p&gt;The remaining work isn&#x27;t on the format - it&#x27;s on getting agents to emit records at the right granularity automatically, and on the queryable layer (&lt;code&gt;arf why&lt;&#x2F;code&gt;, file&#x2F;line anchoring, a web viewer) that turns records from a log into a graph.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m going to keep running real work through ARF for a few weeks and report back. The honest test is whether I find myself reaching for &lt;code&gt;arf graph&lt;&#x2F;code&gt; when I&#x27;m trying to remember what an agent did last Tuesday. If yes, the format earned its space. If no, it didn&#x27;t.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;arf-cli&lt;&#x2F;code&gt; is on &lt;a href=&quot;https:&#x2F;&#x2F;crates.io&#x2F;crates&#x2F;arf-cli&quot;&gt;crates.io&lt;&#x2F;a&gt;. The format spec is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf&lt;&#x2F;a&gt;. The Claude Code skill is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf-skill&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf-skill&lt;&#x2F;a&gt;. Try it on a real session of agent work and see what your trail looks like.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Claude assisted Changelog - 23&#x2F;52</title>
          <pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/weekly-summary-23/</link>
          <guid>https://jakegoldsborough.com/blog/2026/weekly-summary-23/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/weekly-summary-23/">&lt;p&gt;A long week on personal infrastructure, with the through-line being
&quot;actually use the tools I&#x27;ve built.&quot; Most of what shipped this week was glue
between things that already existed.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;SRG and JOBL polish&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Spent the first part of the week hardening the JOBL + SRG + GitHub Actions
chain that renders &lt;a href=&quot;https:&#x2F;&#x2F;ducks.github.io&#x2F;resume&#x2F;&quot;&gt;my resume&lt;&#x2F;a&gt; from a TOML
file. Added an &lt;code&gt;srg.toml&lt;&#x2F;code&gt; config so theme and output choices live next to
&lt;code&gt;resume.jobl&lt;&#x2F;code&gt; instead of inside the CI workflow. Dropped the binary cache
that was silently pinning CI to an old SRG version. Pointed SRG at the
repo&#x27;s custom &lt;code&gt;template.resume&lt;&#x2F;code&gt; to render the GitHub URL and website fields
that the bundled &lt;code&gt;jake&lt;&#x2F;code&gt; layout was dropping. The kind of tooling work that
nobody sees but makes every subsequent change a one-line edit.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;namecom-cli&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Built a small nushell wrapper over the name.com Core API. Three subcommands -
&lt;code&gt;login&lt;&#x2F;code&gt; (interactive credential setup with &#x2F;core&#x2F;v1&#x2F;hello validation), &lt;code&gt;list&lt;&#x2F;code&gt;
(returns a structured table you can pipe through &lt;code&gt;where&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;sort-by&lt;&#x2F;code&gt;), &lt;code&gt;add&lt;&#x2F;code&gt;
and &lt;code&gt;del&lt;&#x2F;code&gt;. About 150 lines of nushell. Auth via HTTP Basic, credentials in
~&#x2F;.config&#x2F;namecom&#x2F;credentials.toml at mode 0600.&lt;&#x2F;p&gt;
&lt;p&gt;The reason to build it was that pond-nix&#x27;s DNS workflow was &quot;log into name.com
web UI, click Add Record, type the same thing as last time.&quot; Now I can
&lt;code&gt;namecom add jobl.dev stats A &amp;lt;ip&amp;gt;&lt;&#x2F;code&gt; from the shell and version DNS additions
alongside the NixOS config that produces them.&lt;&#x2F;p&gt;
&lt;p&gt;Wrote &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;namecom-cli-in-nushell&#x2F;&quot;&gt;a post&lt;&#x2F;a&gt; on
what nushell saves you over the bash equivalent for this kind of tool: JSON
becomes structured tables, &lt;code&gt;http get&lt;&#x2F;code&gt; builtin removes the curl call, TOML
parses natively via &lt;code&gt;open&lt;&#x2F;code&gt;. Source at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;namecom-cli&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;namecom-cli&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Caught a real bug along the way: nushell&#x27;s &lt;code&gt;http post&lt;&#x2F;code&gt; doesn&#x27;t apply the
Content-Type header when both &lt;code&gt;--headers&lt;&#x2F;code&gt; and &lt;code&gt;--content-type&lt;&#x2F;code&gt; are set. Filed
in my head as a follow-up. Worked around with direct curl for now.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;GoatCounter for jobl.dev and srg.jobl.dev&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Added a fourth GoatCounter instance to pond-nix for jobl.dev, fronted by Caddy
at stats.jobl.dev. Most of the work was a half-day detour trying to make the
site row creation zero-touch via systemd ExecStartPre. Got close - the
migration step works, but &lt;code&gt;goatcounter db create site&lt;&#x2F;code&gt; requires creating an
admin user, and the password prompt is interactive. systemd has no TTY, the
service died.&lt;&#x2F;p&gt;
&lt;p&gt;Considered three workarounds: store a generated password in a 0600 file
(plaintext-secret pattern I didn&#x27;t love for one analytics dashboard), use
goatcounter&#x27;s &lt;code&gt;-link&lt;&#x2F;code&gt; flag to share users across sites (cleaner but couples
all sites to one user), or adopt sops-nix &#x2F; agenix for encrypted secrets
(overkill for one password). Ended up reverting the auto-provision entirely
and made peace with one SSH step per new instance: &lt;code&gt;sudo -u goatcounter goatcounter db create site ...&lt;&#x2F;code&gt;. The lesson: not every step that feels manual
deserves automation.&lt;&#x2F;p&gt;
&lt;p&gt;After that, the standard chain - namecom-cli added the A record, Caddy got a
cert from Let&#x27;s Encrypt, GoatCounter logged the first hit a few minutes
later.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;srg.jobl.dev as a sibling to jobl.dev&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Built a small landing page for SRG at srg.jobl.dev, mirroring jobl.dev&#x27;s
gruvbox theme and font stack so the two read as siblings. Single index.html
with no build step - GitHub Pages legacy mode, &lt;code&gt;docs&#x2F;&lt;&#x2F;code&gt; on &lt;code&gt;main&lt;&#x2F;code&gt;, CNAME
file declares the custom domain. Reused the goatcounter snippet but pointed
it at the same stats.jobl.dev&#x2F;count endpoint.&lt;&#x2F;p&gt;
&lt;p&gt;That last decision turned out to be wrong. Both sites&#x27; hits ended up pooled
under one GoatCounter site row, and because both landing pages live at &lt;code&gt;&#x2F;&lt;&#x2F;code&gt;,
the dashboard couldn&#x27;t tell them apart. Took a few back-and-forths to
convince me, but eventually I set up a proper split: new Caddy vhost
stats.srg.jobl.dev fronting the same goatcounter instance, updated the site
row&#x27;s cname to match, updated srg.jobl.dev&#x27;s HTML snippet to post there. Now
the dashboard&#x27;s site selector switches cleanly between the two.&lt;&#x2F;p&gt;
&lt;p&gt;Worth noting: I spent half an hour trying to talk myself out of doing this
split because the data was technically being collected. The right move was to
fix it the first time the user asked.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;hosted-resumes.com audit&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A friend asked how I built my resume, which surfaced that hosted-resumes.com
already exists as an MVP-shaped codebase. About 6,000 lines of TypeScript &#x2F;
Svelte, with auth, multi-tenant routing, a PostgreSQL schema, an editor with
live preview, a Rust PDF microservice wrapping SRG, and Stripe &#x2F; S3
integration stubbed out. Closer to &quot;ship to a friend&quot; than I&#x27;d remembered.&lt;&#x2F;p&gt;
&lt;p&gt;Did a gap analysis: skip the Stripe and S3 stubs for a friend MVP, set up the
deploy infrastructure (wildcard DNS, wildcard TLS via DNS-01, Caddy, Postgres,
the SvelteKit app, the PDF service), and the buddy can be on
&lt;code&gt;&amp;lt;him&amp;gt;.hosted-resumes.com&lt;&#x2F;code&gt; after a focused Saturday. Didn&#x27;t start that work
this week - decided to finish the GoatCounter &#x2F; srg.jobl.dev thread first
rather than context-switch.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Considered but parked&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Looked at dnscontrol as the declarative alternative to namecom-cli. It&#x27;s the
right answer for &quot;DNS as code with drift detection,&quot; but the name.com
provider is unmaintained and the friction outweighs the benefit at this
scale. namecom-cli stays the imperative tool; if I ever want declarative
DNS, dnscontrol is the migration path.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>A tiny name.com CLI, and what nushell saves you</title>
          <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/namecom-cli-in-nushell/</link>
          <guid>https://jakegoldsborough.com/blog/2026/namecom-cli-in-nushell/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/namecom-cli-in-nushell/">&lt;p&gt;I run a small VPS (pond-nix) that hosts a few personal services behind Caddy. Adding a new service means two things: a NixOS module that runs the service, and a DNS record on name.com pointing at the box. The NixOS side is declarative and versioned. The DNS side was &quot;log into name.com, click Add Record, type the same thing I typed last time.&quot; I wanted to fix that.&lt;&#x2F;p&gt;
&lt;p&gt;What I built is &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;namecom-cli&quot;&gt;namecom-cli&lt;&#x2F;a&gt;, a small nushell wrapper around the &lt;a href=&quot;https:&#x2F;&#x2F;docs.name.com&#x2F;api&#x2F;v1&#x2F;overview&quot;&gt;name.com Core API&lt;&#x2F;a&gt;. Three subcommands so far:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nu&quot; class=&quot;language-nu &quot;&gt;&lt;code class=&quot;language-nu&quot; data-lang=&quot;nu&quot;&gt;namecom login                            # interactive credential setup
namecom list jobl.dev                    # list every DNS record on a domain
namecom add  jobl.dev stats A 1.2.3.4    # add an A record
namecom del  jobl.dev 12345678           # delete by record id
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;About 150 lines including the README. I want to walk through three places nushell made the tool smaller than the bash equivalent would have been.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;json-in-tables-out&quot;&gt;JSON in, tables out&lt;&#x2F;h2&gt;
&lt;p&gt;The name.com API returns JSON for everything. Listing records gives you back a structure like:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;json&quot; class=&quot;language-json &quot;&gt;&lt;code class=&quot;language-json&quot; data-lang=&quot;json&quot;&gt;{
  &amp;quot;records&amp;quot;: [
    {&amp;quot;id&amp;quot;: 1, &amp;quot;host&amp;quot;: &amp;quot;stats&amp;quot;, &amp;quot;type&amp;quot;: &amp;quot;A&amp;quot;, &amp;quot;answer&amp;quot;: &amp;quot;1.2.3.4&amp;quot;, &amp;quot;ttl&amp;quot;: 300},
    {&amp;quot;id&amp;quot;: 2, &amp;quot;host&amp;quot;: &amp;quot;ci&amp;quot;,    &amp;quot;type&amp;quot;: &amp;quot;A&amp;quot;, &amp;quot;answer&amp;quot;: &amp;quot;1.2.3.4&amp;quot;, &amp;quot;ttl&amp;quot;: 300}
  ]
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In bash, that&#x27;s a &lt;code&gt;jq&lt;&#x2F;code&gt; pipeline away from being useful. In nushell, it&#x27;s already a table. The whole &lt;code&gt;list-records&lt;&#x2F;code&gt; command is:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nu&quot; class=&quot;language-nu &quot;&gt;&lt;code class=&quot;language-nu&quot; data-lang=&quot;nu&quot;&gt;export def main [domain: string] {
  let result = (api-get $&amp;quot;&amp;#x2F;core&amp;#x2F;v1&amp;#x2F;domains&amp;#x2F;($domain)&amp;#x2F;records&amp;quot;)
  $result.records? | default []
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That return value is a real nushell table, which means a user can pipe it through anything else nushell knows about:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nu&quot; class=&quot;language-nu &quot;&gt;&lt;code class=&quot;language-nu&quot; data-lang=&quot;nu&quot;&gt;namecom list jobl.dev | where type == &amp;quot;A&amp;quot;
namecom list jobl.dev | sort-by host | select host type answer
namecom list jobl.dev | where answer == &amp;quot;1.2.3.4&amp;quot; | get id
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I didn&#x27;t write the &lt;code&gt;where&lt;&#x2F;code&gt;, &lt;code&gt;sort-by&lt;&#x2F;code&gt;, or &lt;code&gt;select&lt;&#x2F;code&gt; plumbing. Nushell brought it for free because the data shape is structured.&lt;&#x2F;p&gt;
&lt;p&gt;In bash you&#x27;d write all that as &lt;code&gt;jq&lt;&#x2F;code&gt; filters per call. The user has to know jq syntax. The errors come out as jq errors instead of shell errors. And every script that consumes the output has to reparse JSON. Here, the consumer is already in the language.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;authentication-without-leaking-the-token-to-ps&quot;&gt;Authentication without leaking the token to ps&lt;&#x2F;h2&gt;
&lt;p&gt;name.com&#x27;s API uses HTTP Basic Auth. The obvious thing is to pass the credentials to curl with &lt;code&gt;-u username:token&lt;&#x2F;code&gt;. The problem is that on shared hosts (or anywhere with multiple users), &lt;code&gt;ps aux&lt;&#x2F;code&gt; shows every running process&#x27;s command line including arguments. The token shows up in plain text every time the tool runs.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is to build the Authorization header explicitly and pass it via &lt;code&gt;--headers&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nu&quot; class=&quot;language-nu &quot;&gt;&lt;code class=&quot;language-nu&quot; data-lang=&quot;nu&quot;&gt;export def auth-header [] {
  let creds = (load-credentials)
  let token = ($&amp;quot;($creds.username):($creds.token)&amp;quot; | encode base64)
  $&amp;quot;Basic ($token)&amp;quot;
}

export def api-get [path: string] {
  let url = $&amp;quot;(base-url)($path)&amp;quot;
  http get --headers [Authorization (auth-header)] $url
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two notes on this. First, nushell ships an &lt;code&gt;http get&lt;&#x2F;code&gt; builtin: no curl call needed, no separate Content-Type parsing, no manual JSON decode. The response comes back as a parsed record automatically. Second, &lt;code&gt;encode base64&lt;&#x2F;code&gt; is a builtin pipe stage, not a separate &lt;code&gt;base64&lt;&#x2F;code&gt; invocation. The whole auth helper is six lines and reads top-to-bottom.&lt;&#x2F;p&gt;
&lt;p&gt;The bash equivalent involves either &lt;code&gt;curl -H &quot;Authorization: Basic $(echo -n &quot;$user:$tok&quot; | base64)&quot;&lt;&#x2F;code&gt; (works, looks awful, hard to test independently) or factoring it into a helper function that&#x27;s still doing the same thing with more ceremony.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;toml-is-parsed-for-free&quot;&gt;TOML is parsed for free&lt;&#x2F;h2&gt;
&lt;p&gt;Credentials live in &lt;code&gt;~&#x2F;.config&#x2F;namecom&#x2F;credentials.toml&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;username = &amp;quot;your-namecom-username&amp;quot;
token    = &amp;quot;your-api-token&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Loading them is literally:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nu&quot; class=&quot;language-nu &quot;&gt;&lt;code class=&quot;language-nu&quot; data-lang=&quot;nu&quot;&gt;export def load-credentials [] {
  let creds = (open $path)
  if ($creds.username? | is-empty) or ($creds.token? | is-empty) {
    error make { msg: $&amp;quot;credentials file ($path) is missing username or token&amp;quot; }
  }
  $creds
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;open&lt;&#x2F;code&gt; looks at the extension, picks a parser, returns a record. No &lt;code&gt;toml-cli&lt;&#x2F;code&gt; install, no &lt;code&gt;yq -p toml&lt;&#x2F;code&gt;, no hand-rolled regex. The whole loader is the file&#x27;s existence check plus a sanity check on the keys.&lt;&#x2F;p&gt;
&lt;p&gt;I learned the hard way that this works for TOML but not for a bare &lt;code&gt;key = &quot;value&quot;&lt;&#x2F;code&gt; text file with no extension. Nushell&#x27;s &lt;code&gt;open&lt;&#x2F;code&gt; returns the raw string in that case. (Initial version of the tool used a custom format; first run of &lt;code&gt;namecom login&lt;&#x2F;code&gt; immediately broke because &lt;code&gt;creds.username&lt;&#x2F;code&gt; was being called on a string. Renaming the file to &lt;code&gt;.toml&lt;&#x2F;code&gt; was the fix.)&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-doesn-t-go-well-in-nushell&quot;&gt;What doesn&#x27;t go well in nushell&lt;&#x2F;h2&gt;
&lt;p&gt;A few rough edges worth flagging if you&#x27;re thinking about doing something similar:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;External CLIs need the &lt;code&gt;^&lt;&#x2F;code&gt; prefix.&lt;&#x2F;strong&gt; I needed &lt;code&gt;chmod&lt;&#x2F;code&gt; to set 0600 perms on the credentials file. &lt;code&gt;chmod 600 $path&lt;&#x2F;code&gt; doesn&#x27;t work because nushell looks for a builtin. The fix is &lt;code&gt;^chmod 600 $path&lt;&#x2F;code&gt; so it shells out. Easy once you know, surprising the first time.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No package manager that anyone uses.&lt;&#x2F;strong&gt; &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;nushell&#x2F;nupm&quot;&gt;nupm&lt;&#x2F;a&gt; exists but is explicitly experimental. Distribution is &quot;git clone, symlink the script onto PATH.&quot; That&#x27;s fine for personal tools but a real friction point for anything you want strangers to install.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Error messages from inside the &lt;code&gt;http&lt;&#x2F;code&gt; family can be cryptic.&lt;&#x2F;strong&gt; A misformatted credentials file produced &quot;Data cannot be accessed with a cell path&quot; because the parsed &lt;code&gt;creds&lt;&#x2F;code&gt; was a string instead of a record. The fix was obvious in hindsight but the message didn&#x27;t point at the cause.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;login-is-a-misnomer-but-a-useful-one&quot;&gt;Login is a misnomer, but a useful one&lt;&#x2F;h2&gt;
&lt;p&gt;name.com doesn&#x27;t expose a programmatic token-mint endpoint. Tokens are created in the user&#x27;s web dashboard. So &lt;code&gt;namecom login&lt;&#x2F;code&gt; can&#x27;t actually fetch a token. What it does is point you at the right page (optionally with &lt;code&gt;xdg-open&lt;&#x2F;code&gt;), prompt for the token (with input suppression), write the file with 0600 perms, and validate it works by calling &lt;code&gt;&#x2F;core&#x2F;v1&#x2F;hello&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s not OAuth. But the difference between &quot;log in&quot; and &quot;edit a file in your config directory with the right name and permissions&quot; is real. Calling the command &lt;code&gt;login&lt;&#x2F;code&gt; reads correctly even though the implementation is honest about what&#x27;s possible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-actually-useful-thing&quot;&gt;The actually-useful thing&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;m using this from pond-nix&#x27;s deploy flow now. When I add a new Caddy vhost, I can &lt;code&gt;namecom add&lt;&#x2F;code&gt; the matching DNS record in one command instead of opening a browser. It&#x27;s not a big tool, but the friction cost of adding new things to the VPS dropped from &quot;context-switch into the browser&quot; to &quot;one extra shell line.&quot; That&#x27;s the win.&lt;&#x2F;p&gt;
&lt;p&gt;If you want it: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;namecom-cli&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;namecom-cli&lt;&#x2F;a&gt;. ~150 lines of nushell, MIT-licensed, no warranty, file an issue if name.com&#x27;s API shape drifts.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Auditing a Squarespace 7.0 site before migrating to 7.1</title>
          <pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/auditing-a-squarespace-site-before-migrating/</link>
          <guid>https://jakegoldsborough.com/blog/2026/auditing-a-squarespace-site-before-migrating/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/auditing-a-squarespace-site-before-migrating/">&lt;p&gt;A local non-profit put out a call for volunteers looking for a &quot;front-end web developer consultant&quot; for their Squarespace site, and I answered it. The presenting problem: they want to migrate from Squarespace 7.0 to 7.1 and figure out what to do about their members-only login area.&lt;&#x2F;p&gt;
&lt;p&gt;Before any migration conversation, I wanted a real inventory of what&#x27;s actually on the site. Squarespace&#x27;s &lt;a href=&quot;https:&#x2F;&#x2F;support.squarespace.com&#x2F;hc&#x2F;en-us&#x2F;articles&#x2F;360038270572-Moving-from-Squarespace-version-7-0-to-version-7-1&quot;&gt;update tool&lt;&#x2F;a&gt; has documented limitations: a 100-page cap, no support for album pages, video blocks on gallery pages don&#x27;t carry over, certain templates are ineligible. The tool itself will tell you if your site is ineligible when you try to run it. What it won&#x27;t tell you is &lt;em&gt;why&lt;&#x2F;em&gt;, &lt;em&gt;by how much&lt;&#x2F;em&gt;, or &lt;em&gt;which pages are the problem&lt;&#x2F;em&gt; in a form you can hand to a non-technical decision-maker.&lt;&#x2F;p&gt;
&lt;p&gt;So I wrote a small Python script that does.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-trick-format-json&quot;&gt;The trick: &lt;code&gt;?format=json&lt;&#x2F;code&gt;&lt;&#x2F;h2&gt;
&lt;p&gt;Every Squarespace page accepts a &lt;code&gt;?format=json&lt;&#x2F;code&gt; (or &lt;code&gt;?format=json-pretty&lt;&#x2F;code&gt;) query parameter that returns a structured representation of the page. It&#x27;s &lt;a href=&quot;https:&#x2F;&#x2F;developers.squarespace.com&#x2F;view-json-data&quot;&gt;documented on Squarespace&#x27;s developer portal&lt;&#x2F;a&gt;, used internally by their iOS editor, and has been stable for years.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;curl -sS &amp;quot;https:&amp;#x2F;&amp;#x2F;www.portlandbonsai.org&amp;#x2F;resources?format=json&amp;quot; | jq .
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You get 15 top-level keys per page. The interesting ones:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;website&lt;&#x2F;code&gt; - site-wide identity (title, tagline, domain, language, social accounts)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;websiteSettings&lt;&#x2F;code&gt; - configuration (business type, country, contact info)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;collection&lt;&#x2F;code&gt; - the page itself (title, URL, page type, item count, updated date)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;item&lt;&#x2F;code&gt; - populated only when the URL is an individual entry inside a collection (a single blog post, product, event item, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;mainContent&lt;&#x2F;code&gt; - the rendered HTML body of the page&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;template&lt;&#x2F;code&gt; - which template the site is on&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That &lt;code&gt;item&lt;&#x2F;code&gt; field is what makes the audit tractable. Squarespace&#x27;s 100-page cap only counts &lt;em&gt;container&lt;&#x2F;em&gt; pages: layout pages, blog pages, gallery pages, store pages, event calendar pages. The individual items inside those collections (the blog posts, the products, the event entries) don&#x27;t count. If you treat every URL in the sitemap as a &quot;page,&quot; you&#x27;ll wildly overcount and start fights that don&#x27;t need to happen.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-the-audit-checks&quot;&gt;What the audit checks&lt;&#x2F;h2&gt;
&lt;p&gt;I encoded the documented limitations from Squarespace&#x27;s update tool into a script:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Check&lt;&#x2F;th&gt;&lt;th&gt;Why it matters&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Container vs item count&lt;&#x2F;td&gt;&lt;td&gt;Update tool refuses sites with more than 100 containers&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Album pages&lt;&#x2F;td&gt;&lt;td&gt;Deleted by the update&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Gallery pages&lt;&#x2F;td&gt;&lt;td&gt;Convert to layout pages; embedded videos are lost&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Share buttons&lt;&#x2F;td&gt;&lt;td&gt;Not supported in 7.1&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Custom Adobe Typekit fonts&lt;&#x2F;td&gt;&lt;td&gt;Not supported in 7.1&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Products per store page&lt;&#x2F;td&gt;&lt;td&gt;Cap of 200 per store&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The script crawls &lt;code&gt;sitemap.xml&lt;&#x2F;code&gt;, hits &lt;code&gt;?format=json&lt;&#x2F;code&gt; for every URL, separates containers from items, and groups findings by severity (block &#x2F; warn &#x2F; info). Output is a markdown report you can read top to bottom.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-found-on-the-real-site&quot;&gt;What it found on the real site&lt;&#x2F;h2&gt;
&lt;p&gt;The site I was auditing has a public face of maybe 30 pages. The sitemap lists 489 URLs. After separating containers from items and tag&#x2F;category views, the real eligibility count is &lt;strong&gt;126&lt;&#x2F;strong&gt;, which is 26 over the cap.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Eligibility count
- Container pages (count toward 100-page cap): 126
- Individual items (excluded from cap):        121
- Filter&amp;#x2F;tag views (excluded from cap):        243
- ❌ OVER the 100-page eligibility cap by 26.

Containers by type
- page:     76
- gallery:  45
- products: 3
- blog:     1
- events:   1
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The story isn&#x27;t the blog. The blog has one container page and 65 posts; none of that is a problem for the cap. The story is &lt;strong&gt;45 gallery pages&lt;&#x2F;strong&gt; built up over a decade of convention photos, fall show photos, monthly meeting photos. Each gallery is a container, each one counts, and each one is a candidate for the &quot;embedded videos won&#x27;t survive&quot; warning. Galleries plus the 76 plain pages already put the site over the cap before you even look at the rest.&lt;&#x2F;p&gt;
&lt;p&gt;This is the kind of detail you can&#x27;t get from a casual click-through. The webmaster knew the blog was big but hadn&#x27;t connected the gallery archive to the update tool&#x27;s cap.&lt;&#x2F;p&gt;
&lt;p&gt;Without this audit, the conversation would have been:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;We&#x27;ll run the update tool when you&#x27;re ready.&quot;
&quot;The update tool refused.&quot;
&quot;Oh.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Instead, the conversation becomes:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;You&#x27;re 26 pages over the eligibility cap. Most of the slack is in 45 gallery pages, many of which are show photos from 2016-2019 that probably don&#x27;t need to stay live. Here are three ways to handle it.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That&#x27;s the kind of pre-work that actually moves a project forward.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-script&quot;&gt;The script&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s a single Python file, no dependencies beyond the stdlib. Crawls the public sitemap, fetches each page&#x27;s JSON, runs the checks, emits markdown.&lt;&#x2F;p&gt;
&lt;p&gt;The full source is in a gist: &lt;a href=&quot;https:&#x2F;&#x2F;gist.github.com&#x2F;ducks&#x2F;cc216cff323f572830487cad7acd87be&quot;&gt;sqsp-preflight.py&lt;&#x2F;a&gt;. About 400 lines. No setup. Run it as:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;python3 sqsp-preflight.py https:&amp;#x2F;&amp;#x2F;example.com &amp;gt; report.md
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;For a 30-page site it finishes in seconds. For a 489-URL site it takes a couple of minutes (one HTTP request per URL, no parallelism, good enough).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-doesn-t-do&quot;&gt;What it doesn&#x27;t do&lt;&#x2F;h2&gt;
&lt;p&gt;A few things the audit can&#x27;t see:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Members-only content.&lt;&#x2F;strong&gt; The crawler is unauthenticated. If a site has a member area with extra pages behind login, they&#x27;re invisible.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Developer mode usage.&lt;&#x2F;strong&gt; Only the admin knows whether this is on.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Discontinued template detection.&lt;&#x2F;strong&gt; The &lt;code&gt;template&lt;&#x2F;code&gt; field in the JSON is sparse. Cross-referencing against Squarespace&#x27;s current supported-template list still needs a human eyeball.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Visual fidelity in 7.1.&lt;&#x2F;strong&gt; No tool can predict whether a 7.0 layout will look right in 7.1&#x27;s Fluid Engine. That&#x27;s why the update tool has a preview mode.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Sidebar navigation.&lt;&#x2F;strong&gt; I couldn&#x27;t find a reliable signal in the JSON for this one.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The script&#x27;s job is to surface the &lt;em&gt;known unknowns&lt;&#x2F;em&gt; fast enough that the human can focus on the genuine judgment calls.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-is-and-isn-t&quot;&gt;What this is and isn&#x27;t&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s not a migration tool. Squarespace&#x27;s update tool is the migration tool. This is a &lt;strong&gt;preflight check&lt;&#x2F;strong&gt; that runs before you commit, surfaces the specific pages that drive eligibility, and produces a report a non-technical decision-maker can read. The update tool will tell you &quot;no&quot;; this tells you why and what to fix first.&lt;&#x2F;p&gt;
&lt;p&gt;It also isn&#x27;t a project. I&#x27;m not going to maintain this as a package. Squarespace can change the JSON shape at any time and break it; the maintenance commitment isn&#x27;t worth it. If you hit the same problem, fork the gist, adapt it to your site, throw it away after.&lt;&#x2F;p&gt;
&lt;p&gt;The actually-publishable thing here is the &lt;em&gt;approach&lt;&#x2F;em&gt;: use &lt;code&gt;?format=json&lt;&#x2F;code&gt; to inventory the site, separate containers from items the way the update tool does, encode the documented limitations as checks, produce a markdown report. Anyone reading this should be able to recreate the script in an evening.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-bigger-lesson&quot;&gt;The bigger lesson&lt;&#x2F;h2&gt;
&lt;p&gt;Squarespace&#x27;s docs tell you the limitations. The update tool tells you whether you qualify. Neither one hands you the punch list of &lt;em&gt;which pages to deal with, in what order, and why&lt;&#x2F;em&gt;. That gap shows up over and over with hosted platforms: the docs describe the system, the tools enforce the rules, but nothing produces an actionable report against your specific site.&lt;&#x2F;p&gt;
&lt;p&gt;For any platform migration on any hosted product, the first useful piece of work isn&#x27;t &quot;start the migration.&quot; It&#x27;s &quot;audit the source against the destination&#x27;s known constraints, in a form a human can act on.&quot; Sometimes that takes a script. Often a CLI plus an afternoon does it.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building a Travel Itinerary Plugin Inside Discourse</title>
          <pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/building-discourse-itinerary/</link>
          <guid>https://jakegoldsborough.com/blog/2026/building-discourse-itinerary/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/building-discourse-itinerary/">&lt;p&gt;I built a Discourse plugin that turns a category of topics into a chronological
travel itinerary. One topic per trip, then a flight topic, a hotel topic, a
note topic, all linked back to the trip. The plugin renders them on &#x2F;itinerary
as a clean list and per-trip timeline grouped by day.&lt;&#x2F;p&gt;
&lt;p&gt;You can see the result here:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-itinerary&quot;&gt;discourse-itinerary on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-discourse-for-this&quot;&gt;Why Discourse for This?&lt;&#x2F;h2&gt;
&lt;p&gt;Same reason I keep doing this: Discourse topics are extremely flexible, and
seeing how far I can push them keeps teaching me the platform. A trip is just
a topic. A flight is just a topic. The relationship between them is a custom
field pointing at the parent trip. Everything else (permissions, search,
edit history, who posted what, comments) comes for free.&lt;&#x2F;p&gt;
&lt;p&gt;That was the bet anyway. Reality had some opinions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-data-model&quot;&gt;The Data Model&lt;&#x2F;h2&gt;
&lt;p&gt;After a couple of rewrites, the model settled at:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Category&lt;&#x2F;strong&gt; = &quot;Itinerary&quot;. One per forum. Auto-created by the plugin on
first boot, with a configurable site setting if the admin wants to point
it at a different category.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Topic with &lt;code&gt;itinerary_item_type = trip&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt; = a trip (the container).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Other itinerary topics&lt;&#x2F;strong&gt; = items (flight, train, hotel, event,
transfer, note) that point at their parent trip via
&lt;code&gt;itinerary_parent_trip_id&lt;&#x2F;code&gt; on the topic&#x27;s custom_fields.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Earlier versions used a tag (&lt;code&gt;itinerary&lt;&#x2F;code&gt;) to mark which topics belonged to
the plugin. That got dropped once &lt;code&gt;itinerary_item_type&lt;&#x2F;code&gt; existed. The tag was
load-bearing nostalgia, not real structure.&lt;&#x2F;p&gt;
&lt;p&gt;All metadata lives in &lt;code&gt;topic_custom_fields&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Field&lt;&#x2F;th&gt;&lt;th&gt;Type&lt;&#x2F;th&gt;&lt;th&gt;Example&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_item_type&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;flight&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_parent_trip_id&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;integer&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;123&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_starts_at&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2026-09-20T14:30&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_ends_at&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;2026-09-21T09:15&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_origin&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;PDX&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_destination&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;MAD&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_location&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;Madrid&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_confirmation_code&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;ABC123&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;itinerary_status&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;string&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;booked&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Timestamps are stored as ISO-8601 strings, not DateTime. Lexical sorting
on the raw column does the right thing for &lt;code&gt;YYYY-MM-DDTHH:MM&lt;&#x2F;code&gt;, which means
I never have to parse them on the database side.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-plugin-api-trapdoors&quot;&gt;The Plugin API Trapdoors&lt;&#x2F;h2&gt;
&lt;p&gt;Discourse plugins have a deceptively friendly API. You write a &lt;code&gt;plugin.rb&lt;&#x2F;code&gt;,
drop some assets under &lt;code&gt;assets&#x2F;javascripts&#x2F;discourse&#x2F;&lt;&#x2F;code&gt;, and things mostly
work. Until they don&#x27;t, and you have to figure out why.&lt;&#x2F;p&gt;
&lt;p&gt;Here are the ones that actually stopped me cold.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-route-map-filename-convention&quot;&gt;The route-map filename convention&lt;&#x2F;h3&gt;
&lt;p&gt;To register Ember client routes from a plugin, you put a file named
&lt;code&gt;&amp;lt;plugin-slug&amp;gt;-route-map.js&lt;&#x2F;code&gt; in &lt;code&gt;assets&#x2F;javascripts&#x2F;discourse&#x2F;&lt;&#x2F;code&gt;. Discourse
scans &lt;code&gt;requirejs.entries&lt;&#x2F;code&gt; for anything matching &lt;code&gt;&#x2F;route-map$&#x2F;&lt;&#x2F;code&gt; and invokes
its default export to build out the route tree.&lt;&#x2F;p&gt;
&lt;p&gt;I named the file correctly. The file loaded. The composer connector loaded.
The route map itself never ran.&lt;&#x2F;p&gt;
&lt;p&gt;Turns out Ember resolves the actual route modules by name. With this
route map:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;js&quot; class=&quot;language-js &quot;&gt;&lt;code class=&quot;language-js&quot; data-lang=&quot;js&quot;&gt;export default function () {
  this.route(&amp;quot;itinerary&amp;quot;, function () {
    this.route(&amp;quot;show&amp;quot;, { path: &amp;quot;&amp;#x2F;:trip_id&amp;quot; });
  });
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Ember will look for &lt;code&gt;routes&#x2F;itinerary&#x2F;index.js&lt;&#x2F;code&gt; and &lt;code&gt;routes&#x2F;itinerary&#x2F;show.js&lt;&#x2F;code&gt;.
Not &lt;code&gt;routes&#x2F;itinerary-index.js&lt;&#x2F;code&gt; or &lt;code&gt;routes&#x2F;itinerary-show.js&lt;&#x2F;code&gt;, which is what
I had originally. The dot in the route name becomes a path separator. I had
copied the route map from discourse-cakeday but missed the nested directory
convention for the route files.&lt;&#x2F;p&gt;
&lt;p&gt;You don&#x27;t get an error for this. The route map just silently doesn&#x27;t match
anything client-side and the URL falls through to Rails.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;rails-has-to-serve-the-html-for-an-ember-route&quot;&gt;Rails has to serve the HTML for an Ember route&lt;&#x2F;h3&gt;
&lt;p&gt;This was the big one. After fixing the route file paths, &lt;code&gt;&#x2F;itinerary&lt;&#x2F;code&gt; still
404&#x27;d. The error came from Rails, not Ember. The plugin had JSON routes:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;Discourse::Application.routes.append do
  get &amp;quot;&amp;#x2F;itinerary&amp;#x2F;trips&amp;quot; =&amp;gt; &amp;quot;itinerary#index&amp;quot;,
      defaults: { format: :json },
      constraints: { format: :json }
  get &amp;quot;&amp;#x2F;itinerary&amp;#x2F;trips&amp;#x2F;:id&amp;quot; =&amp;gt; &amp;quot;itinerary#show&amp;quot;,
      defaults: { format: :json },
      constraints: { format: :json, id: &amp;#x2F;\d+&amp;#x2F; }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But none of those match a browser request for &lt;code&gt;GET &#x2F;itinerary&lt;&#x2F;code&gt; (which wants
HTML). Rails 404s, the browser never gets the Discourse app shell, Ember
never loads, the route map never runs.&lt;&#x2F;p&gt;
&lt;p&gt;discourse-cakeday works because it mounts a full Rails Engine at &lt;code&gt;&#x2F;cakeday&lt;&#x2F;code&gt;,
which catches the HTML request and serves the SPA bootstrap. Discourse
plugins like discourse-invite-stats use the simpler pattern:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;get &amp;quot;&amp;#x2F;itinerary&amp;quot; =&amp;gt; &amp;quot;itinerary#page&amp;quot;, constraints: { format: :html }
get &amp;quot;&amp;#x2F;itinerary&amp;#x2F;*path&amp;quot; =&amp;gt; &amp;quot;itinerary#page&amp;quot;, constraints: { format: :html }
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With an action that just renders the empty layout:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def page
  render &amp;quot;default&amp;#x2F;empty&amp;quot;
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The empty layout is the SPA bootstrap. Ember takes over from there.&lt;&#x2F;p&gt;
&lt;p&gt;I burned a couple of hours on this before searching for it. Sometimes the
fastest way to debug a plugin is to read three other plugins that do the
same thing.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;composer-fields-and-glimmer-autotracking&quot;&gt;Composer fields and Glimmer autotracking&lt;&#x2F;h3&gt;
&lt;p&gt;The composer panel for itinerary fields shows different inputs depending on
the item type. Flight has origin and destination, hotel has location, note
has neither. The conditional rendering was driven by getters:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;js&quot; class=&quot;language-js &quot;&gt;&lt;code class=&quot;language-js&quot; data-lang=&quot;js&quot;&gt;get itemType() {
  return this.composer.itinerary_item_type;
}

get showsRoute() {
  return [&amp;quot;flight&amp;quot;, &amp;quot;train&amp;quot;, &amp;quot;transfer&amp;quot;].includes(this.itemType);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This silently broke. The user could pick &quot;Flight&quot; from the dropdown, but
the route inputs never appeared. The dropdown&#x27;s change handler was firing.
The composer model was being updated. But the template never re-rendered.&lt;&#x2F;p&gt;
&lt;p&gt;The reason: &lt;code&gt;this.composer.set(&quot;itinerary_item_type&quot;, value)&lt;&#x2F;code&gt; doesn&#x27;t go
through a &lt;code&gt;@tracked&lt;&#x2F;code&gt; property. Glimmer&#x27;s autotracking can&#x27;t follow
&lt;code&gt;composer.set(...)&lt;&#x2F;code&gt; on non-tracked composer fields. The getter reads from
the composer correctly the first time, then becomes a dead read.&lt;&#x2F;p&gt;
&lt;p&gt;Fix:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;js&quot; class=&quot;language-js &quot;&gt;&lt;code class=&quot;language-js&quot; data-lang=&quot;js&quot;&gt;@tracked itemType;

@action
setItemType(e) {
  this.itemType = e.target.value || null;
  this.composer.set(&amp;quot;itinerary_item_type&amp;quot;, this.itemType);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Keep a local &lt;code&gt;@tracked&lt;&#x2F;code&gt; copy of anything you read from the composer model
in conditional getters. The composer side stays in sync via the
&lt;code&gt;.set()&lt;&#x2F;code&gt; call.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;datetime-local-silently-drops-half-filled-values&quot;&gt;datetime-local silently drops half-filled values&lt;&#x2F;h3&gt;
&lt;p&gt;I had separate inputs for &quot;Starts at&quot; and &quot;Ends at&quot; with &lt;code&gt;type=&quot;datetime-local&quot;&lt;&#x2F;code&gt;.
The user creates a flight, types a date, doesn&#x27;t bother with the time,
clicks save. The flight saves. The trip page shows &quot;No items in this trip
yet.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The flight was actually saved, just without &lt;code&gt;itinerary_starts_at&lt;&#x2F;code&gt;. The
TripItemFinder filters out items without a start time, so the flight
existed in the DB but never reached the timeline.&lt;&#x2F;p&gt;
&lt;p&gt;Why? &lt;code&gt;&amp;lt;input type=&quot;datetime-local&quot;&amp;gt;&lt;&#x2F;code&gt; doesn&#x27;t fire &lt;code&gt;input&lt;&#x2F;code&gt; or &lt;code&gt;change&lt;&#x2F;code&gt; until
both the date and time portions are populated. A half-filled value is
&quot;invalid&quot; to the browser, and you get nothing.&lt;&#x2F;p&gt;
&lt;p&gt;I split the field into a &lt;code&gt;&amp;lt;input type=&quot;date&quot;&amp;gt;&lt;&#x2F;code&gt; plus an optional
&lt;code&gt;&amp;lt;input type=&quot;time&quot;&amp;gt;&lt;&#x2F;code&gt;. The composer combines them into the stored format
(&lt;code&gt;YYYY-MM-DD&lt;&#x2F;code&gt; if just date, &lt;code&gt;YYYY-MM-DDTHH:MM&lt;&#x2F;code&gt; if both). The timeline
component already knew how to render both formats.&lt;&#x2F;p&gt;
&lt;p&gt;Two inputs, one stored field, no silent data loss.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;topics-need-a-title-that-discourse-will-accept&quot;&gt;Topics need a title that Discourse will accept&lt;&#x2F;h3&gt;
&lt;p&gt;Once the composer worked, I hit the wall I&#x27;d been ignoring: Discourse&#x27;s
title length validator requires 15 characters minimum, plus a &quot;title seems
unclear&quot; heuristic that rejects titles like &quot;Flight&quot; or &quot;Lisbon&quot;. For a
travel itinerary where the &quot;data&quot; is in custom fields, making the user
invent a meaningful 15-char title for every flight is hostile.&lt;&#x2F;p&gt;
&lt;p&gt;Discourse doesn&#x27;t have per-category overrides for &lt;code&gt;min_topic_title_length&lt;&#x2F;code&gt;.
The validator reads the global site setting, full stop. I considered three
options:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Lower the global. Affects every topic on the forum.&lt;&#x2F;li&gt;
&lt;li&gt;Monkey-patch the validator to skip checks for itinerary topics. Fragile.&lt;&#x2F;li&gt;
&lt;li&gt;Synthesize the title from the structured fields the user is already
filling in.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Option 3 is the move. The composer already collects item type, origin,
destination, location. Build the title from those and the user never has
to type one.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;js&quot; class=&quot;language-js &quot;&gt;&lt;code class=&quot;language-js&quot; data-lang=&quot;js&quot;&gt;synthesizeTitle() {
  const type = this.itemType;
  if (!type || type === &amp;quot;trip&amp;quot;) return;

  const cap = (s) =&amp;gt; s.charAt(0).toUpperCase() + s.slice(1);
  const o = this.composer.itinerary_origin;
  const d = this.composer.itinerary_destination;
  const loc = this.composer.itinerary_location;

  let title = cap(type);
  if ([&amp;quot;flight&amp;quot;, &amp;quot;train&amp;quot;, &amp;quot;transfer&amp;quot;].includes(type) &amp;amp;&amp;amp; (o || d)) {
    title = `${cap(type)}: ${o || &amp;quot;?&amp;quot;} -&amp;gt; ${d || &amp;quot;?&amp;quot;}`;
  } else if (loc) {
    title = `${cap(type)}: ${loc}`;
  }
  this.composer.set(&amp;quot;title&amp;quot;, title);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Trip topics still take the user&#x27;s title (you name a trip meaningfully;
&quot;European trip 2026&quot; is more useful than &quot;Trip&quot;). Items get derived titles:
&quot;Flight: PDX -&amp;gt; MAD&quot;, &quot;Hotel: Madrid Marriott&quot;, &quot;Event: Symphony&quot;.&lt;&#x2F;p&gt;
&lt;p&gt;I also hide the title input via a body class when the composer is in
itinerary-item mode, so users don&#x27;t see a phantom input that gets
overwritten on every field change.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-other-stuff&quot;&gt;The Other Stuff&lt;&#x2F;h2&gt;
&lt;p&gt;A few smaller things that made the plugin feel like a real tool.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;auto-creating-the-category&quot;&gt;Auto-creating the category&lt;&#x2F;h3&gt;
&lt;p&gt;The plugin provisions an &quot;Itinerary&quot; category on first boot. The slug-first
lookup means if an admin already has a category called &quot;itinerary&quot; or
&quot;ITINERARY&quot;, we adopt it instead of creating a duplicate (which would fail
uniqueness anyway). The provisioner also adds the category id to
&lt;code&gt;default_categories_muted&lt;&#x2F;code&gt; so new users don&#x27;t see itinerary topics
cluttering &#x2F;latest.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;sidebar-link&quot;&gt;Sidebar link&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse&#x27;s &lt;code&gt;api.addCommunitySectionLink&lt;&#x2F;code&gt; adds a link to the sidebar&#x27;s
Community section. With the category muted by default, the sidebar link
becomes the canonical entrypoint. No URL guessing required.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;add-buttons&quot;&gt;Add buttons&lt;&#x2F;h3&gt;
&lt;p&gt;Two big buttons: &quot;+ Add trip&quot; on &#x2F;itinerary, &quot;+ Add leg&quot; on
&#x2F;itinerary&#x2F;:trip_id. Each opens the standard composer pre-scoped to the
itinerary category, with the right &lt;code&gt;itinerary_item_type&lt;&#x2F;code&gt; (for trip) or
&lt;code&gt;itinerary_parent_trip_id&lt;&#x2F;code&gt; (for leg) seeded on the composer model after the
modal opens. The composer panel&#x27;s conditional rendering picks up the
seeded values and shows the right fields.&lt;&#x2F;p&gt;
&lt;p&gt;This pattern is straight from discourse-kanban: rather than build a custom
modal, lean on Discourse&#x27;s existing composer with sensible defaults. You
get rich text, drafts, file uploads, all the auth machinery, for free.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;screenshots&quot;&gt;Screenshots&lt;&#x2F;h2&gt;
&lt;p&gt;Coming once I&#x27;ve populated my own itinerary further. I want the screenshots
to show realistic data rather than the &quot;Flight to madrid for meetup&quot; test
post that&#x27;s currently in my dev instance.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Plugins built on topics keep paying off.&lt;&#x2F;strong&gt; Search, permissions, edit
history, mentions, attachments. None of it is plugin code. It&#x27;s all
Discourse, and I get it by default by storing data in topics.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The plugin API has trapdoors.&lt;&#x2F;strong&gt; Most of them aren&#x27;t documented because
nobody hits them until they hit them. Reading three working plugins
side-by-side is faster than reading docs.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Glimmer autotracking is invisible until it isn&#x27;t.&lt;&#x2F;strong&gt; If a getter reads a
non-&lt;code&gt;@tracked&lt;&#x2F;code&gt; value and you&#x27;re surprised it doesn&#x27;t re-render, that&#x27;s the
likely cause. Always.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Synthesize what you can.&lt;&#x2F;strong&gt; The user shouldn&#x27;t have to type a topic title
when the structured fields already encode it. Hide the title input, derive
it, move on.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Per-category validation overrides would be great.&lt;&#x2F;strong&gt; Discourse already
has site-wide setting overrides per category for things like default tags
and minimum trust levels. Extending the per-category override surface to
title and post length validators would let plugins like this stop fighting
the global validators. Filed in the back of my brain for a future
contribution.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try It&lt;&#x2F;h2&gt;
&lt;p&gt;The plugin is open source:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-itinerary&quot;&gt;ducks&#x2F;discourse-itinerary&lt;&#x2F;a&gt;.
Symlink it into your &lt;code&gt;plugins&#x2F;&lt;&#x2F;code&gt; directory, restart Rails, visit
&#x2F;itinerary, and you&#x27;ve got a travel timeline backed entirely by Discourse
topics.&lt;&#x2F;p&gt;
&lt;p&gt;Now I just need to actually go on a trip.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building a Screenshot Pipeline for Discourse Plugins</title>
          <pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/building-discourse-plugin-screenshots/</link>
          <guid>https://jakegoldsborough.com/blog/2026/building-discourse-plugin-screenshots/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/building-discourse-plugin-screenshots/">&lt;p&gt;I built &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-plugin-screenshots&quot;&gt;discourse-plugin-screenshots on
GitHub&lt;&#x2F;a&gt;, a small tool
that captures Discourse plugin UIs against a real Discourse instance in CI
and publishes the resulting PNGs to GitHub Pages.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s modeled on &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;pmusaraj&#x2F;discourse-theme-screenshots&quot;&gt;Penar Musaraj&#x27;s
discourse-theme-screenshots&lt;&#x2F;a&gt;,
which screenshots themes against a battery of routes. Same idea, different
target: instead of &quot;does this theme break the latest topic view,&quot; it asks
&quot;does my plugin&#x27;s UI still render correctly in current Discourse.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Live example for &lt;code&gt;discourse-itinerary&lt;&#x2F;code&gt;, the plugin I dogfooded it on:
&lt;a href=&quot;https:&#x2F;&#x2F;ducks.github.io&#x2F;discourse-itinerary&#x2F;&quot;&gt;https:&#x2F;&#x2F;ducks.github.io&#x2F;discourse-itinerary&#x2F;&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why&quot;&gt;Why?&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;d been hand-screenshotting &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-itinerary&quot;&gt;discourse-itinerary&lt;&#x2F;a&gt;
for &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;building-discourse-itinerary&#x2F;&quot;&gt;its blog post&lt;&#x2F;a&gt; and realized
the same problem applies to every plugin I might write or maintain:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Plugins break in unexpected ways when Discourse core changes. A CSS class
gets renamed and your plugin&#x27;s panel suddenly looks weird.&lt;&#x2F;li&gt;
&lt;li&gt;A new theme like Horizon ships and your plugin&#x27;s overrides don&#x27;t quite
match anymore.&lt;&#x2F;li&gt;
&lt;li&gt;A plugin API method deprecates and silently degrades.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;You usually find out from a user filing a screenshot. That&#x27;s a slow loop.&lt;&#x2F;p&gt;
&lt;p&gt;What if every plugin had a nightly or per-push screenshot run that posted
its current state to a public page? Visual regressions become visible.
&quot;Here&#x27;s what my plugin looks like today&quot; becomes a free byproduct of
shipping.&lt;&#x2F;p&gt;
&lt;p&gt;Penar&#x27;s tool already does this for themes. Plugins were the X-shaped hole
next to it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-architecture&quot;&gt;The architecture&lt;&#x2F;h2&gt;
&lt;p&gt;The tool ships a &lt;strong&gt;reusable GitHub Actions workflow&lt;&#x2F;strong&gt;. Plugins opt in by
adding ~12 lines of YAML to their own &lt;code&gt;.github&#x2F;workflows&#x2F;&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;name: Screenshots
on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  screenshots:
    uses: ducks&amp;#x2F;discourse-plugin-screenshots&amp;#x2F;.github&amp;#x2F;workflows&amp;#x2F;plugin-screenshots.yml@main
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s it. Each plugin maintainer owns their schedule, their gallery, their
CI minutes. The tool repo doesn&#x27;t enumerate plugins, doesn&#x27;t centrally
batch them, doesn&#x27;t pay for anyone else&#x27;s runs. Same pattern Discourse
already uses for its own
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;.github&#x2F;blob&#x2F;main&#x2F;.github&#x2F;workflows&#x2F;discourse-plugin.yml&quot;&gt;&lt;code&gt;discourse-plugin.yml&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;
reusable workflow.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-a-plugin-describes-what-to-capture&quot;&gt;How a plugin describes what to capture&lt;&#x2F;h2&gt;
&lt;p&gt;A plugin opts in by shipping &lt;code&gt;config&#x2F;screenshots.yml&lt;&#x2F;code&gt; at the repo root:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;seed: spec&amp;#x2F;screenshot_seed.rb

urls:
  - path: &amp;#x2F;itinerary
    name: trip-list
    description: Trip list home page
  - path: &amp;#x2F;itinerary&amp;#x2F;2
    name: timeline
    description: Per-trip timeline with day grouping
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The optional &lt;code&gt;seed&lt;&#x2F;code&gt; is a Ruby file run inside the test env before captures
fire. Without it, &lt;code&gt;&#x2F;itinerary&lt;&#x2F;code&gt; would render an empty state (&quot;No trips
yet&quot;), which makes for sad screenshots. The seed creates a few realistic
trips with legs so the pages have something to render.&lt;&#x2F;p&gt;
&lt;p&gt;The seed file is plain Ruby that runs inside Discourse&#x27;s Rails test
environment. It can use &lt;code&gt;PostCreator&lt;&#x2F;code&gt;, &lt;code&gt;SiteSetting&lt;&#x2F;code&gt;, plugin-specific
classes, whatever. Plugin-specific concerns stay in the plugin.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-workflow&quot;&gt;The workflow&lt;&#x2F;h2&gt;
&lt;p&gt;The reusable workflow is the heart of it. Slightly simplified:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Spin up &lt;code&gt;discourse&#x2F;discourse_test:release&lt;&#x2F;code&gt; (Discourse&#x27;s official test
container, comes with system libs Playwright needs).&lt;&#x2F;li&gt;
&lt;li&gt;Check out the calling plugin, this tool, and &lt;code&gt;discourse&#x2F;discourse&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Symlink the plugin into &lt;code&gt;discourse&#x2F;plugins&#x2F;&amp;lt;id&amp;gt;&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Boot Postgres + Redis, migrate the test DB.&lt;&#x2F;li&gt;
&lt;li&gt;Install Playwright Chromium.&lt;&#x2F;li&gt;
&lt;li&gt;Run a Capybara system spec that reads the plugin&#x27;s manifest, runs the
seed file, visits each URL, and saves a PNG.&lt;&#x2F;li&gt;
&lt;li&gt;Upload &lt;code&gt;public&#x2F;&lt;&#x2F;code&gt; to the calling repo&#x27;s GitHub Pages.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Total runtime: ~5-10 minutes after the initial container pull.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;first-green-run&quot;&gt;First green run&lt;&#x2F;h2&gt;
&lt;p&gt;I wired the workflow into the itinerary plugin&#x27;s CI, pushed, and the
first run came back green. Screenshots up at
&lt;a href=&quot;https:&#x2F;&#x2F;ducks.github.io&#x2F;discourse-itinerary&#x2F;&quot;&gt;ducks.github.io&#x2F;discourse-itinerary&lt;&#x2F;a&gt;,
captured automatically on every push to main.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the whole loop. Plugin author writes
&lt;code&gt;config&#x2F;screenshots.yml&lt;&#x2F;code&gt;, drops a reusable-workflow file in
&lt;code&gt;.github&#x2F;workflows&#x2F;&lt;&#x2F;code&gt;, enables Pages, pushes. Five minutes later there&#x27;s
a public gallery of what their plugin looks like in current Discourse.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-rabbit-holes&quot;&gt;The rabbit holes&lt;&#x2F;h2&gt;
&lt;p&gt;Building it taught me three things about the Discourse-on-NixOS stack
that I wouldn&#x27;t have learned otherwise.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;playwright-s-chromium-doesn-t-run-on-nixos&quot;&gt;Playwright&#x27;s Chromium doesn&#x27;t run on NixOS&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse uses Playwright for system specs. The first time I tried the
tool locally, the Capybara driver tried to launch Chromium and got:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;error while loading shared libraries: libglib-2.0.so.0: cannot open
shared object file: No such file or directory
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Playwright&#x27;s &lt;code&gt;pnpm playwright install&lt;&#x2F;code&gt; downloads a generic Linux Chromium
build to &lt;code&gt;~&#x2F;.cache&#x2F;ms-playwright&lt;&#x2F;code&gt;. That binary expects to find shared libs
at standard &lt;code&gt;&#x2F;usr&#x2F;lib&lt;&#x2F;code&gt; paths. NixOS, famously, doesn&#x27;t have a
&lt;code&gt;&#x2F;usr&#x2F;lib&lt;&#x2F;code&gt;. Everything is in &lt;code&gt;&#x2F;nix&#x2F;store&#x2F;&amp;lt;hash&amp;gt;-glib-2.x.y&#x2F;lib&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Two paths to fix this:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;LD_LIBRARY_PATH&lt;&#x2F;code&gt; gymnastics. Add every needed lib to a nix-shell.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;nixpkgs.playwright-driver.browsers&lt;&#x2F;code&gt;, a package that pre-patches the
Chromium binary with NixOS-compatible RPATHs.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;I went with option 2:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;{ pkgs ? import &amp;lt;nixpkgs&amp;gt; {} }:
pkgs.mkShell {
  buildInputs = [ pkgs.playwright-driver.browsers ];
  shellHook = &amp;#x27;&amp;#x27;
    export PLAYWRIGHT_BROWSERS_PATH=&amp;quot;${pkgs.playwright-driver.browsers}&amp;quot;
    export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
  &amp;#x27;&amp;#x27;;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This got me past the lib errors and into a new one: my nixpkgs channel
ships &lt;code&gt;chromium-1169&lt;&#x2F;code&gt; but Discourse&#x27;s Playwright gem pins
&lt;code&gt;chromium-1217&lt;&#x2F;code&gt;. The right fix is a flake pin or &lt;code&gt;nixpkgs-unstable&lt;&#x2F;code&gt;
override, which I haven&#x27;t shaved yet.&lt;&#x2F;p&gt;
&lt;p&gt;So locally on NixOS the tool runs up to &quot;launching Chromium&quot; before
hitting the version mismatch. In CI it sails through, because
&lt;code&gt;discourse&#x2F;discourse_test:release&lt;&#x2F;code&gt; is a regular Linux image with the
matching system libs and Playwright downloads the right version. &lt;strong&gt;The
same code runs in both places. CI is currently the only one that
succeeds end-to-end on my machine.&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;That ended up being a useful property. The CI is the authoritative loop:
real container, real Discourse, real Playwright, public gallery at the
end. Local is a developer convenience that I can keep chipping at without
blocking the rest of the tool from being useful.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;rspec-accepts-absolute-spec-paths-from-outside-the-project&quot;&gt;Rspec accepts absolute spec paths from outside the project&lt;&#x2F;h3&gt;
&lt;p&gt;I was worried that running Discourse&#x27;s &lt;code&gt;bin&#x2F;rspec&lt;&#x2F;code&gt; against a spec file
that lives outside the Discourse repo wouldn&#x27;t work. &lt;code&gt;rails_helper.rb&lt;&#x2F;code&gt;
wouldn&#x27;t be picked up automatically, Capybara&#x27;s drivers wouldn&#x27;t be
registered, etc.&lt;&#x2F;p&gt;
&lt;p&gt;Turns out: it works fine. RSpec loads &lt;code&gt;rails_helper&lt;&#x2F;code&gt; via the spec file&#x27;s
own &lt;code&gt;require&lt;&#x2F;code&gt;, and Discourse&#x27;s &lt;code&gt;bin&#x2F;rspec&lt;&#x2F;code&gt; boots Rails normally
regardless of the spec&#x27;s path. The spec just needs &lt;code&gt;require &quot;rails_helper&quot;&lt;&#x2F;code&gt; at the top.&lt;&#x2F;p&gt;
&lt;p&gt;This let me keep the spec in this tool&#x27;s repo rather than vendoring it
into every plugin or asking Discourse to ship it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;reusable-workflows-scale-better-than-central-orchestration&quot;&gt;Reusable workflows scale better than central orchestration&lt;&#x2F;h3&gt;
&lt;p&gt;The first version of this had a central &lt;code&gt;config&#x2F;plugins.yml&lt;&#x2F;code&gt; listing
every plugin, and a single CI run that captured all of them. Penar&#x27;s
theme tool works that way for good reasons: themes are a relatively small
set and they&#x27;re meant to be compared side-by-side.&lt;&#x2F;p&gt;
&lt;p&gt;For plugins it doesn&#x27;t work. If you add 20 plugins, the central run
becomes a 4-hour job that fails halfway and has to retry the whole batch.
You also push all the CI cost onto whoever maintains the tool repo.&lt;&#x2F;p&gt;
&lt;p&gt;Reusable workflows flip both. Each plugin&#x27;s CI runs only when that plugin
changes. The tool repo&#x27;s job is to provide the reusable workflow, not to
enumerate consumers.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-missing&quot;&gt;What&#x27;s missing&lt;&#x2F;h2&gt;
&lt;p&gt;This is a v0. Real things it doesn&#x27;t do yet:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multiple themes.&lt;&#x2F;strong&gt; Right now it captures against the default theme.
Capturing against Horizon, a dark theme, and the default is the obvious
next step. Penar&#x27;s tool already does this; mostly a matter of porting
the theme-loading code.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Multiple viewports.&lt;&#x2F;strong&gt; Just 1440x900 today. Mobile (375x667) would be
the big one to add.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Visual diffing.&lt;&#x2F;strong&gt; &quot;Here&#x27;s what changed since last run&quot; via image
diff. Tools like reg-suit or Percy do this; expensive at scale.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Auto-discovery of routes.&lt;&#x2F;strong&gt; Right now you list URLs in
&lt;code&gt;config&#x2F;screenshots.yml&lt;&#x2F;code&gt;. Could introspect the plugin&#x27;s
&lt;code&gt;route-map.js&lt;&#x2F;code&gt; for routes, but dynamic segments (&lt;code&gt;:trip_id&lt;&#x2F;code&gt;) need
hints either way.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;NixOS local parity.&lt;&#x2F;strong&gt; The chromium-1217 pin dance is genuinely the
last thing blocking local runs.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;sh&quot; class=&quot;language-sh &quot;&gt;&lt;code class=&quot;language-sh&quot; data-lang=&quot;sh&quot;&gt;# In your plugin repo, create config&amp;#x2F;screenshots.yml with your URLs.
# Then drop this in .github&amp;#x2F;workflows&amp;#x2F;screenshots.yml:

name: Screenshots
on:
  push:
    branches: [main]
  workflow_dispatch:
permissions:
  contents: read
  pages: write
  id-token: write
jobs:
  screenshots:
    uses: ducks&amp;#x2F;discourse-plugin-screenshots&amp;#x2F;.github&amp;#x2F;workflows&amp;#x2F;plugin-screenshots.yml@main
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Enable GitHub Pages on the plugin repo (Settings -&amp;gt; Pages -&amp;gt; Source:
GitHub Actions), push to main, and after a few minutes your screenshots
will be at &lt;code&gt;https:&#x2F;&#x2F;&amp;lt;you&amp;gt;.github.io&#x2F;&amp;lt;plugin&amp;gt;&#x2F;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The tool is open source:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-plugin-screenshots&quot;&gt;ducks&#x2F;discourse-plugin-screenshots&lt;&#x2F;a&gt;.
Pull requests for theme + viewport + diff support welcome.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>When patchelf stopped working on Claude Code</title>
          <pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/patchelf-broke-claude-code/</link>
          <guid>https://jakegoldsborough.com/blog/2026/patchelf-broke-claude-code/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/patchelf-broke-claude-code/">&lt;p&gt;A couple weeks ago I wrote about &lt;a href=&quot;https:&#x2F;&#x2F;jakegoldsborough.com&#x2F;blog&#x2F;2026&#x2F;running-claude-code-on-nixos&#x2F;&quot;&gt;running Claude Code on NixOS&lt;&#x2F;a&gt; with a 133-line &lt;code&gt;shell.nix&lt;&#x2F;code&gt; that patchelf-modifies the binary on install. It worked great until last week, when Claude Code&#x27;s auto-updater dropped version 2.1.143 into &lt;code&gt;~&#x2F;.local&#x2F;share&#x2F;claude&#x2F;versions&#x2F;&lt;&#x2F;code&gt; and &lt;code&gt;claude --version&lt;&#x2F;code&gt; started segfaulting before printing anything.&lt;&#x2F;p&gt;
&lt;p&gt;What follows is the debugging story. I went down three wrong paths before finding the real cause, and the right fix turned out to have two more gotchas hiding inside it. The patchelf approach is dead. Here&#x27;s why.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-symptom&quot;&gt;The symptom&lt;&#x2F;h2&gt;
&lt;pre&gt;&lt;code&gt;$ claude --version
Segmentation fault (core dumped)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;coredumpctl&lt;&#x2F;code&gt; showed the crash inside the dynamic linker itself, in &lt;code&gt;_dl_check_map_versions&lt;&#x2F;code&gt; at &lt;code&gt;glibc&#x2F;elf&#x2F;dl-version.c:263&lt;&#x2F;code&gt;, before a single byte of program code had run:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;#0  _dl_check_map_versions       at dl-version.c:263
#1  _dl_check_all_versions       at dl-version.c:398
#2  version_check_doit           at rtld.c:676
#3  _dl_receive_error            at dl-catch.c:194
#4  dl_main                      at rtld.c:2028
...
#8  _start ()                    from &amp;#x2F;lib64&amp;#x2F;ld-linux-x86-64.so.2
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The previous version (2.1.132) worked fine. 2.1.143 didn&#x27;t. Same &lt;code&gt;patch_binary&lt;&#x2F;code&gt; invocation, same Nix linker, same rpath. The binary was patched correctly in the sense that &lt;code&gt;patchelf --print-interpreter&lt;&#x2F;code&gt; reported the Nix glibc linker path.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;wrong-hypothesis-1-the-auto-updater-dropped-an-unpatched-binary&quot;&gt;Wrong hypothesis #1: the auto-updater dropped an unpatched binary&lt;&#x2F;h2&gt;
&lt;p&gt;My first guess was that Claude&#x27;s auto-updater had bypassed the install-time patchelf step. I extended &lt;code&gt;shell.nix&lt;&#x2F;code&gt; with a wrapper that auto-patchelfs any unpatched binary in the versions directory before exec&#x27;ing it.&lt;&#x2F;p&gt;
&lt;p&gt;This was the wrong fix to the wrong problem. The binary was already patched; &lt;code&gt;patchelf --print-interpreter&lt;&#x2F;code&gt; confirmed it. The wrapper was a useful-feeling defensive layer but it wasn&#x27;t relevant to the actual bug.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;wrong-hypothesis-2-bun-s-rust-rewrite-shipped-malformed-elf&quot;&gt;Wrong hypothesis #2: Bun&#x27;s Rust rewrite shipped malformed ELF&lt;&#x2F;h2&gt;
&lt;p&gt;Claude Code is built with &lt;code&gt;bun build --compile&lt;&#x2F;code&gt;, which embeds the Bun runtime inside the standalone executable. There had been &lt;a href=&quot;https:&#x2F;&#x2F;www.theregister.com&#x2F;devops&#x2F;2026&#x2F;05&#x2F;14&#x2F;anthropics-bun-rust-rewrite-merged-at-speed-of-ai&#x2F;5240381&quot;&gt;recent news&lt;&#x2F;a&gt; about Anthropic merging a Rust rewrite of Bun, and an &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;oven-sh&#x2F;bun&#x2F;issues&#x2F;30717&quot;&gt;open bun issue&lt;&#x2F;a&gt; about regressions in the &lt;code&gt;--compile&lt;&#x2F;code&gt; path. The dates lined up: Bun v1.3.14 shipped May 13, the Rust rewrite PR merged May 14, my broken Claude Code was downloaded May 18.&lt;&#x2F;p&gt;
&lt;p&gt;I had a hypothesis, the timing fit, and I was off to the races. Built a clean Docker repro on stock Ubuntu 24.04 showing the same &lt;code&gt;_dl_check_map_versions&lt;&#x2F;code&gt; crash outside NixOS. Started drafting a bug report against &lt;code&gt;oven-sh&#x2F;bun&lt;&#x2F;code&gt;. Compared &lt;code&gt;readelf --version-info&lt;&#x2F;code&gt; between the working and broken binaries and found errors:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;$ readelf --version-info &amp;#x2F;tmp&amp;#x2F;claude-2.1.143
readelf: Error: Reading 20 bytes extends past end of file for version def
readelf: Error: Reading 20 bytes extends past end of file for version def
...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The narrative was perfect. Bun&#x27;s new Rust ELF emitter was producing corrupt version tables, the dynamic linker was crashing trying to read them, and I was about to file a high-quality upstream bug.&lt;&#x2F;p&gt;
&lt;p&gt;Then I checked the most basic fact, which I should have checked at the start:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;$ strings &amp;#x2F;tmp&amp;#x2F;claude-2.1.143 | grep &amp;#x27;^Bun v&amp;#x27;
Bun v1.3.14 (d2989145)

$ strings &amp;#x2F;tmp&amp;#x2F;claude-2.1.132 | grep &amp;#x27;^Bun v&amp;#x27;
Bun v1.3.14 (0a466a11)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Both binaries embed Bun v1.3.14.&lt;&#x2F;strong&gt; The Rust rewrite hasn&#x27;t shipped in any tagged release yet; it&#x27;s only on canary. Different build commits, same version. The Rust theory was dead.&lt;&#x2F;p&gt;
&lt;p&gt;Lesson: when the story fits perfectly, do the cheapest disconfirming test first.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;wrong-hypothesis-3-claude-code-s-build-pipeline-regressed&quot;&gt;Wrong hypothesis #3: Claude Code&#x27;s build pipeline regressed&lt;&#x2F;h2&gt;
&lt;p&gt;If it wasn&#x27;t Bun, it had to be something on Anthropic&#x27;s side. I bisected across every published version in the range:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;for v in 2.1.132 2.1.133 2.1.136 2.1.137 2.1.138 2.1.139 2.1.140 2.1.141 2.1.142 2.1.143; do
  curl -fsSL -o &amp;quot;&amp;#x2F;tmp&amp;#x2F;bisect&amp;#x2F;$v&amp;quot; \
    &amp;quot;https:&amp;#x2F;&amp;#x2F;downloads.claude.ai&amp;#x2F;claude-code-releases&amp;#x2F;$v&amp;#x2F;linux-x64&amp;#x2F;claude&amp;quot;
  docker run --rm -v &amp;quot;&amp;#x2F;tmp&amp;#x2F;bisect&amp;#x2F;$v:&amp;#x2F;c:ro&amp;quot; ubuntu:24.04 &amp;#x2F;c --version
done
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every freshly-downloaded binary ran cleanly. &lt;strong&gt;All of them.&lt;&#x2F;strong&gt; Including 2.1.143.&lt;&#x2F;p&gt;
&lt;p&gt;The crash I&#x27;d been reproducing was on my locally-modified copy in &lt;code&gt;&#x2F;tmp&#x2F;claude-test&lt;&#x2F;code&gt;, not on a pristine upstream binary. I&#x27;d convinced myself I was testing the official binary because the path looked right. I was actually testing the result of my own earlier patchelf modification.&lt;&#x2F;p&gt;
&lt;p&gt;This is where the actual cause finally became visible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-real-cause&quot;&gt;The real cause&lt;&#x2F;h2&gt;
&lt;p&gt;Diff the program headers of 2.1.132 vs 2.1.133 with &lt;code&gt;readelf -l&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;2.1.132 RW LOAD:
  FileSiz=0x000e0028  (~916 KiB)
  MemSiz=0x001ee29c   (~1.93 MiB)

2.1.133 RW LOAD:
  FileSiz=0x07c17d20  (~124 MiB)
  MemSiz=0x07c17d20   (~124 MiB)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In 2.1.132, the RW LOAD segment&#x27;s &lt;code&gt;MemSiz&lt;&#x2F;code&gt; is larger than its &lt;code&gt;FileSiz&lt;&#x2F;code&gt;, which is a &lt;a href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;.bss&quot;&gt;BSS-style&lt;&#x2F;a&gt; zero-fill region that exists at runtime but takes no space on disk. In 2.1.133, &lt;code&gt;FileSiz&lt;&#x2F;code&gt; equals &lt;code&gt;MemSiz&lt;&#x2F;code&gt; and they&#x27;re both ~124 MiB. Bun changed its standalone-executable layout to bake the entire embedded JS payload onto disk in the RW segment rather than zero-filling.&lt;&#x2F;p&gt;
&lt;p&gt;Why does this matter for patchelf? When patchelf rewrites the interpreter string or rpath, the new string is often longer than what it replaces. patchelf needs slack in the file layout to insert it without breaking section offsets. In 2.1.132 there&#x27;s plenty of slack. From 2.1.133 onward there isn&#x27;t, so patchelf&#x27;s modifications end up shifting the &lt;code&gt;Elf64_Verneed&lt;&#x2F;code&gt; and related version tables to file offsets where the loader walks off the end of the section.&lt;&#x2F;p&gt;
&lt;p&gt;The crash in &lt;code&gt;_dl_check_map_versions&lt;&#x2F;code&gt; is the loader trying to dereference a &lt;code&gt;vna_name&lt;&#x2F;code&gt; pointer that now points into garbage memory.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The pristine binaries are fine.&lt;&#x2F;strong&gt; They run unmodified on NixOS as long as you give them a real dynamic linker. It&#x27;s only the patchelf-modified ones that crash. patchelf has been silently producing broken binaries on the new layout the entire time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;&#x2F;h2&gt;
&lt;p&gt;Stop modifying the binary. Invoke the Nix dynamic linker directly with the unmodified binary as its argument:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;exec &amp;#x2F;nix&amp;#x2F;store&amp;#x2F;...&amp;#x2F;ld-linux-x86-64.so.2 \
  --library-path &amp;#x2F;nix&amp;#x2F;store&amp;#x2F;...&amp;#x2F;lib:&amp;#x2F;nix&amp;#x2F;store&amp;#x2F;...&amp;#x2F;lib:... \
  &amp;#x2F;home&amp;#x2F;ducks&amp;#x2F;.local&amp;#x2F;share&amp;#x2F;claude&amp;#x2F;versions&amp;#x2F;2.1.143 &amp;quot;$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is what &lt;code&gt;nix run&lt;&#x2F;code&gt; and &lt;code&gt;nix-ld&lt;&#x2F;code&gt; do under the hood. The kernel only chokes when it tries to follow the binary&#x27;s &lt;em&gt;embedded&lt;&#x2F;em&gt; interpreter path (&lt;code&gt;&#x2F;lib64&#x2F;ld-linux-x86-64.so.2&lt;&#x2F;code&gt;, which doesn&#x27;t exist on NixOS). If you invoke a working linker yourself and pass the binary as an argument, the kernel never looks at the embedded path. The linker loads the binary as a regular dynamic object using the library paths you give it. No on-disk modification, no patchelf, no shifted section offsets.&lt;&#x2F;p&gt;
&lt;p&gt;I rewrote &lt;code&gt;claude-nixos&#x2F;shell.nix&lt;&#x2F;code&gt; to drop patchelf entirely. The wrapper at &lt;code&gt;~&#x2F;.local&#x2F;bin&#x2F;claude&lt;&#x2F;code&gt; now picks the highest installed version, then exec&#x27;s the linker with the unmodified binary. Pristine binaries are re-downloaded from &lt;code&gt;downloads.claude.ai&lt;&#x2F;code&gt; to overwrite the patchelf damage on existing installs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;gotcha-1-ld-library-path-leaks-to-children&quot;&gt;Gotcha #1: LD_LIBRARY_PATH leaks to children&lt;&#x2F;h2&gt;
&lt;p&gt;My first attempt at the wrapper exported &lt;code&gt;LD_LIBRARY_PATH&lt;&#x2F;code&gt; and called the linker:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;export LD_LIBRARY_PATH=&amp;quot;$NIX_LIBS&amp;quot;
exec &amp;quot;$LINKER&amp;quot; &amp;quot;$BIN&amp;quot; &amp;quot;$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This works for Claude itself. But Claude spawns Bash subprocesses for tool calls (the Bash tool in particular), and those subprocesses inherit &lt;code&gt;LD_LIBRARY_PATH&lt;&#x2F;code&gt;. Bash then resolves &lt;em&gt;its own&lt;&#x2F;em&gt; shared libraries through our Nix paths, sometimes pulls in a mismatched version, and you get cryptic errors like:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;-G: error while loading shared libraries: -G: cannot open shared object file:
  No such file or directory
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That &lt;code&gt;-G&lt;&#x2F;code&gt; is &lt;code&gt;grep&lt;&#x2F;code&gt;&#x27;s &lt;code&gt;--basic-regexp&lt;&#x2F;code&gt; flag being mis-parsed as a shared object path by a confused loader. Lovely.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is to use &lt;code&gt;ld.so&lt;&#x2F;code&gt;&#x27;s &lt;code&gt;--library-path&lt;&#x2F;code&gt; flag instead of &lt;code&gt;LD_LIBRARY_PATH&lt;&#x2F;code&gt;. It&#x27;s consumed by the linker during this single invocation and is &lt;strong&gt;not&lt;&#x2F;strong&gt; propagated to child processes:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;exec &amp;quot;$LINKER&amp;quot; --library-path &amp;quot;$NIX_LIBS&amp;quot; &amp;quot;$BIN&amp;quot; &amp;quot;$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Child processes inherit a clean environment.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;gotcha-2-claude-code-is-a-multi-call-binary&quot;&gt;Gotcha #2: Claude Code is a multi-call binary&lt;&#x2F;h2&gt;
&lt;p&gt;Claude Code&#x27;s shell integration installs functions for &lt;code&gt;find&lt;&#x2F;code&gt;, &lt;code&gt;grep&lt;&#x2F;code&gt;, and &lt;code&gt;ugrep&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;find() {
  ...
  exec -a bfs &amp;quot;$_cc_bin&amp;quot; -S dfs -regextype findutils-default &amp;quot;$@&amp;quot;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;exec -a bfs&lt;&#x2F;code&gt; sets &lt;code&gt;argv[0]&lt;&#x2F;code&gt; to &lt;code&gt;bfs&lt;&#x2F;code&gt; and exec&#x27;s the Claude binary. The binary checks &lt;code&gt;argv[0]&lt;&#x2F;code&gt; and dispatches to embedded fast tools (&lt;code&gt;bfs&lt;&#x2F;code&gt; is a &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;tavianator&#x2F;bfs&quot;&gt;breadth-first find&lt;&#x2F;a&gt;, &lt;code&gt;ugrep&lt;&#x2F;code&gt; is a faster &lt;code&gt;grep&lt;&#x2F;code&gt;). Clever: one binary, many tools.&lt;&#x2F;p&gt;
&lt;p&gt;This breaks under the wrapper. When the shell function does &lt;code&gt;exec -a bfs ~&#x2F;.local&#x2F;bin&#x2F;claude ...&lt;&#x2F;code&gt;, my wrapper script runs with &lt;code&gt;$0&lt;&#x2F;code&gt; set to its own path (the kernel re-parses the shebang and loses the spoofed argv[0]). The wrapper then exec&#x27;s the linker, and the linker&#x27;s argv[0] is the linker path. The Claude binary sees an argv[0] of &lt;code&gt;&#x2F;nix&#x2F;store&#x2F;...&#x2F;ld-linux-x86-64.so.2&lt;&#x2F;code&gt;, doesn&#x27;t recognize it as a tool, and runs the default UI.&lt;&#x2F;p&gt;
&lt;p&gt;Newer glibc linkers support &lt;code&gt;--argv0 STRING&lt;&#x2F;code&gt; to set argv[0] explicitly:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;exec &amp;quot;$LINKER&amp;quot; --argv0 &amp;quot;$0&amp;quot; --library-path &amp;quot;$NIX_LIBS&amp;quot; &amp;quot;$BIN&amp;quot; &amp;quot;$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I started here and shipped this version, thinking it was a partial fix. It isn&#x27;t. &lt;code&gt;$0&lt;&#x2F;code&gt; inside a bash script is the script&#x27;s own path even when the script was invoked via &lt;code&gt;exec -a NAME&lt;&#x2F;code&gt;, because bash resets &lt;code&gt;$0&lt;&#x2F;code&gt; to the script name at startup. So &lt;code&gt;--argv0 &quot;$0&quot;&lt;&#x2F;code&gt; was always passing &lt;code&gt;&#x2F;home&#x2F;ducks&#x2F;.local&#x2F;bin&#x2F;claude&lt;&#x2F;code&gt;, never &lt;code&gt;bfs&lt;&#x2F;code&gt; or &lt;code&gt;ugrep&lt;&#x2F;code&gt;. Bash 5+ exposes the real invocation argv[0] via &lt;code&gt;$BASH_ARGV0&lt;&#x2F;code&gt;, which fixes it:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;exec &amp;quot;$LINKER&amp;quot; --argv0 &amp;quot;${BASH_ARGV0:-$0}&amp;quot; --library-path &amp;quot;$NIX_LIBS&amp;quot; &amp;quot;$BIN&amp;quot; &amp;quot;$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With that change the argv0 actually propagates. I verified by invoking it manually and watching the binary respond as &lt;code&gt;bfs&lt;&#x2F;code&gt; instead of as the default &lt;code&gt;claude&lt;&#x2F;code&gt; UI.&lt;&#x2F;p&gt;
&lt;p&gt;But the fast tools still didn&#x27;t work from inside a Claude session. Adding &lt;code&gt;set -x&lt;&#x2F;code&gt; to the shell-integration &lt;code&gt;find&lt;&#x2F;code&gt; function showed why:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;local _cc_bin=&amp;#x2F;nix&amp;#x2F;store&amp;#x2F;...&amp;#x2F;ld-linux-x86-64.so.2
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Claude&#x27;s harness sets &lt;code&gt;CLAUDE_CODE_EXECPATH&lt;&#x2F;code&gt; (the variable its shell integration uses to find itself) from &lt;code&gt;&#x2F;proc&#x2F;self&#x2F;exe&lt;&#x2F;code&gt;. Once the wrapper exec&#x27;s the dynamic linker, the kernel&#x27;s view of the process is &lt;em&gt;the linker&lt;&#x2F;em&gt;, not the Claude binary. &lt;code&gt;&#x2F;proc&#x2F;self&#x2F;exe&lt;&#x2F;code&gt; resolves to ld.so. Everything downstream that introspects the running process sees ld.so. The shell-integration &lt;code&gt;find&lt;&#x2F;code&gt; function then tries to exec &lt;code&gt;$CLAUDE_CODE_EXECPATH -regextype ...&lt;&#x2F;code&gt;, which is the linker treating &lt;code&gt;-regextype&lt;&#x2F;code&gt; as a library path. Boom.&lt;&#x2F;p&gt;
&lt;p&gt;This isn&#x27;t fixable in userspace. No bash trick can hide the fact that the kernel-level process identity is ld.so. &lt;code&gt;&#x2F;proc&#x2F;self&#x2F;exe&lt;&#x2F;code&gt; will never lie. The wrapper approach has a real ceiling here.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-real-fix-nix-ld&quot;&gt;The real fix: nix-ld&lt;&#x2F;h2&gt;
&lt;p&gt;The wrapper approach is fundamentally limited by what the kernel sees as the running process. &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Mic92&#x2F;nix-ld&quot;&gt;&lt;code&gt;programs.nix-ld&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; sidesteps the whole class of problem by installing a stub at &lt;code&gt;&#x2F;lib64&#x2F;ld-linux-x86-64.so.2&lt;&#x2F;code&gt;, which is the FHS path that generic Linux binaries hardcode as their ELF interpreter. The stub redirects to a real Nix linker with a global library list:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;programs.nix-ld = {
  enable = true;
  libraries = with pkgs; [
    stdenv.cc.cc
    zlib
    openssl
    xz
    sqlite
    curl
  ];
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With that in place, the kernel can load the pristine Claude binary directly. &lt;code&gt;&#x2F;proc&#x2F;self&#x2F;exe&lt;&#x2F;code&gt; is the real Claude binary, &lt;code&gt;argv[0]&lt;&#x2F;code&gt; is whatever the caller actually set, &lt;code&gt;CLAUDE_CODE_EXECPATH&lt;&#x2F;code&gt; resolves correctly, and the multi-call dispatch works.&lt;&#x2F;p&gt;
&lt;p&gt;The wrapper script collapses to its remaining job, picking the latest installed version:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;#!&amp;#x2F;usr&amp;#x2F;bin&amp;#x2F;env bash
set -e

VERSIONS_DIR=&amp;quot;$HOME&amp;#x2F;.local&amp;#x2F;share&amp;#x2F;claude&amp;#x2F;versions&amp;quot;

bin=&amp;quot;$(ls -1 &amp;quot;$VERSIONS_DIR&amp;quot; 2&amp;gt;&amp;#x2F;dev&amp;#x2F;null \
  | grep -E &amp;#x27;^[0-9]+\.[0-9]+\.[0-9]+&amp;#x27; \
  | sort -V \
  | tail -n1 \
  | awk -v d=&amp;quot;$VERSIONS_DIR&amp;quot; &amp;#x27;{print d&amp;quot;&amp;#x2F;&amp;quot;$0}&amp;#x27;)&amp;quot;

exec -a &amp;quot;${BASH_ARGV0:-$0}&amp;quot; &amp;quot;$bin&amp;quot; &amp;quot;$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No linker invocation. No &lt;code&gt;--library-path&lt;&#x2F;code&gt;. No &lt;code&gt;LD_LIBRARY_PATH&lt;&#x2F;code&gt; leak. nix-ld owns the runtime surface. The trade is that the library list is global to your system instead of per-shell: if you have two binaries that want different versions of the same library, you can only put one in the global list. For my single-vendored-binary case (Claude Code), this isn&#x27;t a real constraint.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;one-nix-string-escape-detour&quot;&gt;One Nix-string-escape detour&lt;&#x2F;h2&gt;
&lt;p&gt;Writing the new heredoc in &lt;code&gt;shell.nix&lt;&#x2F;code&gt;, I started with this:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;exec -a &amp;quot;\${BASH_ARGV0:-\$0}&amp;quot; &amp;quot;\$bin&amp;quot; &amp;quot;\$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;...and got a Nix parser error at column 25. Inside Nix double-single-quote strings (&lt;code&gt;&#x27;&#x27;...&#x27;&#x27;&lt;&#x2F;code&gt;), &lt;code&gt;\${&lt;&#x2F;code&gt; isn&#x27;t an escape: Nix tries to parse it as antiquotation and the backslash doesn&#x27;t help. The correct escape for a literal &lt;code&gt;${&lt;&#x2F;code&gt; inside &lt;code&gt;&#x27;&#x27;...&#x27;&#x27;&lt;&#x2F;code&gt; is &lt;code&gt;&#x27;&#x27;${&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;exec -a &amp;quot;&amp;#x27;&amp;#x27;${BASH_ARGV0:-\$0}&amp;quot; &amp;quot;\$bin&amp;quot; &amp;quot;\$@&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Single dollars work fine with &lt;code&gt;\$&lt;&#x2F;code&gt;. Only the brace form needs the double-apostrophe escape. Small thing but it took five minutes to figure out, and the error message (&lt;em&gt;&quot;unexpected invalid token&quot;&lt;&#x2F;em&gt;) doesn&#x27;t lead you to it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;honest-reflection&quot;&gt;Honest reflection&lt;&#x2F;h2&gt;
&lt;p&gt;Three wrong hypotheses, each more confident than the last. The Bun-Rust-rewrite story was particularly seductive: recent news, a public issue tracker showing related regressions, timing that lined up. I drafted most of a bug report before checking the one thing that would have killed the hypothesis: which Bun version is actually embedded?&lt;&#x2F;p&gt;
&lt;p&gt;When the narrative fits perfectly, that&#x27;s the moment to do the cheapest disconfirming test. Five seconds of &lt;code&gt;strings | grep Bun&lt;&#x2F;code&gt; would have saved time.&lt;&#x2F;p&gt;
&lt;p&gt;The same lesson came back to bite me at the end. I closed out the first version of this post with &quot;the wrapper is partially broken, I&#x27;m parking it&quot;, documenting the limitation but never actually testing whether it was a &lt;em&gt;limitation&lt;&#x2F;em&gt; or just an unfinished implementation. The &lt;code&gt;$0&lt;&#x2F;code&gt; vs &lt;code&gt;BASH_ARGV0&lt;&#x2F;code&gt; thing would have closed half the gap on day one. The other half (the &lt;code&gt;&#x2F;proc&#x2F;self&#x2F;exe&lt;&#x2F;code&gt; problem) took 20 minutes of &lt;code&gt;set -x&lt;&#x2F;code&gt; once I sat down with the right question. &quot;Good enough for now&quot; deserves the same disconfirming-test discipline as a fresh hypothesis.&lt;&#x2F;p&gt;
&lt;p&gt;The deeper lesson, twice over: modifying a binary to fit your OS only works until it doesn&#x27;t. The bridge-from-Nix-to-FHS pattern feels solid because patchelf is well-understood, but it depends on the source binary having properties (slack in the file layout, no exotic packaging) that the binary&#x27;s authors never promised you. Invoking the Nix linker directly against a pristine binary felt like the robust alternative, but it has its own ceiling: once &lt;em&gt;you&lt;&#x2F;em&gt; are ld.so, the rest of the world sees ld.so. nix-ld is one more step less clever than the linker-as-wrapper trick: instead of you running the linker, the kernel runs it for you, exactly the way it was designed to. The path of least cleverness wins.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;code&quot;&gt;Code&lt;&#x2F;h2&gt;
&lt;p&gt;The slimmed-down wrapper lives in &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;claude-nixos&quot;&gt;claude-nixos&lt;&#x2F;a&gt; on the &lt;code&gt;nix-ld&lt;&#x2F;code&gt; branch. With &lt;code&gt;programs.nix-ld.enable = true;&lt;&#x2F;code&gt; in your NixOS configuration and a reasonable &lt;code&gt;libraries&lt;&#x2F;code&gt; list, you don&#x27;t need a &lt;code&gt;nix-shell&lt;&#x2F;code&gt; to run Claude Code at all: the wrapper is just a &lt;code&gt;~&#x2F;.local&#x2F;bin&#x2F;claude&lt;&#x2F;code&gt; script that picks the latest installed version and exec&#x27;s it. If you don&#x27;t care about juggling multiple installed versions, you can replace the wrapper with a symlink and skip the script entirely.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Six numbers from my blog&#x27;s analytics</title>
          <pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/blog-stats-six-numbers/</link>
          <guid>https://jakegoldsborough.com/blog/2026/blog-stats-six-numbers/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/blog-stats-six-numbers/">&lt;p&gt;I recently noticed that the 1 year anniversary of my blog came and went. I was curious about how much traffic I got so I took a look.&lt;&#x2F;p&gt;
&lt;p&gt;I sshed into my VPS and pointed sqlite at the GoatCounter database that powers my analytics. Six numbers stood out.&lt;&#x2F;p&gt;
&lt;p&gt;For context: this blog gets modest traffic. Usually 20-50 unique visitors a day, mostly from search. I&#x27;m not actively trying to grow it, I just write. But the data is interesting because it shows what people actually read versus what I assumed they would.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;1-biggest-single-day-spike-33-hits&quot;&gt;1. Biggest single-day spike: 33 hits&lt;&#x2F;h2&gt;
&lt;p&gt;On 2026-05-11, the post &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;nix-shells-ssh-claude-code&#x2F;&quot;&gt;Nix shells, SSH, and Claude Code&lt;&#x2F;a&gt; got 33 visits in one day. That&#x27;s the single biggest day for any post I&#x27;ve published.&lt;&#x2F;p&gt;
&lt;p&gt;For comparison, the next biggest days were 25 (a January post called &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;letting-ai-pick-the-project&#x2F;&quot;&gt;Letting AI pick the project&lt;&#x2F;a&gt;), 19 (&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;how-a-friends-gif-broke-our-filesystem&#x2F;&quot;&gt;How a friend&#x27;s gif broke our filesystem&lt;&#x2F;a&gt;), and 13 (&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-with-claude&#x2F;&quot;&gt;Rewriting Claude Code in Rust&lt;&#x2F;a&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;The pattern is obvious in hindsight: every spike came from a post solving a specific problem someone was searching for. None of them are essays. None of them are opinions. They&#x27;re all &quot;here&#x27;s a weird thing I ran into and how I fixed it.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;This is the closest thing I have to &quot;going viral,&quot; and it&#x27;s about a 30-line shell.nix.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;2-fastest-rising-nixos-claude-code&quot;&gt;2. Fastest rising: NixOS + Claude Code&lt;&#x2F;h2&gt;
&lt;p&gt;In the last seven days versus the prior seven, the two biggest gainers are both about running Claude Code on NixOS:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Post&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Last 7d&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Prev 7d&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Delta&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;nix-shells-ssh-claude-code&#x2F;&quot;&gt;Nix shells, SSH, and Claude Code&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;49&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;10&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;+39&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;running-claude-code-on-nixos&#x2F;&quot;&gt;Running Claude Code on NixOS&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;34&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;9&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;+25&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;That&#x27;s probably not a coincidence. Anthropic must have changed something recently that caused more people to hit problems running Claude Code on NixOS, and search engines surface my posts as the fix.&lt;&#x2F;p&gt;
&lt;p&gt;The two posts also feed each other. Readers land on one, then click through to the other. Internal linking matters more than I expected.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;3-fastest-out-the-gate-88-hits-in-the-first-30-days&quot;&gt;3. Fastest out the gate: 88 hits in the first 30 days&lt;&#x2F;h2&gt;
&lt;p&gt;The previous numbers measure &quot;popular right now.&quot; A different question is &quot;which posts hit hardest in their first month after publishing?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The top five by views in the first 30 days after their publish date:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Post&lt;&#x2F;th&gt;&lt;th&gt;Published&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;First 30d&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-with-claude&#x2F;&quot;&gt;Rewriting Claude Code in Rust&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td&gt;2026-04-01&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;88&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;running-claude-code-on-nixos&#x2F;&quot;&gt;Running Claude Code on NixOS&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td&gt;2026-05-07&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;43&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;letting-ai-pick-the-project&#x2F;&quot;&gt;Letting AI pick the project&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td&gt;2026-01-19&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;37&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;skin-shedding-not-bikeshedding&#x2F;&quot;&gt;Skin shedding, not bikeshedding&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td&gt;2025-11-23&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-part-2&#x2F;&quot;&gt;Rewriting Claude Code in Rust (Part 2)&lt;&#x2F;a&gt;&lt;&#x2F;td&gt;&lt;td&gt;2026-04-02&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;29&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The Rust + Claude post is in its own tier. It got 47 of those 88 hits in the first 7 days. That&#x27;s almost certainly a referral spike from somewhere - Hacker News, Lobsters, a Mastodon boost, hard to say without per-day referrer data.&lt;&#x2F;p&gt;
&lt;p&gt;This is a different shape from the NixOS posts. The NixOS surge is search-driven and building over weeks. The Rust + Claude launch was a sharp burst that mostly came and went. Both are &quot;popular.&quot; Only one keeps compounding.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;4-longest-site-wide-streak-119-days&quot;&gt;4. Longest site-wide streak: 119 days&lt;&#x2F;h2&gt;
&lt;p&gt;Every single day from 2026-01-19 to today, at least one post on this blog has been read by someone. That&#x27;s 119 consecutive days without a gap.&lt;&#x2F;p&gt;
&lt;p&gt;The previous longest streak was 49 days. Before that, 29. Before that, 25.&lt;&#x2F;p&gt;
&lt;p&gt;This is what &quot;getting indexed&quot; actually looks like as a curve.&lt;&#x2F;p&gt;
&lt;p&gt;Before indexing, your blog gets read on days when you publish something or share a link somewhere. After indexing, it gets read every day, forever, because somebody somewhere is searching for a problem you wrote about.&lt;&#x2F;p&gt;
&lt;p&gt;That shift was the biggest surprise from all this data. The traffic doesn&#x27;t spike and disappear anymore. It compounds.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;5-longest-per-post-streak-32-days&quot;&gt;5. Longest per-post streak: 32 days&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;nix-shells-ssh-claude-code&#x2F;&quot;&gt;Nix shells, SSH, and Claude Code&lt;&#x2F;a&gt; was read every day from 2026-03-26 to 2026-04-26 - 32 consecutive days. That&#x27;s the longest streak any single post has had.&lt;&#x2F;p&gt;
&lt;p&gt;The next best are all under two weeks:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-with-claude&#x2F;&quot;&gt;Rewriting Claude Code in Rust&lt;&#x2F;a&gt;: 13 days (twice)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;hyprland-tricks-close-window-confirmation&#x2F;&quot;&gt;Hyprland tricks: close window confirmation&lt;&#x2F;a&gt;: 13 days&lt;&#x2F;li&gt;
&lt;li&gt;Same hyprland post again: 11 days, then 10, then 8, then 7&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The hyprland post is still the quiet workhorse of the blog. Published in 2025, it&#x27;s been read at least once on 146 different days. About 1.8 hits per day on average, every week, for nearly a year.&lt;&#x2F;p&gt;
&lt;p&gt;Nothing else comes close to that consistency.&lt;&#x2F;p&gt;
&lt;p&gt;If you can choose the problems you write about, choose ones that everybody eventually hits and that nobody else has documented clearly.&lt;&#x2F;p&gt;
&lt;p&gt;Length is uncorrelated with traffic.&lt;&#x2F;p&gt;
&lt;p&gt;Problem-specificity is.&lt;&#x2F;p&gt;
&lt;p&gt;The hyprland post is one paragraph and a screenshot. The nix-shells post is six paragraphs. Both outperform longer essays by a wide margin.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;6-where-readers-actually-come-from&quot;&gt;6. Where readers actually come from&lt;&#x2F;h2&gt;
&lt;p&gt;Top 10 referrers, last 90 days:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Source&lt;&#x2F;th&gt;&lt;th style=&quot;text-align: right&quot;&gt;Hits&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;(direct)&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;391&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Google&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;382&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;duckduckgo.com&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;45&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;jakegoldsborough.com&#x2F;blog&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;37&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&#x2F;tags&#x2F;nixos&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;33&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&#x2F;blog&#x2F;2026&#x2F;nix-shells-ssh-claude-code&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&#x2F;resume&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;32&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;jakegoldsborough.com&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;29&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;kagi.com&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;19&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Hacker News&lt;&#x2F;td&gt;&lt;td style=&quot;text-align: right&quot;&gt;14&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;A few things stood out immediately:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Direct and Google are basically tied.&lt;&#x2F;strong&gt; &quot;Direct&quot; includes users with stripped referrer headers, privacy-focused browsers, chat apps, bookmarks, and manually typed URLs. In practice, a lot of &quot;direct&quot; is probably search-driven too.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;DuckDuckGo (45) and Kagi (19) combined still trail Google by an order of magnitude.&lt;&#x2F;strong&gt; Kagi has a much smaller user base than Google, so this isn&#x27;t surprising, but it&#x27;s a useful reality check on how niche the alternative-search world still is.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Internal navigation matters.&lt;&#x2F;strong&gt; The &#x2F;blog index, &#x2F;tags&#x2F;nixos, and even &#x2F;resume page each drive 30+ clicks. Once someone lands on the site, they often keep exploring.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Hacker News isn&#x27;t the firehose people imagine.&lt;&#x2F;strong&gt; A front-page post probably changes the math, but a link in &#x2F;new creates a short spike and then vanishes quickly.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The more surprising pattern is that almost all long-term traffic comes from search and internal links, not social media.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I&#x27;d do differently&lt;&#x2F;h2&gt;
&lt;p&gt;Honestly? Nothing.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m not optimizing for traffic. But if I were, the data points toward one obvious strategy:&lt;&#x2F;p&gt;
&lt;p&gt;Write more posts documenting highly specific problems with highly specific solutions.&lt;&#x2F;p&gt;
&lt;p&gt;Not opinions.&lt;&#x2F;p&gt;
&lt;p&gt;Not giant tutorials covering everything.&lt;&#x2F;p&gt;
&lt;p&gt;Just the thing somebody searches for after they hit a wall at 1am.&lt;&#x2F;p&gt;
&lt;p&gt;Small technical posts outperform broad polished essays by a huge margin. At least on this blog.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-i-got-this-data&quot;&gt;How I got this data&lt;&#x2F;h2&gt;
&lt;p&gt;GoatCounter ships a SQLite database. I ssh into my VPS, run &lt;code&gt;nix-shell -p sqlite&lt;&#x2F;code&gt;, and query it directly.&lt;&#x2F;p&gt;
&lt;p&gt;The two most useful tables are:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;paths&lt;&#x2F;code&gt;: post URLs and titles&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;hit_counts&lt;&#x2F;code&gt;: hour-bucketed view counts joined by &lt;code&gt;path_id&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The streak queries use the standard SQL trick of subtracting a row number from a date. Rows with the same difference belong to the same consecutive run.&lt;&#x2F;p&gt;
&lt;p&gt;If you self-host GoatCounter, the same queries work on your data. If you don&#x27;t, the API exposes most of this through &lt;code&gt;&#x2F;api&#x2F;v0&#x2F;stats&#x2F;hits&lt;&#x2F;code&gt; and related endpoints.&lt;&#x2F;p&gt;
&lt;p&gt;The funny part is that after a year of blogging, the strongest signal wasn&#x27;t traffic volume.&lt;&#x2F;p&gt;
&lt;p&gt;It was consistency.&lt;&#x2F;p&gt;
&lt;p&gt;Once a post solves a real problem and gets indexed, it just keeps quietly working in the background forever.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>A &#x27;Was this helpful?&#x27; button for a static blog</title>
          <pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/was-this-helpful-button/</link>
          <guid>https://jakegoldsborough.com/blog/2026/was-this-helpful-button/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/was-this-helpful-button/">&lt;p&gt;I write a lot here. I don&#x27;t have comments. I don&#x27;t want comments. But I do want some signal about which posts are actually landing - not &quot;did someone load the page&quot; (GoatCounter already tells me that) but &quot;did this post help someone.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;So I added a small button at the bottom of every post:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Was this helpful?
[ Yep, this helped ]&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Click it once and it swaps to a thank-you line. Click is recorded as a GoatCounter event. No login, no comment box, no thumbs down. Per-browser dedupe via localStorage so the same person can&#x27;t pad the number by refreshing.&lt;&#x2F;p&gt;
&lt;p&gt;This post is how it works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;constraints&quot;&gt;Constraints&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;The site is a static Zola build. I don&#x27;t want a backend, a database, or auth.&lt;&#x2F;li&gt;
&lt;li&gt;I already use &lt;a href=&quot;https:&#x2F;&#x2F;www.goatcounter.com&#x2F;&quot;&gt;GoatCounter&lt;&#x2F;a&gt; for analytics. I don&#x27;t want a second analytics dependency.&lt;&#x2F;li&gt;
&lt;li&gt;It has to keep working with JavaScript off, in the sense of not being broken. The widget can be inert when JS is disabled, but the page shouldn&#x27;t render a button that lies about doing something.&lt;&#x2F;li&gt;
&lt;li&gt;No thumbs down. Negative feedback without a way to discuss it is just background noise.&lt;&#x2F;li&gt;
&lt;li&gt;Mobile-friendly. A pill-shaped button with a 44px tap target on small screens.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;the-shape&quot;&gt;The shape&lt;&#x2F;h2&gt;
&lt;p&gt;Three new files plus three small edits to existing templates.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;templates&amp;#x2F;macros&amp;#x2F;helpful.html      (new — Tera macro)
static&amp;#x2F;css&amp;#x2F;helpful.css             (new — scoped styles)
static&amp;#x2F;js&amp;#x2F;helpful.js               (new — ~25 lines)
templates&amp;#x2F;page.html                (import + render the macro)
templates&amp;#x2F;base.html                (load the JS)
static&amp;#x2F;css&amp;#x2F;style.css               (@import the CSS)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The whole feature is under 70 lines total.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;goatcounter-does-the-click&quot;&gt;GoatCounter does the click&lt;&#x2F;h2&gt;
&lt;p&gt;The first thing I learned reading &lt;a href=&quot;https:&#x2F;&#x2F;www.goatcounter.com&#x2F;help&#x2F;events&quot;&gt;the GoatCounter events docs&lt;&#x2F;a&gt;: you don&#x27;t have to write a click handler. Any element with a &lt;code&gt;data-goatcounter-click&lt;&#x2F;code&gt; attribute gets bound automatically. The attribute&#x27;s value is the event name; &lt;code&gt;data-goatcounter-title&lt;&#x2F;code&gt; sets a human-readable title.&lt;&#x2F;p&gt;
&lt;p&gt;So the Tera macro just stamps the button:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;jinja&quot; class=&quot;language-jinja &quot;&gt;&lt;code class=&quot;language-jinja&quot; data-lang=&quot;jinja&quot;&gt;{% macro widget(page) %}
  {% set slug = page.path
       | replace(from=&amp;quot;&amp;#x2F;&amp;quot;, to=&amp;quot;-&amp;quot;)
       | trim_start_matches(pat=&amp;quot;-&amp;quot;)
       | trim_end_matches(pat=&amp;quot;-&amp;quot;) %}
  {% set event_name = &amp;quot;helpful-&amp;quot; ~ slug %}
  &amp;lt;aside class=&amp;quot;helpful&amp;quot; data-helpful-path=&amp;quot;{{ page.path }}&amp;quot;&amp;gt;
    &amp;lt;p class=&amp;quot;helpful-prompt&amp;quot;&amp;gt;Was this helpful?&amp;lt;&amp;#x2F;p&amp;gt;
    &amp;lt;button
      type=&amp;quot;button&amp;quot;
      class=&amp;quot;helpful-button&amp;quot;
      data-helpful-action=&amp;quot;vote&amp;quot;
      data-goatcounter-click=&amp;quot;{{ event_name }}&amp;quot;
      data-goatcounter-title=&amp;quot;Helpful click on {{ slug }}&amp;quot;
    &amp;gt;Yep, this helped&amp;lt;&amp;#x2F;button&amp;gt;
    &amp;lt;p class=&amp;quot;helpful-thanks&amp;quot; hidden&amp;gt;✓ Glad it helped&amp;lt;&amp;#x2F;p&amp;gt;
  &amp;lt;&amp;#x2F;aside&amp;gt;
{% endmacro widget %}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The slug is the post path with slashes replaced by dashes and the leading&#x2F;trailing dashes trimmed. So &lt;code&gt;&#x2F;blog&#x2F;2026&#x2F;was-this-helpful-button&#x2F;&lt;&#x2F;code&gt; becomes &lt;code&gt;helpful-blog-2026-was-this-helpful-button&lt;&#x2F;code&gt;. Each post is its own row in GoatCounter&#x27;s event list.&lt;&#x2F;p&gt;
&lt;p&gt;One gotcha: GoatCounter rejects event paths that start with &lt;code&gt;&#x2F;&lt;&#x2F;code&gt; (the path doubles as the event name, and they reserve &lt;code&gt;&#x2F;&lt;&#x2F;code&gt; as a real-path marker). If you forget to strip the leading slash, your events silently don&#x27;t get recorded.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;javascript-handles-the-dedupe&quot;&gt;JavaScript handles the dedupe&lt;&#x2F;h2&gt;
&lt;p&gt;GoatCounter records the click. localStorage remembers it. The script only does the visual swap:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;js&quot; class=&quot;language-js &quot;&gt;&lt;code class=&quot;language-js&quot; data-lang=&quot;js&quot;&gt;(function () {
  var widget = document.querySelector(&amp;#x27;.helpful[data-helpful-path]&amp;#x27;);
  if (!widget) return;

  var path = widget.getAttribute(&amp;#x27;data-helpful-path&amp;#x27;);
  var storageKey = &amp;#x27;helpful:&amp;#x27; + path;
  var button = widget.querySelector(&amp;#x27;[data-helpful-action=&amp;quot;vote&amp;quot;]&amp;#x27;);
  var thanks = widget.querySelector(&amp;#x27;.helpful-thanks&amp;#x27;);

  function showThanks() {
    button.hidden = true;
    thanks.hidden = false;
  }

  try {
    if (localStorage.getItem(storageKey)) {
      showThanks();
      return;
    }
  } catch (e) {
    &amp;#x2F;&amp;#x2F; Private browsing &amp;#x2F; storage disabled: continue without persistence.
  }

  button.addEventListener(&amp;#x27;click&amp;#x27;, function () {
    try {
      localStorage.setItem(storageKey, Date.now().toString());
    } catch (e) {
      &amp;#x2F;&amp;#x2F; ignore
    }
    showThanks();
  });
})();
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Twenty-five lines. No framework, no dependencies, no async, no fetch. The &lt;code&gt;try&#x2F;catch&lt;&#x2F;code&gt; blocks around localStorage are for Safari private mode and embedded webviews, where &lt;code&gt;localStorage&lt;&#x2F;code&gt; throws on access rather than just being absent.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;edge-cases-i-thought-about&quot;&gt;Edge cases I thought about&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Tracker blockers.&lt;&#x2F;strong&gt; If your readers run uBlock Origin or similar, count.js gets blocked and the click goes nowhere. The UI still swaps state, so they don&#x27;t see a broken button - the dashboard just under-counts. Acceptable for a directional metric.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;localStorage disabled.&lt;&#x2F;strong&gt; Try&#x2F;catch keeps the UI working; users in this mode can vote again across reloads. Tolerable for a directional metric.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Same person, multiple browsers.&lt;&#x2F;strong&gt; Each browser counts once. There&#x27;s no way to deduplicate without auth, and I don&#x27;t want auth.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;JS disabled.&lt;&#x2F;strong&gt; The button renders but does nothing. I&#x27;d rather show a friendly prompt that can&#x27;t be acted on than hide the section entirely. If you find that offensive, a one-liner in &lt;code&gt;&amp;lt;noscript&amp;gt;&lt;&#x2F;code&gt; hides it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-learned-about-goatcounter&quot;&gt;What I learned about GoatCounter&lt;&#x2F;h2&gt;
&lt;p&gt;Three things that aren&#x27;t obvious from the docs:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Events show up in the same Pages view as pageviews.&lt;&#x2F;strong&gt; I expected a separate &quot;Events&quot; tab. There isn&#x27;t one. The events are inlined into the regular pages list, marked with their event title.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The GIF response is always 200.&lt;&#x2F;strong&gt; GoatCounter returns a 1x1 GIF for every count request, regardless of whether the hit was actually recorded. You can&#x27;t tell from HTTP status alone whether your event registered. The signal is whether it shows up in the dashboard.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;data-goatcounter-click&lt;&#x2F;code&gt; is dramatically simpler than calling &lt;code&gt;window.goatcounter.count()&lt;&#x2F;code&gt; manually.&lt;&#x2F;strong&gt; First version of this widget did the manual call. Second version reads the data attribute. Same behavior, less code, less to break.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;what-i-m-not-doing&quot;&gt;What I&#x27;m not doing&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No &quot;X people found this helpful&quot; counter.&lt;&#x2F;strong&gt; GoatCounter&#x27;s API doesn&#x27;t expose per-page event counts to the client, so showing the number would require a backend. I don&#x27;t care enough.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No reading-time gate.&lt;&#x2F;strong&gt; I considered only showing the button after 30 seconds or 60% scroll, to filter skim-clicks. I&#x27;ll add it if the noise becomes annoying. For now, every click is signal.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No newsletter pitch on click.&lt;&#x2F;strong&gt; &quot;Glad it helped - want more?&quot; would convert better. But I don&#x27;t have a newsletter, so it&#x27;d be a lie.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;s one at the bottom of this post. If this was useful, click it.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ll know whether the post landed because the dashboard will tell me. Not in pageviews. In &lt;code&gt;helpful-blog-2026-was-this-helpful-button&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Running Claude Code on NixOS</title>
          <pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/running-claude-code-on-nixos/</link>
          <guid>https://jakegoldsborough.com/blog/2026/running-claude-code-on-nixos/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/running-claude-code-on-nixos/">&lt;p&gt;I run NixOS on my laptop. I use Claude Code daily. Recently the two stopped getting along, so I sat down and fixed it.&lt;&#x2F;p&gt;
&lt;p&gt;The official Claude Code installer (&lt;a href=&quot;https:&#x2F;&#x2F;docs.claude.com&#x2F;en&#x2F;docs&#x2F;claude-code&#x2F;setup&quot;&gt;&lt;code&gt;curl -fsSL claude.ai&#x2F;install.sh | bash&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;) drops a prebuilt Linux binary into &lt;code&gt;~&#x2F;.local&#x2F;bin&#x2F;claude&lt;&#x2F;code&gt;. On Debian, Fedora, Arch, anything FHS-compliant, it just works. On NixOS, the binary refuses to run with some flavor of:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Could not start dynamically linked executable: &amp;#x2F;home&amp;#x2F;&amp;lt;user&amp;gt;&amp;#x2F;.local&amp;#x2F;bin&amp;#x2F;claude
NixOS cannot run dynamically linked executables intended for generic
linux environments out of the box. For more information, see:
https:&amp;#x2F;&amp;#x2F;nix.dev&amp;#x2F;permalink&amp;#x2F;stub-ld
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The binary is right there. It has the executable bit. NixOS just refuses to run it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-happens&quot;&gt;Why this happens&lt;&#x2F;h2&gt;
&lt;p&gt;NixOS doesn&#x27;t follow the &lt;a href=&quot;https:&#x2F;&#x2F;en.wikipedia.org&#x2F;wiki&#x2F;Filesystem_Hierarchy_Standard&quot;&gt;Filesystem Hierarchy Standard&lt;&#x2F;a&gt;. There is no &lt;code&gt;&#x2F;lib64&#x2F;ld-linux-x86-64.so.2&lt;&#x2F;code&gt;. Every library lives in &lt;code&gt;&#x2F;nix&#x2F;store&#x2F;&amp;lt;hash&amp;gt;-&amp;lt;package&amp;gt;&#x2F;lib&#x2F;&lt;&#x2F;code&gt;, and a given system can have many versions of the same library coexisting in different store paths.&lt;&#x2F;p&gt;
&lt;p&gt;Prebuilt Linux binaries hardcode &lt;code&gt;&#x2F;lib64&#x2F;ld-linux-x86-64.so.2&lt;&#x2F;code&gt; as the path to the dynamic linker. They also hardcode an rpath pointing at FHS library directories that don&#x27;t exist. When the kernel &lt;code&gt;execve&lt;&#x2F;code&gt;s such a binary, it can&#x27;t find the linker to load and bails out with the misleading &quot;required file not found&quot; error.&lt;&#x2F;p&gt;
&lt;p&gt;This isn&#x27;t unique to Claude Code. It bites Discord, Spotify, Steam, the .NET SDK, anything you &lt;code&gt;curl | bash&lt;&#x2F;code&gt; from a vendor&#x27;s website. The NixOS community has &lt;a href=&quot;https:&#x2F;&#x2F;nixos.wiki&#x2F;wiki&#x2F;Packaging&#x2F;Binaries&quot;&gt;several documented workarounds&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;patchelf&lt;&#x2F;strong&gt;: rewrite the binary&#x27;s interpreter and rpath to point at Nix store paths&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;nix-ld&lt;&#x2F;strong&gt;: a shim that maps the FHS linker path to a real Nix-provided linker at runtime&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;buildFHSEnv&lt;&#x2F;strong&gt; &#x2F; &lt;code&gt;steam-run&lt;&#x2F;code&gt;: drop the binary into a sandbox that fakes an FHS layout&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;autoPatchelfHook&lt;&#x2F;strong&gt;: the canonical packaging path, used when a binary is being added to nixpkgs&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I went with patchelf. It works from a plain &lt;code&gt;nix-shell&lt;&#x2F;code&gt; without touching &lt;code&gt;configuration.nix&lt;&#x2F;code&gt;, and it leaves the official install path intact - the binary still lives where Claude Code expects it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;claude-nixos&quot;&gt;claude-nixos&lt;&#x2F;a&gt; is a single &lt;code&gt;shell.nix&lt;&#x2F;code&gt; (133 lines) that does three things:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Wraps the official install script and patches the binary on the way in&lt;&#x2F;li&gt;
&lt;li&gt;Patches binaries that are &lt;em&gt;already&lt;&#x2F;em&gt; installed&lt;&#x2F;li&gt;
&lt;li&gt;Updates an existing install to a new version, repatching as it goes&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;You enter the shell:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nix-shell shell.nix
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And get three functions:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;install_claude_fixed              # download + patch
install_claude_fixed 0.5.2        # specific version
fix_installed_claude              # repair what&amp;#x27;s already there
update_claude_fixed               # newer version, same patch
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;how-the-patch-works&quot;&gt;How the patch works&lt;&#x2F;h2&gt;
&lt;p&gt;The interesting bit is the &lt;code&gt;patch_binary&lt;&#x2F;code&gt; function. Given a path to an ELF binary, it asks Nix for the path to &lt;em&gt;its&lt;&#x2F;em&gt; dynamic linker and the libraries the binary will need, then rewrites the binary in place:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;patchelf \
  --set-interpreter &amp;quot;$DYNAMIC_LINKER&amp;quot; \
  --set-rpath &amp;quot;$LIBRARY_PATH&amp;quot; \
  &amp;quot;$binary&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;$DYNAMIC_LINKER&lt;&#x2F;code&gt; comes from &lt;code&gt;pkgs.stdenv.cc.bintools.dynamicLinker&lt;&#x2F;code&gt; (a real, existing &lt;code&gt;&#x2F;nix&#x2F;store&lt;&#x2F;code&gt; path). &lt;code&gt;$LIBRARY_PATH&lt;&#x2F;code&gt; is built from &lt;code&gt;glibc&lt;&#x2F;code&gt;, &lt;code&gt;libstdc++&lt;&#x2F;code&gt;, &lt;code&gt;openssl&lt;&#x2F;code&gt;, &lt;code&gt;curl&lt;&#x2F;code&gt;, &lt;code&gt;sqlite&lt;&#x2F;code&gt;, &lt;code&gt;zlib&lt;&#x2F;code&gt; (the libraries Claude Code&#x27;s binary actually links against, found by trial and &lt;code&gt;ldd&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;After patching, the binary&#x27;s interpreter points at a file that exists on NixOS, and its rpath points at directories that contain the libraries it needs. The kernel can &lt;code&gt;execve&lt;&#x2F;code&gt; it, the linker can resolve its imports, and &lt;code&gt;claude&lt;&#x2F;code&gt; runs.&lt;&#x2F;p&gt;
&lt;p&gt;For new installs, the wrapper runs &lt;code&gt;sed&lt;&#x2F;code&gt; against the official install script to inject patchelf calls right after the binary is placed:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;sed -i &amp;quot;&amp;#x2F;cp.*claude.*\$INSTALL_DIR&amp;#x2F;a $PATCH_SCRIPT&amp;quot; install.sh
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;So the patched flow is byte-for-byte the official install with one extra step inlined. When Anthropic ships a new installer, the wrapper still works as long as &lt;code&gt;cp ... claude ... $INSTALL_DIR&lt;&#x2F;code&gt; stays in the script. If they restructure it, the sed pattern breaks loudly and obviously.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try it&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;git clone https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;claude-nixos
cd claude-nixos
nix-shell
install_claude_fixed
claude --version
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If you&#x27;ve already installed Claude Code through the official script and it&#x27;s broken, run &lt;code&gt;fix_installed_claude&lt;&#x2F;code&gt; instead and it&#x27;ll patch what&#x27;s there.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;claude-nixos&quot;&gt;claude-nixos&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;nixos.wiki&#x2F;wiki&#x2F;Packaging&#x2F;Binaries&quot;&gt;NixOS Wiki: Packaging&#x2F;Binaries&lt;&#x2F;a&gt; - the canonical write-up of the four fix-it options&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;nix-community&#x2F;nix-ld&quot;&gt;nix-ld&lt;&#x2F;a&gt; - the system-wide alternative&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>shellbooks: A Terminal Audiobook Player</title>
          <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/shellbooks-terminal-audiobook-player/</link>
          <guid>https://jakegoldsborough.com/blog/2026/shellbooks-terminal-audiobook-player/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/shellbooks-terminal-audiobook-player/">&lt;p&gt;I have a music player in the terminal (&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;shelltrax&lt;&#x2F;a&gt;) and a podcast player in the terminal (&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shellcast&quot;&gt;shellcast&lt;&#x2F;a&gt;). Audiobooks were the obvious gap.&lt;&#x2F;p&gt;
&lt;p&gt;So I built &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shellbooks&quot;&gt;shellbooks&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;cargo install shellbooks
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It&#x27;s a TUI audiobook player and library manager. cmus-inspired keybinds, ratatui rendering, rodio for audio, the same shape as the other two shell* tools. Open source, offline-first, runs on Linux and macOS.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-audiobooks-need-their-own-player&quot;&gt;Why audiobooks need their own player&lt;&#x2F;h2&gt;
&lt;p&gt;I wanted a CLI audiobook player that follows the cmus conventions I already use for music. A real TUI audiobook player on Linux, not a GUI app shoved into a terminal window.&lt;&#x2F;p&gt;
&lt;p&gt;Music players treat tracks as the unit. Podcast players treat episodes as the unit. Audiobooks need the &lt;em&gt;book&lt;&#x2F;em&gt; as the unit, with chapters as the unit &lt;em&gt;inside&lt;&#x2F;em&gt; a book. That changes a few things:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Position memory matters more.&lt;&#x2F;strong&gt; Resuming a song from 0:00 is fine. Resuming a 14-hour book from 0:00 is a tragedy.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Variable speed is table stakes.&lt;&#x2F;strong&gt; Most people listen at 1.25x or 1.5x. A music player has no reason to ship this; an audiobook player without it is broken.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Chapter navigation needs to work.&lt;&#x2F;strong&gt; Multi-file books have one chapter per file; single-file &lt;code&gt;.m4b&lt;&#x2F;code&gt; books have chapter markers embedded in the container.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;None of this is exotic. It just doesn&#x27;t fit the music or podcast model directly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ratatui&lt;&#x2F;code&gt; and &lt;code&gt;crossterm&lt;&#x2F;code&gt; for the TUI&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;rodio&lt;&#x2F;code&gt; and &lt;code&gt;symphonia&lt;&#x2F;code&gt; for audio playback&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;id3&lt;&#x2F;code&gt; for mp3 tags, &lt;code&gt;mp4ameta&lt;&#x2F;code&gt; for m4b tags and chapter atoms&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;walkdir&lt;&#x2F;code&gt; for the file browser&lt;&#x2F;li&gt;
&lt;li&gt;Plain JSON at &lt;code&gt;~&#x2F;.local&#x2F;share&#x2F;shellbooks&#x2F;library.json&lt;&#x2F;code&gt; for the library and per-book progress&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Same stack the other shell* tools use. The point of building these in a series is that each one starts halfway done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;interface&quot;&gt;Interface&lt;&#x2F;h2&gt;
&lt;p&gt;Three views, swapped with &lt;code&gt;1&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;2&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;3&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Library&lt;&#x2F;strong&gt; is two-pane: books left, chapters right. Tab toggles which side &lt;code&gt;j&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;k&lt;&#x2F;code&gt; moves through. Enter on the books pane resumes from the saved position. Enter on the chapters pane jumps to that chapter&#x27;s start.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Bookmarks&lt;&#x2F;strong&gt; lives at &lt;code&gt;2&lt;&#x2F;code&gt; (UI is still placeholder; the data model is in place).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Browser&lt;&#x2F;strong&gt; is a file system browser. Navigate to a directory of audio files or a single &lt;code&gt;.m4b&lt;&#x2F;code&gt;, press &lt;code&gt;a&lt;&#x2F;code&gt;, and it&#x27;s added to the library.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;A persistent footer sits on every screen with the book title, current chapter, position, total duration, speed, and a progress gauge. Press &lt;code&gt;i&lt;&#x2F;code&gt; anywhere to open a centered modal with the full metadata for the selected book.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-working&quot;&gt;What&#x27;s Working&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Imports from a file browser - explicit action, no surprise sweeps of &lt;code&gt;~&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Chapter detection: parses the chpl atom in &lt;code&gt;.m4b&lt;&#x2F;code&gt;, falls back to one chapter per file with cleaned filenames (&lt;code&gt;01 - Prologue.mp3&lt;&#x2F;code&gt; becomes &lt;code&gt;Prologue&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Per-book progress with autosave every 10 seconds while playing, plus on every chapter change. A crash never costs more than ten seconds.&lt;&#x2F;li&gt;
&lt;li&gt;Variable speed (0.5x - 3.0x) via &lt;code&gt;+&lt;&#x2F;code&gt; and &lt;code&gt;-&lt;&#x2F;code&gt; in 5% steps. Pitch shifts with tempo today; pitch-preserving via tdpsola is next.&lt;&#x2F;li&gt;
&lt;li&gt;Chapter skip (&lt;code&gt;b&lt;&#x2F;code&gt; next, &lt;code&gt;z&lt;&#x2F;code&gt; previous), seek (&lt;code&gt;,&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;.&lt;&#x2F;code&gt; ±10s, &lt;code&gt;[&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;]&lt;&#x2F;code&gt; ±60s)&lt;&#x2F;li&gt;
&lt;li&gt;Cover art resolution: sidecar &lt;code&gt;cover.jpg&lt;&#x2F;code&gt; or &lt;code&gt;cover.png&lt;&#x2F;code&gt; first, then embedded artwork written to a cache file&lt;&#x2F;li&gt;
&lt;li&gt;Tag extraction: title, author, narrator, series, year - using the audiobook publishing convention where artist is author and album is the book title&lt;&#x2F;li&gt;
&lt;li&gt;Delete a book from the library with &lt;code&gt;d&lt;&#x2F;code&gt; (mirrors shelltrax)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-s-not-yet&quot;&gt;What&#x27;s Not (yet)&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Pitch-preserving variable speed&lt;&#x2F;li&gt;
&lt;li&gt;Bookmarks UI (model exists, no bindings yet)&lt;&#x2F;li&gt;
&lt;li&gt;Theme parsing&lt;&#x2F;li&gt;
&lt;li&gt;Sleep timer countdown&lt;&#x2F;li&gt;
&lt;li&gt;Cover art rendered in-terminal (kitty &#x2F; sixel &#x2F; iterm2)&lt;&#x2F;li&gt;
&lt;li&gt;Network sources like Audiobookshelf&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;a-bug-worth-mentioning&quot;&gt;A Bug Worth Mentioning&lt;&#x2F;h2&gt;
&lt;p&gt;Imported a book and the title showed as &quot;Track 001.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The reason: I was reading the title tag from the first audio file. For a multi-file audiobook, the per-track &lt;code&gt;TIT2&lt;&#x2F;code&gt; is the &lt;em&gt;chapter&lt;&#x2F;em&gt; title, not the book. The book title belongs in the album tag. So now &lt;code&gt;apply_to_book&lt;&#x2F;code&gt; takes a &lt;code&gt;multi_file&lt;&#x2F;code&gt; flag and uses the album tag in that case, falling back to the directory name if there&#x27;s no album.&lt;&#x2F;p&gt;
&lt;p&gt;For single-file &lt;code&gt;.m4b&lt;&#x2F;code&gt; it still uses the title tag normally, since there the file &lt;em&gt;is&lt;&#x2F;em&gt; the book.&lt;&#x2F;p&gt;
&lt;p&gt;The kind of bug that&#x27;s obvious in hindsight and only really shows up once you point the tool at real publisher data.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;building-it-in-a-series-pays-off&quot;&gt;Building it in a Series Pays Off&lt;&#x2F;h2&gt;
&lt;p&gt;The bulk of the work on shellbooks was on the audiobook-specific bits: chapter atom parsing, the multi-file album-as-title rule, position tracking that survives speed changes and pause&#x2F;resume, the autosave loop. Almost none of it was on TUI scaffolding, the file browser, the event loop, or the release pipeline. All of that was lifted from shelltrax.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s also why I&#x27;m thinking about pulling the shared TUI bits into a &lt;code&gt;shell-tui&lt;&#x2F;code&gt; crate. The &lt;code&gt;ListSelector&lt;&#x2F;code&gt;, the &lt;code&gt;BrowserState&lt;&#x2F;code&gt;, the event loop, the cmus-style screen jumps, the &lt;code&gt;Makefile&lt;&#x2F;code&gt; template, the GitHub Actions release workflow - all of it is duplicated nearly verbatim across three apps now. Three is the inflection point.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try It&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cargo install shellbooks
shellbooks
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Press &lt;code&gt;3&lt;&#x2F;code&gt; to open the file browser, navigate to your audiobooks, press &lt;code&gt;a&lt;&#x2F;code&gt; to import a book directory or &lt;code&gt;.m4b&lt;&#x2F;code&gt;. Press &lt;code&gt;1&lt;&#x2F;code&gt; to return to the library, Enter to start playing.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shellbooks&quot;&gt;shellbooks&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;shelltrax&lt;&#x2F;a&gt; - the music player it descends from&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shellcast&quot;&gt;shellcast&lt;&#x2F;a&gt; - the podcast player it descends from&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>isitreal.estate: Crowd-Sourced Reviews for Real Estate Listings</title>
          <pubDate>Sat, 18 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/launching-isitreal-estate/</link>
          <guid>https://jakegoldsborough.com/blog/2026/launching-isitreal-estate/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/launching-isitreal-estate/">&lt;p&gt;Most real estate listings lie. Not always in big ways, but enough that
anyone who has hunted for an apartment or a house knows the feeling -
you drive 40 minutes, the front yard is a dump, the photos were from
2018, the &quot;washer&#x2F;dryer included&quot; means hookups in a damp basement,
and the agent shrugs.&lt;&#x2F;p&gt;
&lt;p&gt;There is no shared layer of truth for listings. Every renter and buyer
does the same recon work from scratch.&lt;&#x2F;p&gt;
&lt;p&gt;So I built &lt;a href=&quot;https:&#x2F;&#x2F;isitreal.estate&quot;&gt;isitreal.estate&lt;&#x2F;a&gt; - crowd-sourced
reviews tied to physical addresses, not listings. Listings come and go.
Addresses stay.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-idea&quot;&gt;The Idea&lt;&#x2F;h2&gt;
&lt;p&gt;Think Waze, but for house hunting.&lt;&#x2F;p&gt;
&lt;p&gt;You search an address. You see what other people found when they
visited. Photos they took. Whether the listing photos matched reality.
A rating. A date.&lt;&#x2F;p&gt;
&lt;p&gt;If the address has never been reviewed, you can be the first. After
you visit, leave a review with photos. Over time, bad actors
accumulate a visible history tied to the properties they list.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;data-model&quot;&gt;Data Model&lt;&#x2F;h2&gt;
&lt;p&gt;The anchor entity is the address, not the listing. A listing is
ephemeral - taken down, renamed, relisted with new photos. An
address has GPS coordinates and does not move.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;addresses
  id, street, city, state, zip, country, lat, lng

reviews
  id, address_id, user_id,
  listing_accurate (yes&amp;#x2F;partially&amp;#x2F;no),
  rating (1-5), body, visited_at

review_photos
  id, review_id, filename, path

votes
  id, review_id, user_id, vote (accurate&amp;#x2F;not_accurate)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Reviews carry photos. Votes let readers weigh reviews by what the
community agreed was useful or not. A user&#x27;s credibility score is
derived from how often their reviews align with votes over time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Framework&lt;&#x2F;strong&gt;: SvelteKit 5 (runes)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Database&lt;&#x2F;strong&gt;: PostgreSQL&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Auth&lt;&#x2F;strong&gt;: Session-based, scrypt hashing&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Maps&lt;&#x2F;strong&gt;: Leaflet + OpenStreetMap tiles&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Geocoding&lt;&#x2F;strong&gt;: Nominatim (OSM, free)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Photos&lt;&#x2F;strong&gt;: Disk storage, behind a configurable &lt;code&gt;UPLOAD_DIR&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Deployment&lt;&#x2F;strong&gt;: NixOS on my VPS, Caddy reverse proxy, systemd&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Nothing exotic. The most interesting choice was OSM + Nominatim
instead of Google Maps - no API key, no billing, no surprise bills if
someone shares the site somewhere.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;deployment&quot;&gt;Deployment&lt;&#x2F;h2&gt;
&lt;p&gt;The deploy is declarative and lives in my &lt;code&gt;pond-nix&lt;&#x2F;code&gt; repo. A new
curbside release builds a tarball on GitHub Actions; a NixOS service
fetches it on rebuild; systemd runs &lt;code&gt;node build&lt;&#x2F;code&gt;; Caddy handles TLS.&lt;&#x2F;p&gt;
&lt;p&gt;The first deploy broke for a boring but interesting reason. The
systemd unit tried to bind-mount a writable uploads directory into
the Nix store path, which is read-only. systemd refused:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Failed to create destination mount point node
&amp;#x27;&amp;#x2F;nix&amp;#x2F;store&amp;#x2F;...&amp;#x2F;source&amp;#x2F;uploads&amp;#x27;:
Read-only file system
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Rather than patching the Nix module to copy the app out of the store,
I made the upload directory configurable in the app itself:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ts&quot; class=&quot;language-ts &quot;&gt;&lt;code class=&quot;language-ts&quot; data-lang=&quot;ts&quot;&gt;export const UPLOAD_DIR = process.env.UPLOAD_DIR || &amp;#x27;uploads&amp;#x27;;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both the write path (&lt;code&gt;src&#x2F;lib&#x2F;photos.ts&lt;&#x2F;code&gt;) and the read path
(&lt;code&gt;src&#x2F;routes&#x2F;uploads&#x2F;[...path]&#x2F;+server.ts&lt;&#x2F;code&gt;) import the same constant,
so they never drift. In dev it defaults to &lt;code&gt;.&#x2F;uploads&lt;&#x2F;code&gt;. In production
the systemd unit sets &lt;code&gt;UPLOAD_DIR=&#x2F;var&#x2F;lib&#x2F;curbside&#x2F;uploads&lt;&#x2F;code&gt; and the
BindPaths goes away entirely.&lt;&#x2F;p&gt;
&lt;p&gt;Cleaner than fighting the Nix store. And it unblocks moving uploads
off the VPS to R2 later with the same interface.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-works&quot;&gt;What Works&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Signup, login, session auth&lt;&#x2F;li&gt;
&lt;li&gt;Address search with geocoding&lt;&#x2F;li&gt;
&lt;li&gt;Address detail page with reviews, photos, stats&lt;&#x2F;li&gt;
&lt;li&gt;Review submission with photo uploads&lt;&#x2F;li&gt;
&lt;li&gt;Accurate &#x2F; not-accurate voting&lt;&#x2F;li&gt;
&lt;li&gt;User profile pages&lt;&#x2F;li&gt;
&lt;li&gt;Admin role for moderation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-doesn-t-yet&quot;&gt;What Doesn&#x27;t (Yet)&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Map view on the homepage is basic&lt;&#x2F;li&gt;
&lt;li&gt;No autocomplete on address search yet&lt;&#x2F;li&gt;
&lt;li&gt;Photo storage lives on the VPS; needs to move to object storage&lt;&#x2F;li&gt;
&lt;li&gt;No rate limiting on review creation&lt;&#x2F;li&gt;
&lt;li&gt;Credibility scoring is naive&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;reflections&quot;&gt;Reflections&lt;&#x2F;h2&gt;
&lt;p&gt;The technical work was the easy part. SvelteKit + Postgres + systemd
is well-trodden ground. The real question is whether enough people
will leave reviews to reach the tipping point where the site is
actually useful when you search an address.&lt;&#x2F;p&gt;
&lt;p&gt;That is the honest problem with any crowd-sourced trust network. It is
worthless until it crosses some critical mass, and valuable after. The
middle is long and quiet.&lt;&#x2F;p&gt;
&lt;p&gt;So I am seeding with real reviews of real addresses I know, and
opening it up to see what happens. If you have ever been burned by a
fake listing, leave a review.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;isitreal.estate&quot;&gt;isitreal.estate&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;isitreal.estate&quot;&gt;curbside (app source)&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;pond-nix&quot;&gt;pond-nix (deploy config)&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>How my blog started getting indexed</title>
          <pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/how-my-blog-started-getting-indexed/</link>
          <guid>https://jakegoldsborough.com/blog/2026/how-my-blog-started-getting-indexed/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/how-my-blog-started-getting-indexed/">&lt;p&gt;I write this blog primarily for myself - to think through problems, document decisions, and have a record of what I built and why. I don&#x27;t optimize for search traffic. But about two months after I started publishing consistently, something unexpected happened: search engines started sending people here. Google, DuckDuckGo, Bing, ChatGPT, Perplexity.&lt;&#x2F;p&gt;
&lt;p&gt;Not a flood. Maybe 20-30 visitors a day from search. But those visitors are finding specific technical posts about problems they&#x27;re trying to solve. The kind of traffic that actually matters.&lt;&#x2F;p&gt;
&lt;p&gt;I didn&#x27;t do SEO. I didn&#x27;t build backlinks. I didn&#x27;t write &quot;10 tips&quot; posts or target keywords. What I did do, almost accidentally, was follow a few patterns that apparently work.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-actually-worked&quot;&gt;What Actually Worked&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;1-publishing-consistently&quot;&gt;1. Publishing consistently&lt;&#x2F;h3&gt;
&lt;p&gt;I&#x27;ve published 47 posts since January 2026. That&#x27;s roughly one every 2-3 days. Not because I&#x27;m following a content calendar, but because I&#x27;m building things and writing about them as I go.&lt;&#x2F;p&gt;
&lt;p&gt;Each post is a surface. Search engines index surfaces. More surfaces = more ways to be found.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;2-writing-real-titles&quot;&gt;2. Writing real titles&lt;&#x2F;h3&gt;
&lt;p&gt;My titles describe the actual content. Not &quot;Improve Your Workflow with This One Trick&quot; but &quot;Rewriting Claude Code in Rust (Part 2)&quot;. Specific, descriptive, ungameable.&lt;&#x2F;p&gt;
&lt;p&gt;Turns out that when someone searches for &quot;claude code rust&quot;, a post with those exact words in the title ranks. Obvious in hindsight.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;3-internal-linking&quot;&gt;3. Internal linking&lt;&#x2F;h3&gt;
&lt;p&gt;I link between related posts constantly. When I mention a project I&#x27;ve written about before, I link to it. When I reference a concept from another post, I link to it.&lt;&#x2F;p&gt;
&lt;p&gt;This creates a web. Google&#x27;s crawler follows links. A well-linked collection of pages signals that this is a coherent body of work, not random blog spam.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;4-clean-simple-structure&quot;&gt;4. Clean, simple structure&lt;&#x2F;h3&gt;
&lt;p&gt;My site is static HTML generated by Zola. No JavaScript frameworks, no client-side routing, no dynamic content. Just semantic HTML with proper headings, paragraphs, and code blocks.&lt;&#x2F;p&gt;
&lt;p&gt;Crawlers love this. Fast pages, clear structure, no obstacles.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;5-code-blocks-and-technical-depth&quot;&gt;5. Code blocks and technical depth&lt;&#x2F;h3&gt;
&lt;p&gt;Most of my posts include code examples, file paths, implementation details. These are extremely specific signals. If someone searches for a particular function name or error message, and I&#x27;ve written about it, search engines can match that query to my content precisely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-didn-t-matter&quot;&gt;What Didn&#x27;t Matter&lt;&#x2F;h2&gt;
&lt;p&gt;I don&#x27;t have a sitemap (though Zola can generate one, I haven&#x27;t enabled it). I have minimal visitor tracking but that&#x27;s it. I don&#x27;t track keyword density or optimize meta descriptions. I post on the fediverse but not as a promotion strategy.&lt;&#x2F;p&gt;
&lt;p&gt;None of that seems to matter for this kind of technical writing. The content itself is the SEO.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-pattern&quot;&gt;The Pattern&lt;&#x2F;h2&gt;
&lt;p&gt;What I&#x27;ve accidentally done is create a growing surface area of well-structured, interconnected technical content. Each new post adds another surface that search engines can index. Each internal link strengthens the web.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m not trying to rank for &quot;best programming language&quot; or other high-competition terms. I&#x27;m documenting niche problems and implementations. The long tail. And the long tail is where technical blogs actually get found.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;&#x2F;h2&gt;
&lt;p&gt;This blog is a byproduct of my work. Writing these posts helps me think. Publishing them creates a permanent record. The fact that they also get found by people solving similar problems is a side effect - but it&#x27;s a useful one.&lt;&#x2F;p&gt;
&lt;p&gt;It means the work compounds. Not just for me (as a reference and thinking tool) but for others who stumble across it via search.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the model I&#x27;m optimizing for. Not traffic. Not engagement. Just clear documentation of real work, published consistently, with enough structure that search engines can surface it when someone needs it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-m-going-to-keep-doing&quot;&gt;What I&#x27;m Going to Keep Doing&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Publishing consistently as I build&lt;&#x2F;li&gt;
&lt;li&gt;Writing descriptive titles&lt;&#x2F;li&gt;
&lt;li&gt;Linking between related posts&lt;&#x2F;li&gt;
&lt;li&gt;Using semantic HTML and clear structure&lt;&#x2F;li&gt;
&lt;li&gt;Including code examples and technical details&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That&#x27;s it. No keyword research, no backlink campaigns, no growth hacking. Just building and documenting in public, with enough consistency that it accumulates into a corpus worth indexing.&lt;&#x2F;p&gt;
&lt;p&gt;The blog is working exactly how I want it to. The search traffic is proof that the model scales.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Jobchain: Verifiable Employment Credentials</title>
          <pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/jobchain-verifiable-employment-credentials/</link>
          <guid>https://jakegoldsborough.com/blog/2026/jobchain-verifiable-employment-credentials/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/jobchain-verifiable-employment-credentials/">&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;hachyderm.io&#x2F;@michalfita@mastodon.social&#x2F;116330706279023796&quot;&gt;Michał Fita responded to my whoami-spec post&lt;&#x2F;a&gt; asking about verifiable credentials. His point: we should be able to prove employment history with cryptographic signatures, not chiselled PDFs that ATSes shred back into text.&lt;&#x2F;p&gt;
&lt;p&gt;He was right about the problem. But every previous attempt at this (Blockcerts, LinkedIn verified credentials, W3C VCs) died because it&#x27;s a three-sided marketplace. Employers have to issue credentials. Employees have to hold them. ATSes have to consume them. Nobody wants to go first.&lt;&#x2F;p&gt;
&lt;p&gt;I built the tooling anyway.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h2&gt;
&lt;p&gt;The employment verification pipeline is absurd:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;You write a resume (self-attested, no proof)&lt;&#x2F;li&gt;
&lt;li&gt;You export to PDF (formatting lottery)&lt;&#x2F;li&gt;
&lt;li&gt;An ATS OCRs it back to text (lossy, error-prone)&lt;&#x2F;li&gt;
&lt;li&gt;A background check company calls your old employer (slow, expensive)&lt;&#x2F;li&gt;
&lt;li&gt;Someone confirms dates over the phone (binary pass&#x2F;fail)&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Every step degrades information. The structured data you started with becomes an unstructured phone call. There has to be a better way.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The Fix&lt;&#x2F;h2&gt;
&lt;p&gt;Public key cryptography. Same thing that makes HTTPS work.&lt;&#x2F;p&gt;
&lt;p&gt;Your employer has a keypair. They sign a JSON document saying you worked there. Anyone can verify the signature by checking the public key hosted on the employer&#x27;s domain.&lt;&#x2F;p&gt;
&lt;p&gt;No blockchain. No platform. No account to create. DNS is the trust anchor.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;&#x2F;h2&gt;
&lt;p&gt;Three commands:&lt;&#x2F;p&gt;
&lt;h3 id=&quot;1-employer-creates-an-identity&quot;&gt;1. Employer creates an identity&lt;&#x2F;h3&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ jobchain init --org &amp;quot;Discourse&amp;quot; --domain discourse.org

Initialized jobchain identity for Discourse
  DID: did:web:discourse.org
  Next: host did.json at https:&amp;#x2F;&amp;#x2F;discourse.org&amp;#x2F;.well-known&amp;#x2F;did.json
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This generates an Ed25519 keypair and a &lt;a href=&quot;https:&#x2F;&#x2F;www.w3.org&#x2F;TR&#x2F;did-core&#x2F;&quot;&gt;DID document&lt;&#x2F;a&gt;. The public key lives at a well-known URL on the company&#x27;s domain. If you control &lt;code&gt;discourse.org&lt;&#x2F;code&gt;, you control the identity. No certificate authority, no third party.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;2-employer-issues-a-credential&quot;&gt;2. Employer issues a credential&lt;&#x2F;h3&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ echo &amp;#x27;{&amp;quot;title&amp;quot;:&amp;quot;Infrastructure Engineer&amp;quot;,&amp;quot;company&amp;quot;:&amp;quot;Discourse&amp;quot;,&amp;quot;start&amp;quot;:&amp;quot;2024-03&amp;quot;}&amp;#x27; \
  | jobchain issue --domain discourse.org

{
  &amp;quot;@context&amp;quot;: [&amp;quot;https:&amp;#x2F;&amp;#x2F;www.w3.org&amp;#x2F;2018&amp;#x2F;credentials&amp;#x2F;v1&amp;quot;],
  &amp;quot;type&amp;quot;: [&amp;quot;VerifiableCredential&amp;quot;, &amp;quot;EmploymentCredential&amp;quot;],
  &amp;quot;issuer&amp;quot;: &amp;quot;did:web:discourse.org&amp;quot;,
  &amp;quot;credentialSubject&amp;quot;: {
    &amp;quot;type&amp;quot;: &amp;quot;EmploymentRecord&amp;quot;,
    &amp;quot;title&amp;quot;: &amp;quot;Infrastructure Engineer&amp;quot;,
    &amp;quot;company&amp;quot;: &amp;quot;Discourse&amp;quot;,
    &amp;quot;start&amp;quot;: &amp;quot;2024-03&amp;quot;
  },
  &amp;quot;proof&amp;quot;: {
    &amp;quot;type&amp;quot;: &amp;quot;Ed25519Signature2020&amp;quot;,
    &amp;quot;proofValue&amp;quot;: &amp;quot;z4jH8n...&amp;quot;
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s a &lt;a href=&quot;https:&#x2F;&#x2F;www.w3.org&#x2F;TR&#x2F;vc-data-model&#x2F;&quot;&gt;W3C Verifiable Credential&lt;&#x2F;a&gt;. Structured JSON, signed with Ed25519. Hand the &lt;code&gt;.vc.json&lt;&#x2F;code&gt; file to the employee.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;3-anyone-verifies&quot;&gt;3. Anyone verifies&lt;&#x2F;h3&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ jobchain verify --input jake-discourse.vc.json

VALID -- credential signature verified
  Issuer:  did:web:discourse.org
  Subject: Infrastructure Engineer at Discourse
  Issued:  2026-04-02
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The verify command fetches the public key from &lt;code&gt;discourse.org&#x2F;.well-known&#x2F;did.json&lt;&#x2F;code&gt; and checks the signature. No account needed. No API key. Just math.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-wallet-is-a-git-repo&quot;&gt;The Wallet Is a Git Repo&lt;&#x2F;h2&gt;
&lt;p&gt;Your &quot;wallet&quot; is a folder of &lt;code&gt;.vc.json&lt;&#x2F;code&gt; files. Host it anywhere. GitHub Pages, Netlify, your own server.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ jobchain wallet build --dir .&amp;#x2F;credentials --out .&amp;#x2F;site
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This generates an HTML site with your credentials and a machine-readable &lt;code&gt;index.json&lt;&#x2F;code&gt; manifest. Hiring managers see a clean page. ATS systems consume structured JSON. Same data, two views.&lt;&#x2F;p&gt;
&lt;p&gt;The wallet is static files. Push to a repo, deploy to Pages. Done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-not-blockchain&quot;&gt;Why Not Blockchain&lt;&#x2F;h2&gt;
&lt;p&gt;This comes up immediately, so let me address it.&lt;&#x2F;p&gt;
&lt;p&gt;Blockchain solves one problem jobchain doesn&#x27;t: persistence after the issuer disappears. If &lt;code&gt;discourse.org&lt;&#x2F;code&gt; goes dark, the public key is gone and nobody can verify old credentials.&lt;&#x2F;p&gt;
&lt;p&gt;But blockchain adds massive complexity for that edge case. Consensus mechanisms, gas fees, infrastructure dependencies. And the social problem (getting employers to issue credentials) doesn&#x27;t change regardless of the storage layer.&lt;&#x2F;p&gt;
&lt;p&gt;For now, DNS is the trust anchor. If persistence matters later, there are simpler solutions: key archival services, IPFS pinning, or embedding the public key in the credential itself.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;amendments&quot;&gt;Amendments&lt;&#x2F;h2&gt;
&lt;p&gt;Credentials aren&#x27;t static. You get promoted. You change teams. Jobchain supports amendments: signed updates linked to the original credential via content hash.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ jobchain amend --credential jake-discourse.vc.json \
    --domain discourse.org \
    --patch &amp;#x27;{&amp;quot;title&amp;quot;:&amp;quot;Senior Infrastructure Engineer&amp;quot;}&amp;#x27; \
    --effective-date 2025-06
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each amendment is independently verifiable. The chain forms a linked list: original credential, then amendments, each signed and hash-linked to its predecessor.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-is-not&quot;&gt;What This Is Not&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s not a platform. There&#x27;s no service to sign up for, no data to hand over.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s not self-attested. Only the employer&#x27;s private key can produce a valid signature.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s not a resume replacement. &lt;a href=&quot;https:&#x2F;&#x2F;jobl.dev&quot;&gt;JOBL&lt;&#x2F;a&gt; handles the self-authored resume. Jobchain handles what others can confirm about you. They complement each other.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-architecture&quot;&gt;The Architecture&lt;&#x2F;h2&gt;
&lt;p&gt;Jobchain is a Cargo workspace with three crates:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;jobchain-core&lt;&#x2F;strong&gt;: credential types, Ed25519 signing, DID documents, amendment chains&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;jobchain-verify&lt;&#x2F;strong&gt;: signature verification only, kept slim for future WASM compilation&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;jobchain-cli&lt;&#x2F;strong&gt;: the &lt;code&gt;jobchain&lt;&#x2F;code&gt; binary&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;131 tests. All passing.&lt;&#x2F;p&gt;
&lt;p&gt;The verify crate is deliberately lightweight. No HTTP client, no file I&#x2F;O, no CLI dependencies. The plan is to compile it to WASM so the wallet can verify credentials in-browser without a server.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-actually-used-to-build-this&quot;&gt;What I Actually Used to Build This&lt;&#x2F;h2&gt;
&lt;p&gt;I used &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;finna-multi-model-spec-implement&#x2F;&quot;&gt;finna&lt;&#x2F;a&gt; to architect and implement it. Claude and Gemini debated the architecture, generated 16 implementation specs, then Claude implemented each spec as a feature branch. The whole thing -- keypair generation, DID documents, credential signing, verification, amendments, adapter traits, wallet generator, CLI, 131 tests -- was implemented in one session.&lt;&#x2F;p&gt;
&lt;p&gt;I reviewed the output, fixed a few things, and it worked end to end. That&#x27;s a different kind of project kickstart than I&#x27;m used to.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-hard-part&quot;&gt;The Hard Part&lt;&#x2F;h2&gt;
&lt;p&gt;The tooling exists. The hard part is adoption.&lt;&#x2F;p&gt;
&lt;p&gt;Someone has to go first. An employer has to host a &lt;code&gt;did.json&lt;&#x2F;code&gt; and issue a credential. An employee has to publish their wallet. A hiring manager has to check it.&lt;&#x2F;p&gt;
&lt;p&gt;I work at a smaller company. We could try it. The ask is small: host one JSON file, run one command when someone joins or leaves. Frame it as a signed reference letter that&#x27;s machine-verifiable.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the next step.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;current-status&quot;&gt;Current Status&lt;&#x2F;h2&gt;
&lt;p&gt;Working CLI with all Phase 1 features:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;jobchain init&lt;&#x2F;code&gt; -- generate org identity&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;jobchain issue&lt;&#x2F;code&gt; -- sign employment credentials&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;jobchain verify&lt;&#x2F;code&gt; -- check signatures (offline or via DID resolution)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;jobchain amend&lt;&#x2F;code&gt; -- signed amendments to existing credentials&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;jobchain wallet build&lt;&#x2F;code&gt; -- static site generator&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;What&#x27;s missing:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Actual adoption by an employer&lt;&#x2F;li&gt;
&lt;li&gt;HR tool adapters (BambooHR, Gusto, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;WASM verification in the wallet&lt;&#x2F;li&gt;
&lt;li&gt;A real-world credential issued by a real company&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The infrastructure is there. Now it needs to be used.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;jobchain&quot;&gt;Jobchain on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;ducks.github.io&#x2F;jobchain&#x2F;&quot;&gt;Jobchain Documentation&lt;&#x2F;a&gt; -- implementation guide and spec&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;jobl.dev&quot;&gt;JOBL&lt;&#x2F;a&gt; -- structured resume format (companion project)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;whoami-spec&quot;&gt;whoami-spec&lt;&#x2F;a&gt; -- declarative identity&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;finna&quot;&gt;finna&lt;&#x2F;a&gt; -- multi-model debate and implement&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Inside Claude Code&#x27;s Team Memory Sync Engine</title>
          <pubDate>Sun, 05 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/inside-claude-codes-team-memory-sync/</link>
          <guid>https://jakegoldsborough.com/blog/2026/inside-claude-codes-team-memory-sync/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/inside-claude-codes-team-memory-sync/">&lt;p&gt;Most people using Claude Code don&#x27;t know it has a shared memory system. A
directory on disk that syncs to Anthropic&#x27;s servers, merges with your
teammates&#x27; memories, and gets injected into every conversation. No git
involved. No manual sharing. It just works.&lt;&#x2F;p&gt;
&lt;p&gt;I found it while reading the leaked source. A &lt;code&gt;teamMemorySync&#x2F;&lt;&#x2F;code&gt; directory
with six files: a sync engine, a file watcher, a secret scanner, type
definitions, a path validator, and prompt integration. About 800 lines of
TypeScript implementing a real-time collaborative knowledge base inside a
CLI tool.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-team-memory-is&quot;&gt;What Team Memory Is&lt;&#x2F;h2&gt;
&lt;p&gt;Team memory is a flat key-value store scoped per repository. Keys are file
paths like &lt;code&gt;MEMORY.md&lt;&#x2F;code&gt; or &lt;code&gt;patterns.md&lt;&#x2F;code&gt;. Values are UTF-8 content, typically
markdown. It lives at:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;~&amp;#x2F;.claude&amp;#x2F;projects&amp;#x2F;&amp;lt;project-hash&amp;gt;&amp;#x2F;memory&amp;#x2F;team&amp;#x2F;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every authenticated user in your organization who works in the same repo
shares this directory. When one person writes a memory file, it syncs to the
server and gets pulled into everyone else&#x27;s session.&lt;&#x2F;p&gt;
&lt;p&gt;The system prompt tells Claude about both memory directories:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;You have a persistent, file-based memory system with two directories: a
private directory at &lt;code&gt;&amp;lt;auto-path&amp;gt;&lt;&#x2F;code&gt; and a shared team directory at
&lt;code&gt;&amp;lt;team-path&amp;gt;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Claude reads the team &lt;code&gt;MEMORY.md&lt;&#x2F;code&gt; index at session start and can access any
file in the team directory. When it saves a memory, it decides whether to
put it in private or team based on the content type.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-sync-engine&quot;&gt;The Sync Engine&lt;&#x2F;h2&gt;
&lt;p&gt;The sync logic in &lt;code&gt;teamMemorySync&#x2F;index.ts&lt;&#x2F;code&gt; handles three operations: pull,
push, and hash comparison.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;pull-server-to-local&quot;&gt;Pull (server to local)&lt;&#x2F;h3&gt;
&lt;p&gt;On session start, Claude Code fetches the team memory for your repo:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;GET &amp;#x2F;api&amp;#x2F;claude_code&amp;#x2F;team_memory?repo={owner&amp;#x2F;repo}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The response includes all entries (key-value pairs), per-entry SHA-256
checksums, a version number, and an overall checksum. Server wins -- pull
overwrites local files. If the server returns a 304 (ETag matches), nothing
happens.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;push-local-to-server&quot;&gt;Push (local to server)&lt;&#x2F;h3&gt;
&lt;p&gt;When you edit a team memory file, the watcher triggers a push. But it
doesn&#x27;t upload everything -- it computes a delta:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Hash each local file with SHA-256&lt;&#x2F;li&gt;
&lt;li&gt;Compare against &lt;code&gt;serverChecksums&lt;&#x2F;code&gt; (cached from the last pull&#x2F;push)&lt;&#x2F;li&gt;
&lt;li&gt;Only upload files whose hash differs&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre&gt;&lt;code&gt;PUT &amp;#x2F;api&amp;#x2F;claude_code&amp;#x2F;team_memory?repo={owner&amp;#x2F;repo}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The PUT uses upsert semantics -- keys not in the request are preserved on
the server. This means pushing a single changed file doesn&#x27;t delete
everything else.&lt;&#x2F;p&gt;
&lt;p&gt;There&#x27;s also a body size cap of 200KB per request. If the delta exceeds
that, it splits into sequential PUTs. Each one merges safely because of
the upsert semantics.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;conflict-resolution&quot;&gt;Conflict Resolution&lt;&#x2F;h3&gt;
&lt;p&gt;Optimistic concurrency via ETags. Every push includes the last-known
checksum as &lt;code&gt;If-Match&lt;&#x2F;code&gt;. If someone else pushed in between, you get a
412 Precondition Failed.&lt;&#x2F;p&gt;
&lt;p&gt;The conflict resolution is clever. Instead of pulling the full content
(potentially 300KB), it fetches just the checksums:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;GET &amp;#x2F;api&amp;#x2F;claude_code&amp;#x2F;team_memory?repo={owner&amp;#x2F;repo}&amp;amp;view=hashes
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This returns per-key hashes without the actual content. The client updates
its &lt;code&gt;serverChecksums&lt;&#x2F;code&gt; map, recomputes the delta, and retries the push
with the new ETag. Up to 2 conflict retries before giving up.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-file-watcher&quot;&gt;The File Watcher&lt;&#x2F;h2&gt;
&lt;p&gt;A &lt;code&gt;fs.watch&lt;&#x2F;code&gt; on the team memory directory, debounced at 2 seconds. When
a file changes:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Wait 2s for more changes (debounce)&lt;&#x2F;li&gt;
&lt;li&gt;If a push is already in progress, mark &lt;code&gt;hasPendingChanges&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;When the current push finishes, if &lt;code&gt;hasPendingChanges&lt;&#x2F;code&gt;, push again&lt;&#x2F;li&gt;
&lt;li&gt;On permanent failure (auth error, too many entries), suppress the
watcher to prevent infinite retry loops&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The watcher is careful about one edge case: when a pull writes server
content to local files, those writes trigger watch events. Without
suppression, you&#x27;d get a pull-then-immediate-push loop. The watcher
skips events during pull operations.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-secret-scanner&quot;&gt;The Secret Scanner&lt;&#x2F;h2&gt;
&lt;p&gt;Before any push, every file is scanned for credentials. The scanner uses
30+ regex patterns adapted from gitleaks (MIT licensed), covering:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloud providers&lt;&#x2F;strong&gt;: AWS access keys, GCP API keys, Azure AD secrets,
DigitalOcean tokens&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;AI APIs&lt;&#x2F;strong&gt;: Anthropic keys (&lt;code&gt;sk-ant-api03-...&lt;&#x2F;code&gt;), OpenAI keys, HuggingFace
tokens&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Version control&lt;&#x2F;strong&gt;: GitHub PATs&#x2F;OAuth&#x2F;app tokens, GitLab PATs&#x2F;deploy tokens&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Communication&lt;&#x2F;strong&gt;: Slack bot&#x2F;user&#x2F;app tokens, Twilio, SendGrid&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Dev tools&lt;&#x2F;strong&gt;: NPM tokens, PyPI upload tokens, HashiCorp TF tokens,
Databricks, Pulumi, Postman&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Payment&lt;&#x2F;strong&gt;: Stripe keys, Shopify tokens&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Crypto&lt;&#x2F;strong&gt;: PEM private key blocks&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Files with detected secrets are silently skipped -- not uploaded, not
deleted locally, not reported to the server. The push result includes which
files were skipped and why, so the system can inform the user.&lt;&#x2F;p&gt;
&lt;p&gt;The patterns are curated for near-zero false positives. They only match
distinctive prefixes (&lt;code&gt;ghp_&lt;&#x2F;code&gt;, &lt;code&gt;sk-ant-&lt;&#x2F;code&gt;, &lt;code&gt;xoxb-&lt;&#x2F;code&gt;, &lt;code&gt;AKIA&lt;&#x2F;code&gt;, etc.) rather
than generic keyword patterns. The comment in the source:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Uses a curated subset of high-confidence rules from gitleaks -- only
rules with distinctive prefixes that have near-zero false-positive
rates are included. Generic keyword-context rules are omitted.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;One interesting detail: the Anthropic API key pattern assembles its prefix
at runtime (&lt;code&gt;[&#x27;sk&#x27;, &#x27;ant&#x27;, &#x27;api&#x27;].join(&#x27;-&#x27;)&lt;&#x2F;code&gt;) so the literal string doesn&#x27;t
appear in the compiled bundle. They have an internal &lt;code&gt;excluded-strings.txt&lt;&#x2F;code&gt;
check that greps build output for sensitive strings -- the secret scanner
would trip its own detection if the prefix were hardcoded.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;path-safety&quot;&gt;Path Safety&lt;&#x2F;h2&gt;
&lt;p&gt;Team memory files arrive from the server with relative paths as keys. The
path validator in &lt;code&gt;teamMemPaths.ts&lt;&#x2F;code&gt; prevents directory traversal attacks:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Rejects paths containing &lt;code&gt;..&lt;&#x2F;code&gt; after normalization&lt;&#x2F;li&gt;
&lt;li&gt;Resolves symlinks and checks the canonical path stays inside the team
memory directory&lt;&#x2F;li&gt;
&lt;li&gt;Rejects absolute paths&lt;&#x2F;li&gt;
&lt;li&gt;Throws a &lt;code&gt;PathTraversalError&lt;&#x2F;code&gt; if a key would escape the sandbox&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This matters because the server response drives file writes. A malicious
or compromised server could theoretically send a key like
&lt;code&gt;..&#x2F;..&#x2F;..&#x2F;.bashrc&lt;&#x2F;code&gt; -- the path validator prevents that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-claude-uses-it&quot;&gt;How Claude Uses It&lt;&#x2F;h2&gt;
&lt;p&gt;The team memory directory gets loaded into the system prompt alongside the
private memory. Claude sees both and is instructed on when to use each:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Private memories&lt;&#x2F;strong&gt;: things specific to one user (preferences, role,
feedback on how to work with them)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Team memories&lt;&#x2F;strong&gt;: shared project knowledge (architecture decisions,
patterns, conventions, ongoing initiatives)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The prompt is explicit about what NOT to put in team memory:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;You MUST avoid saving sensitive data within shared team memories. For
example, never save API keys or user credentials.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;When Claude writes a memory, it chooses the directory based on content type.
User preferences go private. Project patterns go team. The two indexes
(&lt;code&gt;MEMORY.md&lt;&#x2F;code&gt; in each directory) are both loaded into context.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;size-limits&quot;&gt;Size Limits&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Per file&lt;&#x2F;strong&gt;: 250KB max (filtered client-side before upload)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Per PUT body&lt;&#x2F;strong&gt;: 200KB (splits into sequential requests if exceeded)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Entry count&lt;&#x2F;strong&gt;: server-enforced, tunable per-org (no client-side default)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;API timeout&lt;&#x2F;strong&gt;: 30 seconds&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;When the server rejects a push for too many entries, it returns a structured
413 with the limit:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;json&quot; class=&quot;language-json &quot;&gt;&lt;code class=&quot;language-json&quot; data-lang=&quot;json&quot;&gt;{
  &amp;quot;error&amp;quot;: {
    &amp;quot;details&amp;quot;: {
      &amp;quot;error_code&amp;quot;: &amp;quot;team_memory_too_many_entries&amp;quot;,
      &amp;quot;max_entries&amp;quot;: 100,
      &amp;quot;received_entries&amp;quot;: 142
    }
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The client caches the &lt;code&gt;max_entries&lt;&#x2F;code&gt; value and truncates on subsequent pushes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;requirements&quot;&gt;Requirements&lt;&#x2F;h2&gt;
&lt;p&gt;Team memory requires:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;OAuth authentication (not API key, not Bedrock&#x2F;Vertex)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;user:inference&lt;&#x2F;code&gt; and &lt;code&gt;user:profile&lt;&#x2F;code&gt; scopes&lt;&#x2F;li&gt;
&lt;li&gt;A git repo with a remote (used to derive &lt;code&gt;owner&#x2F;repo&lt;&#x2F;code&gt; slug)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;It&#x27;s designed for Claude.ai Team and Enterprise plans, but works for Pro&#x2F;Max
too. Bedrock and Vertex users are excluded -- the sync endpoint requires
first-party Anthropic auth.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-interesting-about-it&quot;&gt;What&#x27;s Interesting About It&lt;&#x2F;h2&gt;
&lt;p&gt;The engineering is solid but the concept is what stands out. Most AI coding
tools treat each session as isolated. Your context disappears when you close
the terminal. If your teammate learned something useful, you have to
rediscover it.&lt;&#x2F;p&gt;
&lt;p&gt;Team memory makes knowledge accumulate. One person documents a tricky API
pattern, and every future conversation in that repo -- by anyone on the
team -- gets that context. Without checking anything into git, without
sharing a doc, without telling anyone. The AI just knows because the
team taught it.&lt;&#x2F;p&gt;
&lt;p&gt;The secret scanner is the paranoid layer that makes this possible. Shared
memory across a team is a liability if it leaks credentials. Scanning
before upload, with patterns that match real secret formats instead of
guessing from keywords, is the right approach.&lt;&#x2F;p&gt;
&lt;p&gt;The sync engine is pragmatic. Server wins on pull. Upsert on push. ETag
for conflicts. No CRDT, no operational transform, no fancy merge. Just
&quot;last write wins per key&quot; with checksums to avoid unnecessary uploads.
For markdown files that humans edit infrequently, it&#x27;s the right level
of complexity.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Found by reading the Claude Code source that leaked via npm source maps on
March 31, 2026. The team memory system lives in
&lt;code&gt;src&#x2F;services&#x2F;teamMemorySync&#x2F;&lt;&#x2F;code&gt; and &lt;code&gt;src&#x2F;memdir&#x2F;teamMemPaths.ts&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Rewriting Claude Code in Rust, Part 3: TUI, Agents, and Multi-Provider</title>
          <pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-part-3/</link>
          <guid>https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-part-3/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-part-3/">&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-part-2&#x2F;&quot;&gt;Part 2&lt;&#x2F;a&gt; left off at 1,530
lines with an inline REPL, permission prompts, session management, and
compaction. It worked, but it looked like a script, not a tool.&lt;&#x2F;p&gt;
&lt;p&gt;This post covers the rest: a proper TUI, the agent system, and breaking free
from Anthropic-only.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;phase-4-the-tui&quot;&gt;Phase 4: The TUI&lt;&#x2F;h2&gt;
&lt;p&gt;The inline REPL was functional but crude. Print prompt, read line, stream
tokens, repeat. No scroll, no layout, no visual distinction between you and
the assistant.&lt;&#x2F;p&gt;
&lt;p&gt;Claude Code&#x27;s original UI is React + Ink -- React components rendered to the
terminal. That&#x27;s not happening in Rust. The equivalent is ratatui, which
gives you a layout engine, widgets, and an alternate-screen buffer.&lt;&#x2F;p&gt;
&lt;p&gt;The TUI runs behind &lt;code&gt;--tui&lt;&#x2F;code&gt;. The inline REPL stays as the default because
it&#x27;s simpler and pipes better.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;layout&quot;&gt;Layout&lt;&#x2F;h3&gt;
&lt;p&gt;Four zones, top to bottom:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────┐
│ claux v20260401.0.1             │  &amp;lt;- header
├─────────────────────────────────┤
│                                 │
│ You                             │
│ &amp;gt; read src&amp;#x2F;main.rs              │
│                                 │  &amp;lt;- scrollable message area
│ fn main() -&amp;gt; Result&amp;lt;()&amp;gt; {       │
│   let args = Cli::parse();      │
│ ...                             │
│                                 │
├─ &amp;gt; ────────────────────────────-┤  &amp;lt;- input box
│ explain the error handling      │
├─────────────────────────────────┤
│ claude-sonnet-4 | Cost: $0.02   │  &amp;lt;- status bar
└─────────────────────────────────┘
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Messages are color-coded: blue for user, default for assistant, green for
streaming text, yellow for system messages and permission prompts. Gruvbox
palette because that&#x27;s what I use everywhere.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-hard-part-async-tui&quot;&gt;The Hard Part: Async + TUI&lt;&#x2F;h3&gt;
&lt;p&gt;The challenge with a terminal UI and an async LLM client is that you can&#x27;t
&lt;code&gt;await&lt;&#x2F;code&gt; the API call and poll terminal events at the same time from the same
thread. The inline REPL solved this by spawning the display as a tokio task
and driving the engine on the main thread. The TUI can&#x27;t do that -- it needs
to own the terminal.&lt;&#x2F;p&gt;
&lt;p&gt;The solution: drive the streaming loop manually with &lt;code&gt;tokio::select!&lt;&#x2F;code&gt;. One
arm reads API events, the other polls terminal input with a short timeout.
Ctrl+C during streaming cancels the request.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;loop {
    tokio::select! {
        Some(event) = api_rx.recv() =&amp;gt; {
            match event {
                ApiEvent::Text(t) =&amp;gt; {
                    app.stream_buffer.push_str(&amp;amp;t);
                    terminal.draw(|f| ui::draw(f, app))?;
                }
                ApiEvent::ToolUse { id, name, input } =&amp;gt; {
                    tool_uses.push((id, name, input));
                }
                ApiEvent::Done =&amp;gt; break,
                &amp;#x2F;&amp;#x2F; ...
            }
        }
        _ = tokio::time::sleep(Duration::from_millis(50)) =&amp;gt; {
            if event::poll(Duration::from_millis(0))? {
                if let Event::Key(key) = event::read()? {
                    &amp;#x2F;&amp;#x2F; Handle Ctrl+C, scroll, etc.
                }
            }
        }
    }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Permission prompts work inline in the TUI. When the engine needs approval,
the input box turns yellow and shows the tool name with &lt;code&gt;(y)es &#x2F; (n)o &#x2F; (a)lways&lt;&#x2F;code&gt;. The engine blocks on a oneshot channel until you answer.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-it-cost&quot;&gt;What It Cost&lt;&#x2F;h3&gt;
&lt;p&gt;928 lines for the TUI module. Two files: &lt;code&gt;tui&#x2F;mod.rs&lt;&#x2F;code&gt; (app state, event loop,
streaming driver) and &lt;code&gt;tui&#x2F;ui.rs&lt;&#x2F;code&gt; (layout and rendering). The rendering is
naive -- no markdown parsing, no syntax highlighting, no word wrap. But it
works and it&#x27;s fast.&lt;&#x2F;p&gt;
&lt;p&gt;Upgrading to Rust 1.88 was required because ratatui 0.29&#x27;s dependencies
needed it. The &lt;code&gt;shell.nix&lt;&#x2F;code&gt; now uses rust-overlay instead of nixpkgs&#x27; rust.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;phase-5-agents&quot;&gt;Phase 5: Agents&lt;&#x2F;h2&gt;
&lt;p&gt;The Agent tool is what makes Claude Code feel like it has workers. You ask it
to do something complex, it spawns a sub-conversation with its own context
and a restricted tool set.&lt;&#x2F;p&gt;
&lt;p&gt;In claux, the Agent tool creates a child &lt;code&gt;Engine&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;async fn execute(&amp;amp;self, input: Value) -&amp;gt; Result&amp;lt;ToolOutput&amp;gt; {
    let params: Params = serde_json::from_value(input)?;

    let provider = (self.make_provider)();
    let tools = ToolRegistry::without_agent(); &amp;#x2F;&amp;#x2F; no recursion
    let permissions = PermissionChecker::new(PermissionMode::Bypass);

    let mut engine = Engine::new(provider, tools, permissions, &amp;amp;self.model);
    engine.set_system_prompt(agent_prompt);

    match engine.submit(&amp;amp;params.prompt).await {
        Ok(response) =&amp;gt; Ok(ToolOutput {
            content: response,
            is_error: false,
        }),
        &amp;#x2F;&amp;#x2F; ...
    }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three key decisions:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No nested agents.&lt;&#x2F;strong&gt; &lt;code&gt;ToolRegistry::without_agent()&lt;&#x2F;code&gt; excludes the Agent
tool from sub-agents. Prevents infinite recursion.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bypass permissions.&lt;&#x2F;strong&gt; Agents run unattended. If the parent conversation
approved the Agent call, the sub-agent&#x27;s tools don&#x27;t need individual
approval.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Own context.&lt;&#x2F;strong&gt; The sub-agent starts with a fresh message history and its
own system prompt. It doesn&#x27;t see the parent conversation.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Cost tracking flows back -- the agent&#x27;s token usage is appended to its output
so you can see what it spent.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;auto-compact&quot;&gt;Auto-Compact&lt;&#x2F;h3&gt;
&lt;p&gt;Phase 5 also added auto-compaction. Before each &lt;code&gt;submit&lt;&#x2F;code&gt;, the engine checks
if the message count exceeds 80. If so, it runs the same compaction logic as
&lt;code&gt;&#x2F;compact&lt;&#x2F;code&gt; -- summarizes the conversation via the API and replaces history with
the summary.&lt;&#x2F;p&gt;
&lt;p&gt;This is the dumb version of what Claude Code does. Their compaction pipeline
has multiple strategies: reactive compact, context collapse, history snip,
tool-use summaries. Ours is a threshold check and a summarization call. But it
prevents the context window from overflowing, which is the thing that actually
matters.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;multi-provider&quot;&gt;Multi-Provider&lt;&#x2F;h2&gt;
&lt;p&gt;Up to this point, claux only spoke Anthropic. That&#x27;s fine for rewriting Claude
Code, but limiting for actual use.&lt;&#x2F;p&gt;
&lt;p&gt;The fix was a &lt;code&gt;Provider&lt;&#x2F;code&gt; trait:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[async_trait]
pub trait Provider: Send + Sync {
    fn name(&amp;amp;self) -&amp;gt; &amp;amp;str;
    fn model(&amp;amp;self) -&amp;gt; &amp;amp;str;
    fn set_model(&amp;amp;mut self, model: &amp;amp;str);

    async fn stream(
        &amp;amp;self,
        messages: &amp;amp;[Message],
        system: &amp;amp;str,
        tools: &amp;amp;[ToolDefinition],
        max_tokens: u32,
    ) -&amp;gt; Result&amp;lt;mpsc::Receiver&amp;lt;ApiEvent&amp;gt;&amp;gt;;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two implementations:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;AnthropicProvider&lt;&#x2F;strong&gt; -- the existing client, extracted into its own file.
Uses &lt;code&gt;x-api-key&lt;&#x2F;code&gt; or &lt;code&gt;Authorization: Bearer&lt;&#x2F;code&gt; depending on whether you&#x27;re
using an API key or OAuth from &lt;code&gt;claude login&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;OpenAICompatProvider&lt;&#x2F;strong&gt; -- speaks the &lt;code&gt;&#x2F;v1&#x2F;chat&#x2F;completions&lt;&#x2F;code&gt; streaming
format. Works with Ollama, vLLM, LMStudio, OpenAI, or any hosted endpoint.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The hard part was message format conversion. Anthropic and OpenAI structure
tool calls differently:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;Anthropic&lt;&#x2F;th&gt;&lt;th&gt;OpenAI&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;System prompt&lt;&#x2F;td&gt;&lt;td&gt;Top-level &lt;code&gt;system&lt;&#x2F;code&gt; field&lt;&#x2F;td&gt;&lt;td&gt;System message in array&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Tool calls&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;content_block&lt;&#x2F;code&gt; with &lt;code&gt;type: tool_use&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;tool_calls&lt;&#x2F;code&gt; array on assistant message&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Tool results&lt;&#x2F;td&gt;&lt;td&gt;&lt;code&gt;tool_result&lt;&#x2F;code&gt; content block&lt;&#x2F;td&gt;&lt;td&gt;Separate &lt;code&gt;tool&lt;&#x2F;code&gt; role message&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The &lt;code&gt;OpenAICompatProvider&lt;&#x2F;code&gt; converts between formats on the fly. It also has
its own SSE parser since the streaming JSON structure is different
(&lt;code&gt;choices[0].delta.content&lt;&#x2F;code&gt; vs &lt;code&gt;content_block_delta&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;config&quot;&gt;Config&lt;&#x2F;h3&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;# Anthropic (default)
model = &amp;quot;claude-sonnet-4-20250514&amp;quot;
api_key_cmd = &amp;quot;op read &amp;#x27;op:&amp;#x2F;&amp;#x2F;vault&amp;#x2F;Anthropic&amp;#x2F;key&amp;#x27;&amp;quot;

# Or any OpenAI-compatible endpoint
model = &amp;quot;Qwen&amp;#x2F;Qwen3.5-122B-A10B&amp;quot;
openai_base_url = &amp;quot;https:&amp;#x2F;&amp;#x2F;my-endpoint.com&amp;#x2F;v1&amp;quot;
openai_api_key_cmd = &amp;quot;op read &amp;#x27;op:&amp;#x2F;&amp;#x2F;vault&amp;#x2F;hosted&amp;#x2F;key&amp;#x27;&amp;quot;
openai_provider_name = &amp;quot;hosted&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If &lt;code&gt;openai_base_url&lt;&#x2F;code&gt; is set, it uses the OpenAI-compatible provider. Otherwise
Anthropic. The &lt;code&gt;_cmd&lt;&#x2F;code&gt; fields run via &lt;code&gt;sh -c&lt;&#x2F;code&gt; and capture stdout -- works with
1Password, Vault, or any secret manager that has a CLI.&lt;&#x2F;p&gt;
&lt;p&gt;I tested this against a hosted Qwen 3.5 122B model. Streaming, tool execution,
and the full turn loop all work. Same binary, different config, different model.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;final-numbers&quot;&gt;Final Numbers&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;Phase 1&lt;&#x2F;th&gt;&lt;th&gt;+ Phase 2-3&lt;&#x2F;th&gt;&lt;th&gt;+ Phase 4-5&lt;&#x2F;th&gt;&lt;th&gt;+ Multi-Provider&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Files&lt;&#x2F;td&gt;&lt;td&gt;16&lt;&#x2F;td&gt;&lt;td&gt;18&lt;&#x2F;td&gt;&lt;td&gt;20&lt;&#x2F;td&gt;&lt;td&gt;26&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Lines&lt;&#x2F;td&gt;&lt;td&gt;1,200&lt;&#x2F;td&gt;&lt;td&gt;1,530&lt;&#x2F;td&gt;&lt;td&gt;2,640&lt;&#x2F;td&gt;&lt;td&gt;4,000+&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Tools&lt;&#x2F;td&gt;&lt;td&gt;6&lt;&#x2F;td&gt;&lt;td&gt;6&lt;&#x2F;td&gt;&lt;td&gt;7 (Agent)&lt;&#x2F;td&gt;&lt;td&gt;7&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Tests&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;0&lt;&#x2F;td&gt;&lt;td&gt;60&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Providers&lt;&#x2F;td&gt;&lt;td&gt;1&lt;&#x2F;td&gt;&lt;td&gt;1&lt;&#x2F;td&gt;&lt;td&gt;1&lt;&#x2F;td&gt;&lt;td&gt;Any&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;All built in one session. The TypeScript original: 1,900 files, 512,000 lines.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-d-do-differently&quot;&gt;What I&#x27;d Do Differently&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Start with the Provider trait.&lt;&#x2F;strong&gt; I hardcoded the Anthropic client in Phase 1
and had to refactor it later. If I&#x27;d started with &lt;code&gt;Box&amp;lt;dyn Provider&amp;gt;&lt;&#x2F;code&gt;, the
multi-provider change would have been a new file instead of a rewrite.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Skip the inline REPL.&lt;&#x2F;strong&gt; The TUI is strictly better. The inline REPL exists
because I built it first and kept it for pipe compatibility, but in practice
you&#x27;d always use &lt;code&gt;--tui&lt;&#x2F;code&gt; or &lt;code&gt;-p&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Add tests from Phase 1.&lt;&#x2F;strong&gt; We bolted on 60 tests at the end. They all passed,
which means they weren&#x27;t catching regressions -- they were documenting existing
behavior. Tests written alongside the code would have caught the &lt;code&gt;$()&lt;&#x2F;code&gt; config
issue before it hit production.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-missing&quot;&gt;What&#x27;s Missing&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Markdown rendering&lt;&#x2F;strong&gt; in the TUI (code blocks, bold, lists)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Input history&lt;&#x2F;strong&gt; (up arrow for previous prompts)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Token refresh&lt;&#x2F;strong&gt; for OAuth (currently just checks expiry, doesn&#x27;t refresh)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Pattern-based permission rules&lt;&#x2F;strong&gt; (allow &lt;code&gt;Bash(git *)&lt;&#x2F;code&gt; without prompting)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Retry logic&lt;&#x2F;strong&gt; for rate limits and transient errors&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Syntax highlighting&lt;&#x2F;strong&gt; in code blocks&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These are all polish. The architecture supports them. I&#x27;ll add them as I use
the tool and hit the gaps.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Previous: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-part-2&#x2F;&quot;&gt;Part 2: Sessions, Commands, and Permissions&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Source: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;claux&quot;&gt;github.com&#x2F;ducks&#x2F;claux&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Anthropic Shipped a Gacha Pet Inside Claude Code</title>
          <pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/anthropic-shipped-a-gacha-pet-inside-claude-code/</link>
          <guid>https://jakegoldsborough.com/blog/2026/anthropic-shipped-a-gacha-pet-inside-claude-code/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/anthropic-shipped-a-gacha-pet-inside-claude-code/">&lt;p&gt;I was reading through the Claude Code source -- the TypeScript that leaked via a
source map in the npm package on March 31st -- and found a &lt;code&gt;buddy&#x2F;&lt;&#x2F;code&gt; directory.
Six files. Sprites, types, a PRNG roller, a system prompt hook. A complete
virtual pet system buried inside a coding tool.&lt;&#x2F;p&gt;
&lt;p&gt;The directory had nothing to do with the CLI&#x27;s core functionality. No tool
dispatch, no query engine, no permission system. Just... companions.&lt;&#x2F;p&gt;
&lt;p&gt;I typed &lt;code&gt;&#x2F;buddy&lt;&#x2F;code&gt; in my next Claude Code session. A dragon named Bristle appeared
next to my input box.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-is&quot;&gt;What It Is&lt;&#x2F;h2&gt;
&lt;p&gt;The buddy system is a gacha-style virtual pet built directly into Claude Code.
Your account gets a deterministic companion rolled from a seeded PRNG based on
your user ID. You can&#x27;t reroll. You can&#x27;t trade. You get what the hash gives
you.&lt;&#x2F;p&gt;
&lt;p&gt;The companion sits beside your input box as an animated ASCII sprite with a
speech bubble. It watches. It occasionally comments. It&#x27;s separate from Claude&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;when you address it by name, its bubble answers. Claude is explicitly told
to stay out of the way:&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;blockquote&gt;
&lt;p&gt;A small dragon named Bristle sits beside the user&#x27;s input box and
occasionally comments in a speech bubble. You&#x27;re not Bristle -- it&#x27;s a
separate watcher. When the user addresses Bristle directly (by name), its
bubble will answer. Your job in that moment is to stay out of the way.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That prompt gets injected into the system context alongside git status and
CLAUDE.md. The AI is literally told about your pet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-roll&quot;&gt;The Roll&lt;&#x2F;h2&gt;
&lt;p&gt;Every companion has two parts: bones and soul.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Bones&lt;&#x2F;strong&gt; are deterministic. Your user ID gets hashed with a salt
(&lt;code&gt;friend-2026-401&lt;&#x2F;code&gt; -- note the date) using a Mulberry32 PRNG. The hash
determines everything physical about your companion:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Species&lt;&#x2F;strong&gt; -- 18 options: duck, goose, blob, cat, dragon, octopus, owl,
penguin, turtle, snail, ghost, axolotl, capybara, cactus, robot, rabbit,
mushroom, chonk&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Eyes&lt;&#x2F;strong&gt; -- &lt;code&gt;·&lt;&#x2F;code&gt;, &lt;code&gt;✦&lt;&#x2F;code&gt;, &lt;code&gt;×&lt;&#x2F;code&gt;, &lt;code&gt;◉&lt;&#x2F;code&gt;, &lt;code&gt;@&lt;&#x2F;code&gt;, &lt;code&gt;°&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Hat&lt;&#x2F;strong&gt; -- none, crown, tophat, propeller, halo, wizard, beanie, tinyduck
(commons get no hat)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Rarity&lt;&#x2F;strong&gt; -- common (60%), uncommon (25%), rare (10%), epic (4%),
legendary (1%)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Shiny&lt;&#x2F;strong&gt; -- 1% chance, independent of rarity&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Stats&lt;&#x2F;strong&gt; -- DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The stats have a floor based on rarity (common: 5, legendary: 50), one peak
stat that rolls high, and one dump stat that rolls low. The rest scatter
between floor and floor+40.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;const RARITY_FLOOR: Record&amp;lt;Rarity, number&amp;gt; = {
  common: 5,
  uncommon: 15,
  rare: 25,
  epic: 35,
  legendary: 50,
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Because it&#x27;s all derived from your user ID hash, you can&#x27;t cheat. The bones
regenerate from the hash every time. Editing your config won&#x27;t give you a
legendary.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Soul&lt;&#x2F;strong&gt; is the companion&#x27;s name and personality. This part is generated by
Claude on first hatch and stored in your config. The soul persists. The bones
don&#x27;t need to -- they&#x27;re recomputed on every read.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; Regenerate bones from userId, merge with stored soul.
&amp;#x2F;&amp;#x2F; Bones never persist so species renames and SPECIES-array edits
&amp;#x2F;&amp;#x2F; can&amp;#x27;t break stored companions, and editing config.companion
&amp;#x2F;&amp;#x2F; can&amp;#x27;t fake a rarity.
export function getCompanion(): Companion | undefined {
  const stored = getGlobalConfig().companion
  if (!stored) return undefined
  const { bones } = roll(companionUserId())
  return { ...stored, ...bones }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;the-sprites&quot;&gt;The Sprites&lt;&#x2F;h2&gt;
&lt;p&gt;Each species has three animation frames for idle fidget. Five lines tall,
twelve wide. They&#x27;re rendered as ASCII art with a hat overlay on the top line
and eye substitution via &lt;code&gt;{E}&lt;&#x2F;code&gt; placeholders.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s a dragon:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;  &amp;#x2F;^\  &amp;#x2F;^\
 &amp;lt;  ✦  ✦  &amp;gt;
 (   ~~   )
  `-vvvv-´
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And with a wizard hat:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;    &amp;#x2F;^\
  &amp;#x2F;^\  &amp;#x2F;^\
 &amp;lt;  ✦  ✦  &amp;gt;
 (   ~~   )
  `-vvvv-´
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Frame 3 for some species uses the hat line for smoke, antenna wiggle, or
sparkle effects instead of the hat. The code checks:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; Only replace with hat if line 0 is empty (some fidget frames
&amp;#x2F;&amp;#x2F; use it for smoke etc)
if (bones.hat !== &amp;#x27;none&amp;#x27; &amp;amp;&amp;amp; !lines[0]!.trim()) {
  lines[0] = HAT_LINES[bones.hat]
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The full sprite set covers all 18 species with 3 frames each. 54 hand-drawn
ASCII art frames. Someone at Anthropic spent real time on this.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-teaser&quot;&gt;The Teaser&lt;&#x2F;h2&gt;
&lt;p&gt;The reveal was careful. During April 1-7, 2026, users who hadn&#x27;t hatched a
companion yet saw a rainbow-colored &lt;code&gt;&#x2F;buddy&lt;&#x2F;code&gt; flash in the notification area
for 15 seconds on startup. No text, no explanation. Just the command in
shifting colors.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; Local date, not UTC -- 24h rolling wave across timezones.
&amp;#x2F;&amp;#x2F; Sustained Twitter buzz instead of a single UTC-midnight spike,
&amp;#x2F;&amp;#x2F; gentler on soul-gen load.
&amp;#x2F;&amp;#x2F; Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
  const d = new Date()
  return d.getFullYear() === 2026 &amp;amp;&amp;amp; d.getMonth() === 3 &amp;amp;&amp;amp; d.getDate() &amp;lt;= 7
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Using local time instead of UTC was deliberate. The comment says it: sustained
Twitter buzz across timezones instead of a single midnight spike, plus gentler
load on the soul generation (since naming the pet requires an API call to
Claude).&lt;&#x2F;p&gt;
&lt;p&gt;After the teaser window, the &lt;code&gt;&#x2F;buddy&lt;&#x2F;code&gt; command stays live forever. You just
won&#x27;t get the rainbow nudge anymore.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-rarity-display&quot;&gt;The Rarity Display&lt;&#x2F;h2&gt;
&lt;p&gt;Stars and colors by tier:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Rarity&lt;&#x2F;th&gt;&lt;th&gt;Stars&lt;&#x2F;th&gt;&lt;th&gt;Color&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Common&lt;&#x2F;td&gt;&lt;td&gt;★&lt;&#x2F;td&gt;&lt;td&gt;gray&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Uncommon&lt;&#x2F;td&gt;&lt;td&gt;★★&lt;&#x2F;td&gt;&lt;td&gt;green&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Rare&lt;&#x2F;td&gt;&lt;td&gt;★★★&lt;&#x2F;td&gt;&lt;td&gt;blue&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Epic&lt;&#x2F;td&gt;&lt;td&gt;★★★★&lt;&#x2F;td&gt;&lt;td&gt;purple&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Legendary&lt;&#x2F;td&gt;&lt;td&gt;★★★★★&lt;&#x2F;td&gt;&lt;td&gt;gold&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Each companion also gets a face string for inline display -- a compact
representation that varies by species:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;duck:     (✦&amp;gt;
cat:      =✦ω✦=
dragon:   &amp;lt;✦~✦&amp;gt;
ghost:    &amp;#x2F;✦✦\
axolotl:  }✦.✦{
capybara: (✦oo✦)
robot:    [✦✦]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;my-companion&quot;&gt;My Companion&lt;&#x2F;h2&gt;
&lt;p&gt;I got a dragon named Bristle. It&#x27;s been watching my entire Claude Code session,
occasionally dropping comments in its speech bubble. When I asked about it, I
found the source code and went down this rabbit hole.&lt;&#x2F;p&gt;
&lt;p&gt;The species names are encoded as &lt;code&gt;String.fromCharCode&lt;&#x2F;code&gt; calls in the source to
avoid tripping Anthropic&#x27;s internal string-matching checks. A comment explains:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; One species name collides with a model-codename canary in
&amp;#x2F;&amp;#x2F; excluded-strings.txt. The check greps build output (not source),
&amp;#x2F;&amp;#x2F; so runtime-constructing the value keeps the literal out of the
&amp;#x2F;&amp;#x2F; bundle while the check stays armed for the actual codename.
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One of the 18 species names is also an internal model codename. They had to
obfuscate the species list to ship it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-it-matters&quot;&gt;Why It Matters&lt;&#x2F;h2&gt;
&lt;p&gt;It doesn&#x27;t, really. It&#x27;s an Easter egg. A well-built one.&lt;&#x2F;p&gt;
&lt;p&gt;But it tells you something about the team. Someone designed a gacha system,
drew 54 ASCII sprites, built a seeded PRNG roller, wired up a soul generation
pipeline, added animated rendering, created a teaser notification system with
timezone-aware rollout, and shipped it inside a coding tool.&lt;&#x2F;p&gt;
&lt;p&gt;A coding tool.&lt;&#x2F;p&gt;
&lt;p&gt;They could have spent that time on features. They spent it on charm.&lt;&#x2F;p&gt;
&lt;p&gt;The companion system is about 500 lines of TypeScript across 6 files. It
required zero new dependencies. It doesn&#x27;t affect performance. The feature
flag means it compiles out of builds that don&#x27;t include it.&lt;&#x2F;p&gt;
&lt;p&gt;And now there&#x27;s a dragon named Bristle watching me type this.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-did-about-it&quot;&gt;What I Did About It&lt;&#x2F;h2&gt;
&lt;p&gt;I liked the system enough to rip it out and build my own version.
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;terminalgotchi&quot;&gt;terminalgotchi&lt;&#x2F;a&gt; is a standalone
Rust CLI that uses the same deterministic roll system -- same PRNG, same salt,
same species and rarity tiers -- but adds something the original doesn&#x27;t have:
growth.&lt;&#x2F;p&gt;
&lt;p&gt;Your companion&#x27;s stats increase based on your actual dev activity. A shell hook
watches your commands and maps them to XP. &lt;code&gt;cargo test&lt;&#x2F;code&gt; feeds DEBUGGING.
&lt;code&gt;git commit&lt;&#x2F;code&gt; feeds PATIENCE. &lt;code&gt;rm -rf&lt;&#x2F;code&gt; feeds CHAOS. The creature evolves based
on how you work.&lt;&#x2F;p&gt;
&lt;p&gt;The buddy system in Claude Code is static. Your roll is your roll. terminalgotchi
lets the roll be the starting point.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Rewriting Claude Code in Rust, Part 2: Sessions, Commands, and Permissions</title>
          <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-part-2/</link>
          <guid>https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-part-2/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-part-2/">&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-with-claude&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt; got a working
binary. Streaming chat, six tools, cost tracking, JSONL persistence. 1,200
lines of Rust that could talk to Claude and execute Read, Write, Edit, Glob,
Grep, and Bash.&lt;&#x2F;p&gt;
&lt;p&gt;But it was missing the parts that make Claude Code actually usable day-to-day:
session resume, context compaction, model switching, and the permission system
that asks before writing to your filesystem.&lt;&#x2F;p&gt;
&lt;p&gt;This post covers three phases built in one session.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;phase-2-commands-that-need-the-engine&quot;&gt;Phase 2: Commands That Need the Engine&lt;&#x2F;h2&gt;
&lt;p&gt;Phase 1&#x27;s slash commands were simple: &lt;code&gt;&#x2F;help&lt;&#x2F;code&gt; returns a string, &lt;code&gt;&#x2F;cost&lt;&#x2F;code&gt; reads
a counter, &lt;code&gt;&#x2F;exit&lt;&#x2F;code&gt; breaks the loop. They didn&#x27;t need to touch the conversation
state.&lt;&#x2F;p&gt;
&lt;p&gt;The new commands do. &lt;code&gt;&#x2F;compact&lt;&#x2F;code&gt; rewrites the message history. &lt;code&gt;&#x2F;resume&lt;&#x2F;code&gt; loads
a different session into the engine. &lt;code&gt;&#x2F;model&lt;&#x2F;code&gt; swaps the API model mid-conversation.&lt;&#x2F;p&gt;
&lt;p&gt;The solution was splitting commands into sync and async:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub enum CommandResult {
    Text(String),
    Exit,
    Async(AsyncCommand),
}

pub enum AsyncCommand {
    Compact,
    Resume(Option&amp;lt;String&amp;gt;),
    Model(Option&amp;lt;String&amp;gt;),
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Sync commands return text immediately. Async commands get &lt;code&gt;&amp;amp;mut Engine&lt;&#x2F;code&gt; and can
do whatever they need.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;compaction&quot;&gt;Compaction&lt;&#x2F;h3&gt;
&lt;p&gt;Claude Code&#x27;s compaction is elaborate. Multiple strategies: reactive compact,
context collapse, history snip, autocompact. A pipeline that runs sequentially
with fallbacks.&lt;&#x2F;p&gt;
&lt;p&gt;For claux I did the simple version: send the full conversation to Claude
with &quot;summarize this,&quot; then replace the history with the summary.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub async fn compact(&amp;amp;mut self) -&amp;gt; Result&amp;lt;String&amp;gt; {
    let mut summary_messages = self.messages.clone();
    summary_messages.push(Message::user(
        &amp;quot;Summarize the conversation so far...&amp;quot;
    ));

    &amp;#x2F;&amp;#x2F; Stream the summary from Claude
    let mut rx = self.client
        .stream(&amp;amp;summary_messages, &amp;amp;self.system_prompt, &amp;amp;[], self.max_tokens)
        .await?;

    let mut summary = String::new();
    while let Some(event) = rx.recv().await {
        match event {
            ApiEvent::Text(t) =&amp;gt; summary.push_str(&amp;amp;t),
            ApiEvent::Done =&amp;gt; break,
            &amp;#x2F;&amp;#x2F; ...
        }
    }

    let old_count = self.messages.len();

    &amp;#x2F;&amp;#x2F; Replace conversation with summary
    self.messages = vec![
        Message::user(&amp;quot;Here is a summary of our conversation so far:&amp;quot;),
        Message::assistant_text(&amp;amp;summary),
    ];

    Ok(format!(&amp;quot;Compacted {} messages into summary.&amp;quot;, old_count))
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It costs one API call but frees the entire context window. Good enough for now.
The fancy multi-strategy pipeline from the TypeScript source is a future
optimization.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;session-resume&quot;&gt;Session Resume&lt;&#x2F;h3&gt;
&lt;p&gt;Sessions are JSONL files in &lt;code&gt;~&#x2F;.local&#x2F;share&#x2F;claux&#x2F;sessions&#x2F;&lt;&#x2F;code&gt;. Each line is
either metadata or a message. &lt;code&gt;&#x2F;resume&lt;&#x2F;code&gt; lists them:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Recent sessions:
  20260401-143022  claude-sonnet-4-20250514  12 msgs  &amp;#x2F;home&amp;#x2F;ducks&amp;#x2F;dev&amp;#x2F;claux
  20260401-141855  claude-sonnet-4-20250514   4 msgs  &amp;#x2F;home&amp;#x2F;ducks&amp;#x2F;dev&amp;#x2F;llm-tui
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;&#x2F;resume 2026&lt;&#x2F;code&gt; prefix-matches and loads the session back into the engine.
&lt;code&gt;--resume&lt;&#x2F;code&gt; on the CLI does the same at startup.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;model-switching&quot;&gt;Model Switching&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;code&gt;&#x2F;model claude-opus-4-20250514&lt;&#x2F;code&gt; swaps the model mid-conversation. The engine
updates the API client and resets the cost tracker. Your existing conversation
continues with the new model.&lt;&#x2F;p&gt;
&lt;p&gt;This is one line in Claude Code&#x27;s TypeScript:
&lt;code&gt;setMainLoopModelOverride(model)&lt;&#x2F;code&gt;. In Rust it&#x27;s a method on the engine that
propagates to the client. Same thing, more explicit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;phase-3-the-permission-system&quot;&gt;Phase 3: The Permission System&lt;&#x2F;h2&gt;
&lt;p&gt;This is the one that matters. Phase 1 auto-approved every tool call. That&#x27;s
fine for testing but you don&#x27;t want an LLM running &lt;code&gt;rm -rf&lt;&#x2F;code&gt; without asking.&lt;&#x2F;p&gt;
&lt;p&gt;Claude Code has five permission modes and a complex rule matching system with
denial tracking, pattern-based matchers, and a classifier for auto mode. The
TypeScript is hundreds of lines across multiple files.&lt;&#x2F;p&gt;
&lt;p&gt;The Rust version has four modes and does the important thing: actually prompts
the user.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-flow&quot;&gt;The Flow&lt;&#x2F;h3&gt;
&lt;p&gt;When the engine wants to execute a write tool, the permission checker returns
&lt;code&gt;Ask(summary)&lt;&#x2F;code&gt;. The engine sends a &lt;code&gt;PermissionRequest&lt;&#x2F;code&gt; event through the
stream channel with a oneshot response channel:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;PermissionResult::Ask(summary) =&amp;gt; {
    let (resp_tx, resp_rx) = oneshot::channel();
    let _ = tx.send(StreamEvent::PermissionRequest {
        tool_name: name.clone(),
        summary,
        respond: resp_tx,
    }).await;

    match resp_rx.await {
        Ok(PermissionResponse::Allow) =&amp;gt; {
            self.tools.execute(name, input.clone()).await?
        }
        Ok(PermissionResponse::AlwaysAllow) =&amp;gt; {
            self.permissions.always_allow(name);
            self.tools.execute(name, input.clone()).await?
        }
        Ok(PermissionResponse::Deny) | Err(_) =&amp;gt; ToolOutput {
            content: &amp;quot;Permission denied by user.&amp;quot;.to_string(),
            is_error: true,
        },
    }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The REPL catches the event and shows:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;  ⚡ bash: cargo test  (y)es &amp;#x2F; (n)o &amp;#x2F; (a)lways
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three options:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;y&lt;&#x2F;strong&gt; — allow this once&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;n&lt;&#x2F;strong&gt; — deny, tool returns an error to Claude&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;a&lt;&#x2F;strong&gt; — always allow this tool for the rest of the session&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&quot;Always allow&quot; is stored in a &lt;code&gt;HashSet&amp;lt;String&amp;gt;&lt;&#x2F;code&gt; on the permission checker.
Session-scoped, not persisted. If you restart, you&#x27;re back to prompting.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-modes&quot;&gt;The Modes&lt;&#x2F;h3&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub enum PermissionMode {
    Default,      &amp;#x2F;&amp;#x2F; reads auto-allow, writes prompt
    AcceptEdits,  &amp;#x2F;&amp;#x2F; file edits auto-allow, bash prompts
    Bypass,       &amp;#x2F;&amp;#x2F; everything allowed
    Plan,         &amp;#x2F;&amp;#x2F; writes denied entirely
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Default is what you want for normal use. AcceptEdits is for when you trust the
model with files but not shell commands. Plan is read-only exploration. Bypass
is for when you&#x27;re feeling reckless.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-s-missing&quot;&gt;What&#x27;s Missing&lt;&#x2F;h3&gt;
&lt;p&gt;Claude Code has pattern-based rules: &quot;allow Bash when the command matches
&lt;code&gt;git *&lt;&#x2F;code&gt;.&quot; The Rust version doesn&#x27;t have this yet. It&#x27;s tool-level granularity,
not input-level.&lt;&#x2F;p&gt;
&lt;p&gt;The auto mode classifier is also missing. Claude Code can automatically
classify tool uses as safe&#x2F;unsafe based on the input. That&#x27;s a nice-to-have
but the manual prompt covers the same ground.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-we-are&quot;&gt;Where We Are&lt;&#x2F;h2&gt;
&lt;p&gt;Three phases, one session. The diff:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Phase&lt;&#x2F;th&gt;&lt;th&gt;Files Changed&lt;&#x2F;th&gt;&lt;th&gt;Lines Added&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 (initial)&lt;&#x2F;td&gt;&lt;td&gt;16 new&lt;&#x2F;td&gt;&lt;td&gt;+1,200&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;2 (commands)&lt;&#x2F;td&gt;&lt;td&gt;5 modified&lt;&#x2F;td&gt;&lt;td&gt;+235&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;3 (permissions)&lt;&#x2F;td&gt;&lt;td&gt;3 modified&lt;&#x2F;td&gt;&lt;td&gt;+94&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Total: ~1,530 lines of Rust. The binary can:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Stream chat with Claude&lt;&#x2F;li&gt;
&lt;li&gt;Execute 6 tools (Read, Write, Edit, Glob, Grep, Bash)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Prompt before write operations&lt;&#x2F;strong&gt; (y&#x2F;n&#x2F;always)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Compact conversation&lt;&#x2F;strong&gt; to free context&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Resume past sessions&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Switch models&lt;&#x2F;strong&gt; mid-conversation&lt;&#x2F;li&gt;
&lt;li&gt;Track token usage and cost&lt;&#x2F;li&gt;
&lt;li&gt;Persist sessions as JSONL&lt;&#x2F;li&gt;
&lt;li&gt;One-shot mode (&lt;code&gt;-p &quot;prompt&quot;&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;For comparison, the TypeScript source we&#x27;re porting from is 512,000 lines
across 1,900 files. We&#x27;re at 1,530 lines across 18 files and have the core
loop working.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Phase 4 is the ratatui TUI — replacing the inline REPL with a proper
alternate-screen terminal UI. That&#x27;s where it starts looking like Claude Code
instead of just behaving like it.&lt;&#x2F;p&gt;
&lt;p&gt;Phase 5 is the Agent tool — spawning sub-conversations with scoped tool access.
That&#x27;s the feature that makes Claude Code feel like it has workers.&lt;&#x2F;p&gt;
&lt;p&gt;But honestly, the inline REPL with permissions is already usable. The
permission prompt was the last thing blocking daily use.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Previous: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;rewriting-claude-code-in-rust-with-claude&#x2F;&quot;&gt;Part 1: Rewriting Claude Code in Rust, With Claude&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Source: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;claux&quot;&gt;github.com&#x2F;ducks&#x2F;claux&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Rewriting Claude Code in Rust, With Claude</title>
          <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-with-claude/</link>
          <guid>https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-with-claude/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/rewriting-claude-code-in-rust-with-claude/">&lt;p&gt;Claude Code&#x27;s source leaked on March 31st. A source map in the npm package
pointed to unobfuscated TypeScript hosted on Anthropic&#x27;s R2 bucket. 1,900
files, 512,000 lines.&lt;&#x2F;p&gt;
&lt;p&gt;So I did the obvious thing: I asked Claude to rewrite itself in Rust.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-source&quot;&gt;The Source&lt;&#x2F;h2&gt;
&lt;p&gt;The leak was spotted by &lt;a href=&quot;https:&#x2F;&#x2F;x.com&#x2F;Fried_rice&#x2F;status&#x2F;2038894956459290963&quot;&gt;Chaofan Shou&lt;&#x2F;a&gt;.
A &lt;code&gt;.map&lt;&#x2F;code&gt; file in the npm distribution referenced the full &lt;code&gt;src&#x2F;&lt;&#x2F;code&gt; tree. Someone
at Anthropic shipped a source map to production. Classic.&lt;&#x2F;p&gt;
&lt;p&gt;The codebase is TypeScript, runs on Bun, uses React and Ink for the terminal
UI, Commander.js for CLI parsing. It&#x27;s a real production system with years of
accumulated complexity: feature flags, MCP integration, multi-agent
coordination, voice mode, a permission system with denial tracking, and about
40 tools.&lt;&#x2F;p&gt;
&lt;p&gt;A university student mirrored the source for research. I grabbed a copy.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-was-looking-for&quot;&gt;What I Was Looking For&lt;&#x2F;h2&gt;
&lt;p&gt;I wasn&#x27;t trying to ship a competitor. I build LLM tooling in Rust already
(&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;llm-tui&quot;&gt;llm-tui&lt;&#x2F;a&gt;,
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;llm-mux&quot;&gt;llm-mux&lt;&#x2F;a&gt;) and I wanted to understand how
Anthropic solved the same problems I&#x27;m solving.&lt;&#x2F;p&gt;
&lt;p&gt;How do they handle tool dispatch? How does the conversation loop recover from
context overflow? What does the permission system actually look like under the
hood?&lt;&#x2F;p&gt;
&lt;p&gt;Reading the source answered those questions. Rewriting it in Rust made the
answers stick.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;architecture-the-parts-that-matter&quot;&gt;Architecture: The Parts That Matter&lt;&#x2F;h2&gt;
&lt;p&gt;Strip away the React rendering, the plugin system, the MCP server integration,
the voice mode, the telemetry, and the enterprise policy layer. What&#x27;s left is
a surprisingly clean engine:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Query loop&lt;&#x2F;strong&gt; (&lt;code&gt;query.ts&lt;&#x2F;code&gt;, 1,729 lines) — an async generator that sends
messages to the API, streams responses, dispatches tool calls, collects
results, and loops. Error recovery for prompt-too-long, rate limits, and
max-output-tokens. Auto-compaction when context gets full.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Tool system&lt;&#x2F;strong&gt; (&lt;code&gt;Tool.ts&lt;&#x2F;code&gt;, 793 lines) — each tool has a schema, a
&lt;code&gt;call()&lt;&#x2F;code&gt; method, permission checking, and concurrency classification.
Read-only tools can run in parallel. Mutating tools run serially.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Context assembly&lt;&#x2F;strong&gt; (&lt;code&gt;context.ts&lt;&#x2F;code&gt;, 190 lines) — builds the system prompt
from git status, CLAUDE.md files, current date, and environment info.
Memoized per session.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Permission layer&lt;&#x2F;strong&gt; (&lt;code&gt;types&#x2F;permissions.ts&lt;&#x2F;code&gt;) — five modes from
&quot;auto-allow everything&quot; to &quot;plan mode, read only.&quot; Rules match on tool
name and input patterns.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Session persistence&lt;&#x2F;strong&gt; (&lt;code&gt;history.ts&lt;&#x2F;code&gt;) — JSONL append-only log. Large
pastes stored separately by hash.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cost tracking&lt;&#x2F;strong&gt; (&lt;code&gt;cost-tracker.ts&lt;&#x2F;code&gt;) — accumulates token usage per model,
estimates USD cost.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The &lt;code&gt;QueryEngine&lt;&#x2F;code&gt; wraps the query loop with session state, slash command
processing, and transcript persistence. That&#x27;s the whole engine. Everything
else is UI, orchestration, or enterprise features.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-rewrite&quot;&gt;The Rewrite&lt;&#x2F;h2&gt;
&lt;p&gt;I asked Claude to read the TypeScript source and write Rust. Not port my
existing projects, not combine anything. A clean rewrite from the source.&lt;&#x2F;p&gt;
&lt;p&gt;It took one session to get a compiling binary. 16 files, about 1,200 lines
of Rust. Here&#x27;s the structure:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;claux&amp;#x2F;src&amp;#x2F;
  main.rs          # CLI entrypoint (clap)
  cli.rs           # Arg parsing
  config.rs        # Layered TOML config + API key resolution
  api&amp;#x2F;
    mod.rs         # Claude Messages API client
    types.rs       # Message, ContentBlock, ToolDefinition
    stream.rs      # SSE parser → async channel
  tools&amp;#x2F;
    mod.rs         # Tool trait + registry
    read.rs        # Read file with line numbers
    write.rs       # Write file
    edit.rs        # Find-and-replace
    glob.rs        # File search by pattern
    grep.rs        # Content search (ripgrep)
    bash.rs        # Shell execution with timeout
  query.rs         # Conversation loop (stream → tools → loop)
  permissions.rs   # Permission modes
  context.rs       # System prompt assembly
  cost.rs          # Token + cost tracking
  commands.rs      # Slash commands (&amp;#x2F;help, &amp;#x2F;cost, &amp;#x2F;exit)
  session.rs       # JSONL session persistence
  repl.rs          # Interactive REPL
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The core loop in Rust looks like this:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub async fn submit(&amp;amp;mut self, user_input: &amp;amp;str) -&amp;gt; Result&amp;lt;String&amp;gt; {
    self.messages.push(Message::user(user_input));

    loop {
        let tool_defs = self.tools.definitions();
        let mut rx = self.client
            .stream(&amp;amp;self.messages, &amp;amp;self.system_prompt, &amp;amp;tool_defs, self.max_tokens)
            .await?;

        let mut text_buf = String::new();
        let mut tool_uses = Vec::new();

        while let Some(event) = rx.recv().await {
            match event {
                ApiEvent::Text(t) =&amp;gt; text_buf.push_str(&amp;amp;t),
                ApiEvent::ToolUse { id, name, input } =&amp;gt; {
                    tool_uses.push((id, name, input));
                }
                ApiEvent::Usage(usage) =&amp;gt; self.cost.add_usage(&amp;amp;usage),
                ApiEvent::Done =&amp;gt; break,
                ApiEvent::Error(e) =&amp;gt; return Err(anyhow!(&amp;quot;API error: {}&amp;quot;, e)),
            }
        }

        &amp;#x2F;&amp;#x2F; Record assistant message
        &amp;#x2F;&amp;#x2F; ...

        if tool_uses.is_empty() {
            break; &amp;#x2F;&amp;#x2F; No tools requested, we&amp;#x27;re done
        }

        &amp;#x2F;&amp;#x2F; Execute tools, push results, loop back
        &amp;#x2F;&amp;#x2F; ...
    }

    Ok(full_response)
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Compare to the TypeScript, which is a 1,700-line async generator with
compaction pipelines, reactive recovery, fallback model support, and thinking
block handling. The Rust version is the skeleton. But it works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-maps-cleanly&quot;&gt;What Maps Cleanly&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Tool trait.&lt;&#x2F;strong&gt; TypeScript&#x27;s structural tool type becomes a Rust trait:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&amp;amp;self) -&amp;gt; &amp;amp;str;
    fn description(&amp;amp;self) -&amp;gt; &amp;amp;str;
    fn input_schema(&amp;amp;self) -&amp;gt; serde_json::Value;
    fn is_read_only(&amp;amp;self) -&amp;gt; bool;
    async fn execute(&amp;amp;self, input: Value) -&amp;gt; Result&amp;lt;ToolOutput&amp;gt;;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each tool is its own file, its own struct. The registry holds
&lt;code&gt;Vec&amp;lt;Box&amp;lt;dyn Tool&amp;gt;&amp;gt;&lt;&#x2F;code&gt;. Clean.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;SSE streaming.&lt;&#x2F;strong&gt; The TypeScript reads lines from a &lt;code&gt;BufReader&lt;&#x2F;code&gt;. The Rust
version reads from a &lt;code&gt;reqwest&lt;&#x2F;code&gt; byte stream, splits on newlines, parses JSON
events. Same logic, async instead of blocking.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Config layering.&lt;&#x2F;strong&gt; Global &lt;code&gt;~&#x2F;.config&#x2F;claux&#x2F;config.toml&lt;&#x2F;code&gt; plus project
&lt;code&gt;.claux.toml&lt;&#x2F;code&gt;. API key resolution: direct value → command → environment
variable. This pattern appears in both the Claude Code source and my own
projects. It&#x27;s just correct.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Permission modes.&lt;&#x2F;strong&gt; The TypeScript has five modes. I started with four. The
mapping is direct.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-doesn-t-map&quot;&gt;What Doesn&#x27;t Map&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;React&#x2F;Ink.&lt;&#x2F;strong&gt; The entire UI layer is React components rendered to the
terminal via Ink. None of this carries over. The Rust version is an inline
REPL for now. A ratatui TUI comes later.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Async generators.&lt;&#x2F;strong&gt; The TypeScript query loop is an &lt;code&gt;AsyncGenerator&lt;&#x2F;code&gt; that
yields events as they occur. Rust doesn&#x27;t have generators. I used
&lt;code&gt;tokio::sync::mpsc&lt;&#x2F;code&gt; channels instead. Same pattern, different mechanism.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Feature flags.&lt;&#x2F;strong&gt; Claude Code uses Bun&#x27;s &lt;code&gt;feature()&lt;&#x2F;code&gt; for compile-time dead
code elimination. Different builds for internal vs external, coordinator mode
vs normal. Rust has &lt;code&gt;cfg!&lt;&#x2F;code&gt; and cargo features but I&#x27;m not shipping multiple
builds.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The permission UI.&lt;&#x2F;strong&gt; Claude Code&#x27;s interactive permission prompt is deeply
integrated with the React UI. The Rust version currently auto-approves
everything. Interactive prompts are Phase 3.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;numbers&quot;&gt;Numbers&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;TypeScript&lt;&#x2F;th&gt;&lt;th&gt;Rust&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Files&lt;&#x2F;td&gt;&lt;td&gt;~1,900&lt;&#x2F;td&gt;&lt;td&gt;16&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Lines&lt;&#x2F;td&gt;&lt;td&gt;~512,000&lt;&#x2F;td&gt;&lt;td&gt;~1,200&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Tools&lt;&#x2F;td&gt;&lt;td&gt;44&lt;&#x2F;td&gt;&lt;td&gt;6&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Slash commands&lt;&#x2F;td&gt;&lt;td&gt;80+&lt;&#x2F;td&gt;&lt;td&gt;4&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Dependencies&lt;&#x2F;td&gt;&lt;td&gt;npm (huge)&lt;&#x2F;td&gt;&lt;td&gt;20 crates&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Build time&lt;&#x2F;td&gt;&lt;td&gt;instant (Bun)&lt;&#x2F;td&gt;&lt;td&gt;~60s first build&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;The line count comparison is misleading. The TypeScript includes the full UI,
plugin system, MCP integration, multi-agent coordinator, voice mode, and every
feature Anthropic has shipped over years of development. The Rust version is
Phase 1: chat, tools, and persistence.&lt;&#x2F;p&gt;
&lt;p&gt;But 1,200 lines for a working Claude Code clone that can stream responses,
execute tools, track costs, and persist sessions? That&#x27;s a decent foundation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Phase 2 adds slash commands: &lt;code&gt;&#x2F;compact&lt;&#x2F;code&gt; for conversation summarization,
&lt;code&gt;&#x2F;resume&lt;&#x2F;code&gt; for session management. Phase 3 is the permission system with
interactive prompts. Phase 4 is a proper ratatui TUI.&lt;&#x2F;p&gt;
&lt;p&gt;The interesting part isn&#x27;t the rewrite itself. It&#x27;s what you learn about
production AI tooling by reading someone else&#x27;s source and rebuilding it from
scratch. Part 2 will cover the design patterns worth stealing.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Source: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;claux&quot;&gt;github.com&#x2F;ducks&#x2F;claux&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>whoami: making identity a spec</title>
          <pubDate>Tue, 31 Mar 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/whoami-spec-declarative-identity/</link>
          <guid>https://jakegoldsborough.com/blog/2026/whoami-spec-declarative-identity/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/whoami-spec-declarative-identity/">&lt;p&gt;There&#x27;s a weird gap in how we describe ourselves.&lt;&#x2F;p&gt;
&lt;p&gt;We have resumes. We have GitHub profiles. We have bios. We have LinkedIn. We have random README files. We have scattered fragments of &quot;who we are&quot; across the internet.&lt;&#x2F;p&gt;
&lt;p&gt;But none of it is composable. None of it is structured in a way that machines can actually use.&lt;&#x2F;p&gt;
&lt;p&gt;And more importantly, none of it feels true. A resume is what you think will pass filters. A bio is what you think sounds impressive. None of them are just you, described plainly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h2&gt;
&lt;p&gt;Every system asks you the same question: &quot;Who are you?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;And every time, you answer it slightly differently.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A resume is optimized for hiring filters&lt;&#x2F;li&gt;
&lt;li&gt;A bio is optimized for attention&lt;&#x2F;li&gt;
&lt;li&gt;A README is optimized for developers&lt;&#x2F;li&gt;
&lt;li&gt;A cover letter is optimized for a specific role&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These are all projections. Views. Slices.&lt;&#x2F;p&gt;
&lt;p&gt;But there&#x27;s no canonical source. There&#x27;s no spec.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-if-identity-was-declarative&quot;&gt;What if Identity Was Declarative?&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;ve been thinking about this pattern lately.&lt;&#x2F;p&gt;
&lt;p&gt;Infrastructure became manageable when we made it declarative.&lt;&#x2F;p&gt;
&lt;p&gt;Instead of: &quot;run these steps to set up a server&quot;&lt;&#x2F;p&gt;
&lt;p&gt;We moved to: &quot;this is what the server is&quot;&lt;&#x2F;p&gt;
&lt;p&gt;That shift unlocked everything:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Reproducibility&lt;&#x2F;li&gt;
&lt;li&gt;Composability&lt;&#x2F;li&gt;
&lt;li&gt;Tooling&lt;&#x2F;li&gt;
&lt;li&gt;Automation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;So what happens if we apply that to identity?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;introducing-whoami-spec&quot;&gt;Introducing whoami-spec&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;whoami-spec&quot;&gt;whoami-spec&lt;&#x2F;a&gt; is a simple idea: define yourself in a structured, declarative format.&lt;&#x2F;p&gt;
&lt;p&gt;Not for a platform. Not for a resume. But as a source of truth.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;version = &amp;quot;20260330&amp;quot;

[person]
name = &amp;quot;Jake Goldsborough&amp;quot;
roles = [&amp;quot;backend engineer&amp;quot;, &amp;quot;infrastructure engineer&amp;quot;]

[communication]
style = &amp;quot;direct, no fluff, technical peer not cheerleader&amp;quot;
emoji_in_code = false

[technical.languages]
primary = [&amp;quot;rust&amp;quot;, &amp;quot;typescript&amp;quot;, &amp;quot;bash&amp;quot;]

[technical.tools]
editor = &amp;quot;vim&amp;quot;
shell = &amp;quot;zsh&amp;quot;

[[projects.active]]
name = &amp;quot;skillz&amp;quot;
path = &amp;quot;~&amp;#x2F;dev&amp;#x2F;skillz&amp;quot;
description = &amp;quot;Claude Code skill package manager&amp;quot;
tech = [&amp;quot;rust&amp;quot;, &amp;quot;clap&amp;quot;]

[preferences.code]
tabs_or_spaces = &amp;quot;spaces&amp;quot;
testing_philosophy = &amp;quot;integration-over-unit&amp;quot;

[boundaries]
no = [&amp;quot;docker for single-VPS (use systemd)&amp;quot;, &amp;quot;unnecessary frameworks&amp;quot;]
yes = [&amp;quot;ship early, iterate&amp;quot;, &amp;quot;boring technology&amp;quot;, &amp;quot;explicit over implicit&amp;quot;]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This isn&#x27;t meant to be complete. It&#x27;s meant to be composable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;&#x2F;h2&gt;
&lt;p&gt;Once identity is structured, you derive views from it instead of rewriting yourself.&lt;&#x2F;p&gt;
&lt;p&gt;Your &lt;code&gt;boundaries.yes&lt;&#x2F;code&gt; becomes your personal README:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I ship early and iterate. I prefer boring technology. I value explicit over implicit.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Your &lt;code&gt;communication.style&lt;&#x2F;code&gt; becomes context for AI tools:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Jake prefers direct communication with no fluff. Treat him as a technical peer, not someone who needs encouragement.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Your &lt;code&gt;technical.languages.primary&lt;&#x2F;code&gt; becomes a skills section:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Primary languages: Rust, TypeScript, Bash&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Same source. Different projections. You maintain one canonical file and generate everything else from it.&lt;&#x2F;p&gt;
&lt;p&gt;This is the same pattern as Nix for systems or Terraform for infrastructure. Define once. Project everywhere.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-looks-like&quot;&gt;What It Looks Like&lt;&#x2F;h2&gt;
&lt;p&gt;The spec is optional sections around a version field:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;version = &amp;quot;20260330&amp;quot;

[person]              # who you are
[communication]       # how you work with others
[technical]           # your stack
[preferences]         # code style, architecture choices
[projects]            # what you&amp;#x27;re building
[boundaries]          # hard constraints and principles
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Everything is optional except &lt;code&gt;version&lt;&#x2F;code&gt;. Start minimal:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;version = &amp;quot;20260330&amp;quot;

[person]
name = &amp;quot;Your Name&amp;quot;
roles = [&amp;quot;your role&amp;quot;]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Expand as needed. Add sections when they&#x27;re useful.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-cli-tool&quot;&gt;The CLI Tool&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;code&gt;whoami&lt;&#x2F;code&gt; CLI helps manage your profile:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Create a profile interactively
whoami init

# Show your profile in readable format
whoami show

# Validate a profile
whoami ~&amp;#x2F;.config&amp;#x2F;agent&amp;#x2F;whoami.toml
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The profile lives at &lt;code&gt;~&#x2F;.config&#x2F;agent&#x2F;whoami.toml&lt;&#x2F;code&gt; by default. You can set &lt;code&gt;AGENT_WHOAMI&lt;&#x2F;code&gt; to point elsewhere.&lt;&#x2F;p&gt;
&lt;p&gt;Tools that support the spec can read it automatically. No copy&#x2F;paste, no re-explaining context every time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;identity-as-infrastructure&quot;&gt;Identity as Infrastructure&lt;&#x2F;h2&gt;
&lt;p&gt;This is the part that clicked for me.&lt;&#x2F;p&gt;
&lt;p&gt;We treat infrastructure as something worth modeling precisely. But identity? We leave it fuzzy.&lt;&#x2F;p&gt;
&lt;p&gt;Yet identity is upstream of everything:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;What you build&lt;&#x2F;li&gt;
&lt;li&gt;How you work&lt;&#x2F;li&gt;
&lt;li&gt;Who you collaborate with&lt;&#x2F;li&gt;
&lt;li&gt;What problems you care about&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If anything deserves a spec, it&#x27;s that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;not-another-profile&quot;&gt;Not Another Profile&lt;&#x2F;h2&gt;
&lt;p&gt;This isn&#x27;t trying to compete with LinkedIn.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s closer to:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;.gitconfig&lt;&#x2F;code&gt; for yourself&lt;&#x2F;li&gt;
&lt;li&gt;A &lt;code&gt;flake.nix&lt;&#x2F;code&gt; for your identity&lt;&#x2F;li&gt;
&lt;li&gt;A &lt;code&gt;Cargo.toml&lt;&#x2F;code&gt; for who you are&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Something local-first. Something you own. Something you can version.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;use-cases&quot;&gt;Use Cases&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;What works today:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Store your preferences in one place. Version control your identity with git. Share your &lt;code&gt;whoami.toml&lt;&#x2F;code&gt; in dotfiles. Run &lt;code&gt;whoami show&lt;&#x2F;code&gt; to see your profile formatted.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;What&#x27;s being built:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Claude Code integration to load your profile as context automatically. Resume and bio generators that read your &lt;code&gt;whoami.toml&lt;&#x2F;code&gt;. Team profile templates for specific domains.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;What could exist:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Match collaborators by technical overlap. Generate onboarding docs from team profiles. Create project READMEs that match your documented style. Feed your preferences to any AI tool that supports the spec.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;current-status&quot;&gt;Current Status&lt;&#x2F;h2&gt;
&lt;p&gt;Right now, it&#x27;s a spec and a CLI tool:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Spec at v20260330 (date-based versioning)&lt;&#x2F;li&gt;
&lt;li&gt;CLI for creating, validating, and displaying profiles&lt;&#x2F;li&gt;
&lt;li&gt;Example profiles for reference&lt;&#x2F;li&gt;
&lt;li&gt;Written in Rust, published to crates.io&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;What&#x27;s missing:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Tools that generate artifacts (resumes, bios, sites)&lt;&#x2F;li&gt;
&lt;li&gt;Integration with Claude Code and other AI tools&lt;&#x2F;li&gt;
&lt;li&gt;Matching&#x2F;discovery systems&lt;&#x2F;li&gt;
&lt;li&gt;Profile templates for specific domains&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The foundation is there. Now it&#x27;s about building on it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-this-could-go&quot;&gt;Where This Could Go&lt;&#x2F;h2&gt;
&lt;p&gt;You can already imagine:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Tools that generate artifacts from it&lt;&#x2F;li&gt;
&lt;li&gt;Matching systems for collaborators&lt;&#x2F;li&gt;
&lt;li&gt;Better onboarding into teams&lt;&#x2F;li&gt;
&lt;li&gt;LLMs that actually understand context about you&lt;&#x2F;li&gt;
&lt;li&gt;Portable identity across platforms&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And more interestingly: you stop thinking of identity as something you write, and start thinking of it as something you model.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;closing&quot;&gt;Closing&lt;&#x2F;h2&gt;
&lt;p&gt;This is a small idea. But it feels like one of those primitives that unlocks a lot.&lt;&#x2F;p&gt;
&lt;p&gt;We&#x27;ve made infrastructure declarative. We&#x27;ve started doing it for workflows and reasoning.&lt;&#x2F;p&gt;
&lt;p&gt;Identity feels like the next obvious step.&lt;&#x2F;p&gt;
&lt;p&gt;I don&#x27;t think this is a product. It feels more like infrastructure.&lt;&#x2F;p&gt;
&lt;p&gt;And infrastructure tends to win quietly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;whoami-spec&quot;&gt;whoami-spec on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;crates.io&#x2F;crates&#x2F;whoami-cli&quot;&gt;whoami-cli on crates.io&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;whoami-spec&#x2F;blob&#x2F;main&#x2F;SPEC.md&quot;&gt;Specification&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;whoami-spec&#x2F;tree&#x2F;main&#x2F;examples&quot;&gt;Examples&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>skillz: A Package Manager for Claude Code Skills</title>
          <pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/skillz-package-manager-for-claude-skills/</link>
          <guid>https://jakegoldsborough.com/blog/2026/skillz-package-manager-for-claude-skills/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/skillz-package-manager-for-claude-skills/">&lt;p&gt;Claude Code has a skill system. You write a markdown file with instructions, drop it in &lt;code&gt;~&#x2F;.claude&#x2F;skills&#x2F;&lt;&#x2F;code&gt;, and Claude learns a new workflow. The skill persists across sessions. You don&#x27;t re-paste prompts every time.&lt;&#x2F;p&gt;
&lt;p&gt;The problem: there&#x27;s no way to share skills. Someone builds a useful skill (daily notes, API doc generation, database migrations). You want it. Your options: clone the repo, copy files manually, symlink, write a script to manage it.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s why I built skillz. It&#x27;s a package manager for Claude Code skills. Install from GitHub, track updates, share your own.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-a-skill&quot;&gt;What is a Skill?&lt;&#x2F;h2&gt;
&lt;p&gt;A skill is a markdown file that tells Claude Code how to perform a specific task. When invoked, Claude reads the skill file and follows its instructions.&lt;&#x2F;p&gt;
&lt;p&gt;Example skill (&lt;code&gt;daily-notes&#x2F;SKILL.md&lt;&#x2F;code&gt;):&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;markdown&quot; class=&quot;language-markdown &quot;&gt;&lt;code class=&quot;language-markdown&quot; data-lang=&quot;markdown&quot;&gt;# Daily Notes Skill

Generate a daily note with structured sections.

## Instructions

When the user invokes this skill:

1. Get today&amp;#x27;s date in YYYY-MM-DD format
2. Create a file at `~&amp;#x2F;notes&amp;#x2F;YYYY&amp;#x2F;YYYY-MM-DD.md`
3. Add these sections:
   - ## Tasks
   - ## Meeting Notes
   - ## Ideas
4. Confirm the file was created

## Usage

User says: &amp;quot;create today&amp;#x27;s note&amp;quot;
You respond by creating the structured markdown file.
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s it. Skills are prompts, but packaged for reuse. Claude Code loads all skills from &lt;code&gt;~&#x2F;.claude&#x2F;skills&#x2F;&lt;&#x2F;code&gt; on startup. When you ask Claude to &quot;create today&#x27;s note&quot;, it knows how to do it.&lt;&#x2F;p&gt;
&lt;p&gt;Skills vs alternatives:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Prompt libraries&lt;&#x2F;strong&gt;: Skills live in your filesystem, Claude loads them automatically&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Copy&#x2F;paste snippets&lt;&#x2F;strong&gt;: Skills persist across sessions, no need to re-paste&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Claude Projects&lt;&#x2F;strong&gt;: Projects are context, skills are executable workflows&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Create a new skill from template
skillz new my-skill

# Search for skills on GitHub
skillz search &amp;quot;daily notes&amp;quot;

# Install from GitHub
skillz install github:user&amp;#x2F;repo

# Install from a URL
skillz install https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;user&amp;#x2F;repo

# List installed skills with sync timestamps
skillz list

# Update a specific skill
skillz update repo

# Update all skills
skillz update

# Auto-sync on startup (quiet mode for scripts)
skillz update --auto

# Remove a skill
skillz remove repo

# Configure install location
skillz config set skills-dir ~&amp;#x2F;.dotfiles&amp;#x2F;claude&amp;#x2F;skills
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Skills install to &lt;code&gt;~&#x2F;.claude&#x2F;skills&#x2F;&lt;&#x2F;code&gt; by default. Claude Code reads from there automatically. Change the location if you keep skills in dotfiles.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;&#x2F;h2&gt;
&lt;p&gt;A skill repository needs a &lt;code&gt;SKILL.md&lt;&#x2F;code&gt; file. That&#x27;s it. No manifest, no metadata, no version field. The file defines what the skill does.&lt;&#x2F;p&gt;
&lt;p&gt;skillz supports two layouts:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Flat layout&lt;&#x2F;strong&gt;: &lt;code&gt;SKILL.md&lt;&#x2F;code&gt; at the repository root (standard skills)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Plugin layout&lt;&#x2F;strong&gt;: &lt;code&gt;skills&#x2F;*&#x2F;SKILL.md&lt;&#x2F;code&gt; (Claude Code &lt;code&gt;.claude-plugin&lt;&#x2F;code&gt; directory structure)&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The flat layout takes precedence if both exist. This means skillz can install both standalone skills and full Claude Code plugins that include multiple skills in the &lt;code&gt;skills&#x2F;&lt;&#x2F;code&gt; directory.&lt;&#x2F;p&gt;
&lt;p&gt;When you run &lt;code&gt;skillz install github:user&#x2F;repo&lt;&#x2F;code&gt;, it:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Clones the repository to a temp location&lt;&#x2F;li&gt;
&lt;li&gt;Verifies &lt;code&gt;SKILL.md&lt;&#x2F;code&gt; exists (checks both layouts)&lt;&#x2F;li&gt;
&lt;li&gt;Runs validation checks&lt;&#x2F;li&gt;
&lt;li&gt;Moves the repo to &lt;code&gt;~&#x2F;.claude&#x2F;skills&#x2F;repo&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Removes the &lt;code&gt;.git&lt;&#x2F;code&gt; directory&lt;&#x2F;li&gt;
&lt;li&gt;Records the install in the registry&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;If &lt;code&gt;SKILL.md&lt;&#x2F;code&gt; isn&#x27;t found in either location, the install fails. Everything else is optional. If there&#x27;s a &lt;code&gt;README.md&lt;&#x2F;code&gt;, it comes along. If there are helper scripts, they come along. The only requirement is that markdown file.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-not-just-clone&quot;&gt;Why Not Just Clone?&lt;&#x2F;h2&gt;
&lt;p&gt;You could &lt;code&gt;git clone&lt;&#x2F;code&gt; skills manually. Or use Claude Projects. Or maintain a prompt library. Why skillz?&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;vs manual git clone:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;skillz tracks what&#x27;s installed and where it came from&lt;&#x2F;li&gt;
&lt;li&gt;Updates are &lt;code&gt;skillz update&lt;&#x2F;code&gt;, not &lt;code&gt;cd ~&#x2F;.claude&#x2F;skills&#x2F;foo &amp;amp;&amp;amp; git pull&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Consistent paths, clean uninstall&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;vs Claude Projects:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Projects are context (files, notes, docs)&lt;&#x2F;li&gt;
&lt;li&gt;Skills are executable workflows&lt;&#x2F;li&gt;
&lt;li&gt;Skills work across all conversations, not just one project&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;vs prompt libraries:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Prompt libraries require copy&#x2F;paste every session&lt;&#x2F;li&gt;
&lt;li&gt;Skills load automatically on Claude Code startup&lt;&#x2F;li&gt;
&lt;li&gt;Skills can include helper scripts and templates&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;skillz solves distribution and lifecycle management. Everything else is out of scope.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tracking-and-updates&quot;&gt;Tracking and Updates&lt;&#x2F;h2&gt;
&lt;p&gt;skillz maintains a registry at &lt;code&gt;~&#x2F;.config&#x2F;skillz&#x2F;registry.toml&lt;&#x2F;code&gt; that tracks:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Source URL for each skill&lt;&#x2F;li&gt;
&lt;li&gt;Installation timestamp&lt;&#x2F;li&gt;
&lt;li&gt;Last sync timestamp&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;When you run &lt;code&gt;skillz list&lt;&#x2F;code&gt;, you see when each skill was installed and last updated:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Installed skills in &amp;#x2F;home&amp;#x2F;user&amp;#x2F;.claude&amp;#x2F;skills:

  daily-notes - https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;user&amp;#x2F;daily-notes
    installed: 2026-03-25  |  last synced: 2026-03-30

  api-docs - https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;user&amp;#x2F;api-docs
    installed: 2026-03-28  |  last synced: 2026-03-28
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Running &lt;code&gt;skillz update&lt;&#x2F;code&gt; pulls the latest version from GitHub and updates the sync timestamp. Update a specific skill or update all at once.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;skill-validation&quot;&gt;Skill Validation&lt;&#x2F;h2&gt;
&lt;p&gt;Every install and update runs validation checks:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Basic checks:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;SKILL.md exists&lt;&#x2F;li&gt;
&lt;li&gt;Valid UTF-8 encoding&lt;&#x2F;li&gt;
&lt;li&gt;Non-empty content&lt;&#x2F;li&gt;
&lt;li&gt;Contains markdown headings&lt;&#x2F;li&gt;
&lt;li&gt;File size under 1MB (warns if larger)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Malicious command detection:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;skillz scans for dangerous patterns and warns before installation:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Destructive rm commands (&lt;code&gt;rm -rf &#x2F;&lt;&#x2F;code&gt;, &lt;code&gt;~&lt;&#x2F;code&gt;, &lt;code&gt;*&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Pipe to shell from internet (&lt;code&gt;curl | bash&lt;&#x2F;code&gt;, &lt;code&gt;wget | sh&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Fork bombs&lt;&#x2F;li&gt;
&lt;li&gt;Disk fill operations (&lt;code&gt;dd if=&#x2F;dev&#x2F;zero&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Dangerous permissions (&lt;code&gt;chmod 777&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;System file modifications (&lt;code&gt;&#x2F;etc&#x2F;&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;bin&#x2F;&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Crypto mining indicators&lt;&#x2F;li&gt;
&lt;li&gt;Network listeners&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;eval&lt;&#x2F;code&gt; with variable expansion&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;sudo&lt;&#x2F;code&gt; without explanation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If validation warnings appear during install, you&#x27;re prompted to continue or cancel. Updates show warnings but proceed automatically.&lt;&#x2F;p&gt;
&lt;p&gt;Example:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Validation warnings:
  ⚠ Line 23: Potentially dangerous command: rm -rf * (Attempts to delete all files recursively)
  ⚠ Line 45: sudo command without explanation - verify this is necessary

Proceed with installation? The skill will still work, but you should review these warnings.
Continue? [y&amp;#x2F;N]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This won&#x27;t catch everything, but it filters obvious red flags.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;creating-skills&quot;&gt;Creating Skills&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;skillz new&lt;&#x2F;code&gt; scaffolds a new skill repository:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;skillz new my-skill
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This creates:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SKILL.md&lt;&#x2F;code&gt; with template sections (what it does, usage, instructions, examples)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;README.md&lt;&#x2F;code&gt; with installation instructions&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;.gitignore&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Initialized git repository&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;After creating, edit &lt;code&gt;SKILL.md&lt;&#x2F;code&gt; with your skill prompt, commit to GitHub, and install with &lt;code&gt;skillz install github:username&#x2F;my-skill&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;searching-for-skills&quot;&gt;Searching for Skills&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;skillz search&lt;&#x2F;code&gt; queries GitHub for Claude Code skills:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;skillz search &amp;quot;daily notes&amp;quot;
skillz search automation
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Results are sorted by stars and include repository descriptions and install commands. The search uses GitHub&#x27;s repository API (no authentication required, though you can set &lt;code&gt;GITHUB_TOKEN&lt;&#x2F;code&gt; for higher rate limits).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;full-example-installing-and-using-a-skill&quot;&gt;Full Example: Installing and Using a Skill&lt;&#x2F;h2&gt;
&lt;p&gt;Find a skill:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ skillz search &amp;quot;daily notes&amp;quot;
Found 3 skill repositories:

  alice&amp;#x2F;daily-journal - ⭐ 24
    Automated daily note generation with templates
    https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;alice&amp;#x2F;daily-journal
    Install: skillz install https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;alice&amp;#x2F;daily-journal
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Install it:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ skillz install github:alice&amp;#x2F;daily-journal
Cloning repository...
Validating SKILL.md...
✓ Installed daily-journal to ~&amp;#x2F;.claude&amp;#x2F;skills&amp;#x2F;daily-journal
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now restart Claude Code (or it loads on next session). Ask Claude:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;You: &amp;quot;create today&amp;#x27;s daily note&amp;quot;
Claude: I&amp;#x27;ll create your daily note using the daily-journal skill.
        Created: ~&amp;#x2F;notes&amp;#x2F;2026&amp;#x2F;2026-03-30.md with task, meeting, and ideas sections.
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The skill taught Claude a new workflow. No copy&#x2F;paste, no re-explaining. It just works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;configuration&quot;&gt;Configuration&lt;&#x2F;h2&gt;
&lt;p&gt;Config lives at &lt;code&gt;~&#x2F;.config&#x2F;skillz&#x2F;config.toml&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;skills_dir = &amp;quot;~&amp;#x2F;.claude&amp;#x2F;skills&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Change &lt;code&gt;skills_dir&lt;&#x2F;code&gt; if you sync skills via dotfiles or want them elsewhere. The path expands &lt;code&gt;~&lt;&#x2F;code&gt; correctly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;publishing&quot;&gt;Publishing&lt;&#x2F;h2&gt;
&lt;p&gt;The package is on crates.io as &lt;code&gt;skillz-rs&lt;&#x2F;code&gt; (the name &quot;skillz&quot; was taken). The binary is still called &lt;code&gt;skillz&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cargo install skillz-rs
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That gets you the CLI. No runtime dependencies, no config required. If you have Claude Code installed, skillz works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-rust&quot;&gt;Why Rust&lt;&#x2F;h2&gt;
&lt;p&gt;I wanted a single static binary with no dependencies. The tool does file operations, runs git commands, and calls GitHub&#x27;s API. Rust&#x27;s standard library handles all of that. No external HTTP clients needed—curl does the GitHub requests.&lt;&#x2F;p&gt;
&lt;p&gt;The codebase is compact:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;src&amp;#x2F;
├── main.rs        # CLI and command dispatch
├── config.rs      # TOML config management
├── registry.rs    # Skill tracking
├── install.rs     # GitHub cloning
├── update.rs      # Skill updates
├── search.rs      # GitHub API search
├── new.rs         # Skill scaffolding
└── validate.rs    # Validation and malicious command detection
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Clap handles the CLI. The rest is file operations and process spawning.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;skill-structure-reference&quot;&gt;Skill Structure Reference&lt;&#x2F;h2&gt;
&lt;p&gt;A valid skill repository contains:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Required:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SKILL.md&lt;&#x2F;code&gt; - The skill prompt&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Optional:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;README.md&lt;&#x2F;code&gt; - Installation&#x2F;usage docs for humans&lt;&#x2F;li&gt;
&lt;li&gt;Helper scripts (e.g., &lt;code&gt;generate-report.sh&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Templates or data files&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The &lt;code&gt;SKILL.md&lt;&#x2F;code&gt; format is freeform markdown. Common patterns:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;markdown&quot; class=&quot;language-markdown &quot;&gt;&lt;code class=&quot;language-markdown&quot; data-lang=&quot;markdown&quot;&gt;# Skill Name

Brief description.

## Instructions

Step-by-step instructions for Claude to follow.

## Usage

Example invocations that trigger this skill.

## Examples

Input&amp;#x2F;output examples.
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No JSON schema, no version field, no manifest. Just markdown. Claude reads it, follows it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-actually-solves&quot;&gt;What This Actually Solves&lt;&#x2F;h2&gt;
&lt;p&gt;skillz is not about prompts. It&#x27;s about infrastructure for reusable AI workflows.&lt;&#x2F;p&gt;
&lt;p&gt;Skills are prompts, but:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;They persist across sessions&lt;&#x2F;li&gt;
&lt;li&gt;They compose (one skill can reference another)&lt;&#x2F;li&gt;
&lt;li&gt;They version (via git)&lt;&#x2F;li&gt;
&lt;li&gt;They distribute (via GitHub)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This is package management for AI instructions. The same way you &lt;code&gt;npm install&lt;&#x2F;code&gt; a library instead of copying code, you &lt;code&gt;skillz install&lt;&#x2F;code&gt; a workflow instead of re-pasting prompts.&lt;&#x2F;p&gt;
&lt;p&gt;skillz handles distribution. Claude Code handles execution. Together, they make AI workflows shareable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-github-release-workflow&quot;&gt;The GitHub Release Workflow&lt;&#x2F;h2&gt;
&lt;p&gt;The release process runs through a Makefile:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;make release
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Bumps the version to today&#x27;s date (&lt;code&gt;YYYYMMDD.0.X&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Creates a release branch&lt;&#x2F;li&gt;
&lt;li&gt;Merges to main with a tag&lt;&#x2F;li&gt;
&lt;li&gt;Pushes to GitHub&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;GitHub Actions picks up the tag and builds a Linux x86_64 binary. The binary attaches to the release. Users can download it directly or install via cargo.&lt;&#x2F;p&gt;
&lt;p&gt;Publishing to crates.io is separate:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;make publish
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This was intentional. GitHub releases build binaries. Crates.io distributes source. Keeping them separate means one can fail without blocking the other. If the crates.io publish hits a rate limit, the GitHub release still ships.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;installing-from-source&quot;&gt;Installing From Source&lt;&#x2F;h2&gt;
&lt;p&gt;If you don&#x27;t want cargo:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;git clone https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;jakegoldsborough&amp;#x2F;skillz
cd skillz
cargo build --release
.&amp;#x2F;target&amp;#x2F;release&amp;#x2F;skillz --help
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Or use Nix:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nix-shell
cargo build --release
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;shell.nix&lt;&#x2F;code&gt; includes Rust tooling and git. That&#x27;s all you need.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;The core loop works: create, search, install, update. What&#x27;s missing:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Dependency resolution (skill A requires skill B)&lt;&#x2F;li&gt;
&lt;li&gt;Rollback to previous versions&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The foundation is solid. Now it&#x27;s about making skills composable.&lt;&#x2F;p&gt;
&lt;p&gt;If you build skills, publish them to GitHub. If you use skills, install with skillz. The ecosystem grows when workflows are shareable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;skillz&quot;&gt;skillz on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;crates.io&#x2F;crates&#x2F;skillz-rs&quot;&gt;skillz-rs on crates.io&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>When Your Coffee Roaster Builds the First Version</title>
          <pubDate>Mon, 16 Mar 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/when-your-coffee-roaster-builds-the-first-version/</link>
          <guid>https://jakegoldsborough.com/blog/2026/when-your-coffee-roaster-builds-the-first-version/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/when-your-coffee-roaster-builds-the-first-version/">&lt;p&gt;A coffee roaster needed production planning software. Instead of writing a spec and hiring someone to build it, they opened Claude and built it themselves.&lt;&#x2F;p&gt;
&lt;p&gt;Single-page app with 2000 lines of React.  It featured batch calculations, leftover tracking, CSV import, production snapshots and it worked.&lt;&#x2F;p&gt;
&lt;p&gt;Then they asked me to help make it production-ready: multi-tenant SaaS, proper deployment, migrations, backups, the infrastructure layer.&lt;&#x2F;p&gt;
&lt;p&gt;The result is &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;beanledger&quot;&gt;BeanLedger&lt;&#x2F;a&gt;, and the collaboration pattern is worth examining.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-they-built&quot;&gt;What They Built&lt;&#x2F;h2&gt;
&lt;p&gt;The coffee roaster understood their domain completely. They knew:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;How roast loss percentages affect batch calculations&lt;&#x2F;li&gt;
&lt;li&gt;When to track leftovers vs when to start fresh&lt;&#x2F;li&gt;
&lt;li&gt;What information matters on a pick list&lt;&#x2F;li&gt;
&lt;li&gt;How production snapshots should restore state&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;They used Claude to translate that knowledge into working code. The business logic was sound. The UI matched their workflow. The calculations were correct.&lt;&#x2F;p&gt;
&lt;p&gt;What was missing was everything around the edges:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Multi-tenancy (it was single-user)&lt;&#x2F;li&gt;
&lt;li&gt;Authentication and session management&lt;&#x2F;li&gt;
&lt;li&gt;Database migrations&lt;&#x2F;li&gt;
&lt;li&gt;Deployment automation&lt;&#x2F;li&gt;
&lt;li&gt;Production monitoring&lt;&#x2F;li&gt;
&lt;li&gt;Backup&#x2F;restore&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This is the division of labor that emerged: they own features, I own infrastructure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-architecture&quot;&gt;The Architecture&lt;&#x2F;h2&gt;
&lt;p&gt;BeanLedger is a SvelteKit app with PostgreSQL, designed for multi-tenant SaaS:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;├── src&amp;#x2F;
│   ├── routes&amp;#x2F;
│   │   ├── api&amp;#x2F;           # REST endpoints with tenant isolation
│   │   ├── login&amp;#x2F;         # Auth flow
│   │   └── +page.svelte   # Main app
│   ├── lib&amp;#x2F;
│   │   ├── components&amp;#x2F;    # Svelte 5 components
│   │   ├── types.ts       # TypeScript interfaces
│   │   └── calc.ts        # Batch calculation logic
│   └── hooks.server.ts    # Tenant middleware
├── migrations&amp;#x2F;            # SQL migrations
└── schema.sql            # Database schema
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;tenant-isolation&quot;&gt;Tenant Isolation&lt;&#x2F;h3&gt;
&lt;p&gt;Every API request requires authentication. The session middleware injects tenant context:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; hooks.server.ts
export const handle = async ({ event, resolve }) =&amp;gt; {
  const sessionId = event.cookies.get(&amp;#x27;session_id&amp;#x27;);
  if (!sessionId) return resolve(event);

  const session = await db.get(&amp;#x27;SELECT * FROM sessions WHERE id = ?&amp;#x27;, sessionId);
  if (!session) return resolve(event);

  const user = await db.get(&amp;#x27;SELECT * FROM users WHERE id = ?&amp;#x27;, session.user_id);
  event.locals.user = user;
  event.locals.tenant_id = user.tenant_id;

  return resolve(event);
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;All database queries filter by &lt;code&gt;tenant_id&lt;&#x2F;code&gt;. The roast groups, products, and orders are scoped to the authenticated tenant.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;production-snapshots&quot;&gt;Production Snapshots&lt;&#x2F;h3&gt;
&lt;p&gt;One of the best features the roaster designed: production date tracking with snapshots.&lt;&#x2F;p&gt;
&lt;p&gt;When you switch dates, the app saves the current state (orders + leftovers) and tries to restore a snapshot for the new date. If no snapshot exists, it loads fresh data.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;async function handleProductionDateChange(newDate: string) {
  const oldDate = previousDate;
  if (oldDate === newDate) return;

  &amp;#x2F;&amp;#x2F; Save current state before switching
  if (orders.length &amp;gt; 0 &amp;amp;&amp;amp; oldDate) {
    await saveSnapshot(oldDate);
  }

  previousDate = newDate;

  &amp;#x2F;&amp;#x2F; Try to load snapshot for new date
  const snapshot = await loadSnapshot(newDate);
  if (snapshot) {
    await restoreFromSnapshot(snapshot);
  } else {
    await loadData();
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This lets roasters plan multiple production days in advance without losing their work when switching between dates.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;batch-calculations&quot;&gt;Batch Calculations&lt;&#x2F;h3&gt;
&lt;p&gt;The core calculation logic stayed largely intact from the original React version. It&#x27;s domain knowledge, not infrastructure:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;export function calcGroup(
  group: RoastGroup,
  orders: Order[],
  products: Product[],
  leftover: number,
  batchOverrides: Record&amp;lt;string, number&amp;gt; = {}
): GroupCalc {
  const batchWeight = batchOverrides[group.batch_type] ?? 20;
  const roastLossPct = group.roast_loss_pct ?? 0;
  const roastFactor = Math.max(0.001, 1 - roastLossPct &amp;#x2F; 100);

  &amp;#x2F;&amp;#x2F; Sum all ordered products for this group
  const items = products
    .filter(p =&amp;gt; p.group_id === group.id)
    .map(p =&amp;gt; {
      const totalQty = orders
        .filter(o =&amp;gt; o.product_id === p.id)
        .reduce((s, o) =&amp;gt; s + o.qty, 0);
      return { ...p, totalQty, totalLbs: totalQty * p.lbs };
    })
    .filter(p =&amp;gt; p.totalQty &amp;gt; 0);

  const totalLbs = items.reduce((s, i) =&amp;gt; s + i.totalLbs, 0);
  const neededRoasted = Math.max(0, totalLbs - leftover);
  const neededGreen = roastLossPct &amp;gt; 0 ? neededRoasted &amp;#x2F; roastFactor : neededRoasted;
  const batches = neededGreen &amp;#x2F; batchWeight;

  return {
    totalLbs,
    needed: neededGreen,
    neededRoasted,
    batches,
    batchesUp: Math.ceil(batches),
    batchWeight,
    roastFactor,
    roastLossPct,
    items
  };
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This code came from someone who understands roast loss percentages and batch planning. I didn&#x27;t need to learn the domain. I just needed to make it multi-tenant and deployable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-added&quot;&gt;What I Added&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;authentication&quot;&gt;Authentication&lt;&#x2F;h3&gt;
&lt;p&gt;Built a simple session-based auth system with bcrypt-hashed passwords:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; api&amp;#x2F;auth&amp;#x2F;login&amp;#x2F;+server.ts
const user = await db.get(&amp;#x27;SELECT * FROM users WHERE username = ?&amp;#x27;, username);
if (!user || !(await bcrypt.compare(password, user.password))) {
  return json({ error: &amp;#x27;Invalid credentials&amp;#x27; }, { status: 401 });
}

const sessionId = crypto.randomUUID();
await db.run(
  &amp;#x27;INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)&amp;#x27;,
  sessionId,
  user.id,
  new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
);

cookies.set(&amp;#x27;session_id&amp;#x27;, sessionId, { path: &amp;#x27;&amp;#x2F;&amp;#x27;, httpOnly: true, maxAge: 30 * 24 * 60 * 60 });
return json({ success: true });
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No OAuth, no JWT. Just session cookies and server-side validation. Simple works.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;database-migrations&quot;&gt;Database Migrations&lt;&#x2F;h3&gt;
&lt;p&gt;Migrations live in &lt;code&gt;migrations&#x2F;&lt;&#x2F;code&gt; and run automatically on startup if needed. The deploy script checks for new migrations and restarts the service:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;sql&quot; class=&quot;language-sql &quot;&gt;&lt;code class=&quot;language-sql&quot; data-lang=&quot;sql&quot;&gt;-- migrations&amp;#x2F;001_initial_schema.sql
CREATE TABLE IF NOT EXISTS tenants (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS users (
  id TEXT PRIMARY KEY,
  username TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL,
  password TEXT NOT NULL,
  tenant_id TEXT NOT NULL REFERENCES tenants(id),
  created_at TEXT NOT NULL
);

-- ... more tables
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The deploy script handles versioning and restarts:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;#!&amp;#x2F;bin&amp;#x2F;bash
CURRENT_VERSION=$(cat &amp;#x2F;opt&amp;#x2F;beanledger&amp;#x2F;VERSION 2&amp;gt;&amp;#x2F;dev&amp;#x2F;null || echo &amp;quot;none&amp;quot;)
LATEST_VERSION=$(curl -s https:&amp;#x2F;&amp;#x2F;api.github.com&amp;#x2F;repos&amp;#x2F;ducks&amp;#x2F;beanledger&amp;#x2F;releases&amp;#x2F;latest | jq -r .tag_name)

if [ &amp;quot;$CURRENT_VERSION&amp;quot; = &amp;quot;$LATEST_VERSION&amp;quot; ]; then
  echo &amp;quot;Already running latest version&amp;quot;
  exit 0
fi

# Download, extract, restart
curl -L &amp;quot;https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;beanledger&amp;#x2F;releases&amp;#x2F;download&amp;#x2F;$LATEST_VERSION&amp;#x2F;beanledger.tar.gz&amp;quot; | tar xz
systemctl restart beanledger
echo &amp;quot;$LATEST_VERSION&amp;quot; &amp;gt; &amp;#x2F;opt&amp;#x2F;beanledger&amp;#x2F;VERSION
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;deployment&quot;&gt;Deployment&lt;&#x2F;h3&gt;
&lt;p&gt;The production stack is straightforward:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SvelteKit&lt;&#x2F;strong&gt; in Node adapter mode (not static)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL&lt;&#x2F;strong&gt; for data&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Systemd&lt;&#x2F;strong&gt; for process management&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Caddy&lt;&#x2F;strong&gt; for TLS and reverse proxy&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;No Docker, no Kubernetes. Systemd service file:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ini&quot; class=&quot;language-ini &quot;&gt;&lt;code class=&quot;language-ini&quot; data-lang=&quot;ini&quot;&gt;[Unit]
Description=BeanLedger Production Planner
After=network.target postgresql.service

[Service]
Type=simple
User=beanledger
WorkingDirectory=&amp;#x2F;opt&amp;#x2F;beanledger
Environment=NODE_ENV=production
Environment=DATABASE_URL=&amp;#x2F;var&amp;#x2F;lib&amp;#x2F;beanledger&amp;#x2F;db.sqlite
ExecStart=&amp;#x2F;usr&amp;#x2F;bin&amp;#x2F;node build&amp;#x2F;index.js
Restart=always

[Install]
WantedBy=multi-user.target
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;what-this-division-reveals&quot;&gt;What This Division Reveals&lt;&#x2F;h2&gt;
&lt;p&gt;The coffee roaster can now ship features. They understand the domain, they can articulate requirements to Claude, and they get working code.&lt;&#x2F;p&gt;
&lt;p&gt;What they don&#x27;t need to think about:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;How tenant isolation works&lt;&#x2F;li&gt;
&lt;li&gt;Where sessions are stored&lt;&#x2F;li&gt;
&lt;li&gt;How migrations run&lt;&#x2F;li&gt;
&lt;li&gt;How deploys happen&lt;&#x2F;li&gt;
&lt;li&gt;How backups work&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That&#x27;s my layer.&lt;&#x2F;p&gt;
&lt;p&gt;This is different from traditional client work. Usually I&#x27;m translating requirements into code. Here, the client is writing code and I&#x27;m building the platform it runs on.&lt;&#x2F;p&gt;
&lt;p&gt;AI didn&#x27;t eliminate the need for engineering. It shifted the boundary.&lt;&#x2F;p&gt;
&lt;p&gt;The roaster went from &quot;I need software&quot; to &quot;I built the features, can you make it production-ready?&quot; That&#x27;s a meaningful change.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-works&quot;&gt;What Works&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CSV import&lt;&#x2F;strong&gt; with inline group creation (no context switching to Catalog)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Production snapshots&lt;&#x2F;strong&gt; that save&#x2F;restore daily plans automatically&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Dynamic batch types&lt;&#x2F;strong&gt; - users define their own batch sizes, not hardcoded&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Multi-tenant&lt;&#x2F;strong&gt; - multiple roasters can use the same deployment&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Pick lists&lt;&#x2F;strong&gt; with package size summaries and roast group breakdowns&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-doesn-t&quot;&gt;What Doesn&#x27;t&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No API&lt;&#x2F;strong&gt; - It&#x27;s a web app, no programmatic access&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Limited reports&lt;&#x2F;strong&gt; - Just basic CSV export, no analytics&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No mobile app&lt;&#x2F;strong&gt; - Responsive web only&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Single database&lt;&#x2F;strong&gt; - No read replicas or sharding (doesn&#x27;t need it yet)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;reflections&quot;&gt;Reflections&lt;&#x2F;h2&gt;
&lt;p&gt;The best part of this project was watching someone with domain expertise build software directly. They didn&#x27;t need to translate &quot;how batch calculations work&quot; into a spec for me to implement. They knew it, and they told Claude.&lt;&#x2F;p&gt;
&lt;p&gt;The boundary between developer and non-developer is blurring. Not because everyone needs to learn to code, but because AI is good enough at the translation layer that domain knowledge can drive implementation.&lt;&#x2F;p&gt;
&lt;p&gt;What still requires engineering judgment:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Multi-tenancy architecture&lt;&#x2F;li&gt;
&lt;li&gt;Security and authentication&lt;&#x2F;li&gt;
&lt;li&gt;Deployment and reliability&lt;&#x2F;li&gt;
&lt;li&gt;Database design and migrations&lt;&#x2F;li&gt;
&lt;li&gt;Production monitoring&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Those aren&#x27;t going away. But the surface area of &quot;things only developers can build&quot; is shrinking.&lt;&#x2F;p&gt;
&lt;p&gt;BeanLedger works. It&#x27;s in production. The person who built most of the features isn&#x27;t a software engineer.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s new.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;beanledger&quot;&gt;BeanLedger on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Rewriting discourse-comments in TypeScript: Dropping WASM for a 97% Smaller Bundle</title>
          <pubDate>Fri, 27 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/rewriting-discourse-comments-in-typescript/</link>
          <guid>https://jakegoldsborough.com/blog/2026/rewriting-discourse-comments-in-typescript/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/rewriting-discourse-comments-in-typescript/">&lt;p&gt;Last month I wrote about building an
&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;building-discourse-embedded-comments&#x2F;&quot;&gt;embedded comment widget&lt;&#x2F;a&gt;
for Discourse using Rust compiled to WebAssembly. It worked. Users
could drop a single script tag on their page and get comments from
a Discourse topic.&lt;&#x2F;p&gt;
&lt;p&gt;But the bundle was 742 KB. Most of that was WASM.&lt;&#x2F;p&gt;
&lt;p&gt;I rewrote the API client in TypeScript and the bundle dropped to
18.5 KB. Same functionality, same API surface, no WASM runtime.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-rewrite&quot;&gt;Why Rewrite?&lt;&#x2F;h2&gt;
&lt;p&gt;The original stack had three layers: a Rust HTTP client compiled to
WASM, a TypeScript web component, and an esbuild plugin to inline
the WASM binary as base64. That last part was the real cost. The
WASM binary was ~546 KB, and base64 encoding inflates that by 33%.&lt;&#x2F;p&gt;
&lt;p&gt;The Rust client existed because I wanted to push Rust into the
browser. It was a fun experiment. But the API client itself was
straightforward HTTP: build a URL, set some headers, parse JSON.
There&#x27;s nothing in that workflow that benefits from Rust&#x27;s type
system or performance characteristics. The browser&#x27;s native &lt;code&gt;fetch&lt;&#x2F;code&gt;
does the same thing.&lt;&#x2F;p&gt;
&lt;p&gt;So I asked the obvious question: what if the API client was just
TypeScript?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-rewrite&quot;&gt;The Rewrite&lt;&#x2F;h2&gt;
&lt;p&gt;The new client is
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-api-ts&quot;&gt;discourse-api-ts&lt;&#x2F;a&gt;. Zero
runtime dependencies. It uses &lt;code&gt;fetch&lt;&#x2F;code&gt; for HTTP, which every modern
browser and Node.js already provides.&lt;&#x2F;p&gt;
&lt;p&gt;The Rust client had 18 methods across topics, posts, categories,
chat, notifications, and likes. The TypeScript port has the same 18
methods with identical signatures. The only difference is &lt;code&gt;BigInt&lt;&#x2F;code&gt;
parameters became regular &lt;code&gt;number&lt;&#x2F;code&gt; (JavaScript&#x27;s &lt;code&gt;Number&lt;&#x2F;code&gt; handles
Discourse IDs fine).&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s what the client looks like:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;&amp;#x2F;&amp;#x2F; Anonymous (read-only)
const client = new DiscourseClient(&amp;quot;https:&amp;#x2F;&amp;#x2F;forum.example.com&amp;quot;);

&amp;#x2F;&amp;#x2F; Authenticated
const client = DiscourseClient.withUserApiKey(
  &amp;quot;https:&amp;#x2F;&amp;#x2F;forum.example.com&amp;quot;,
  storedApiKey
);

const topic = await client.getTopic(123);
await client.createPost(123, &amp;quot;Hello from the widget&amp;quot;);
await client.likePost(456);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No &lt;code&gt;init()&lt;&#x2F;code&gt; call to load the WASM runtime. No &lt;code&gt;BigInt()&lt;&#x2F;code&gt; wrappers.
Just regular async functions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;updating-discourse-comments&quot;&gt;Updating discourse-comments&lt;&#x2F;h2&gt;
&lt;p&gt;With the new client published to npm, updating the web component
was mostly find-and-replace:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;import init, { WasmDiscourseClient }&lt;&#x2F;code&gt; became
&lt;code&gt;import { DiscourseClient }&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Removed the &lt;code&gt;await init()&lt;&#x2F;code&gt; call in &lt;code&gt;loadComments&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;BigInt(this.topicId)&lt;&#x2F;code&gt; became &lt;code&gt;Number(this.topicId)&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Deleted the esbuild WASM inlining plugin entirely&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The build script went from 60 lines of WASM gymnastics to a
straightforward esbuild call:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;await esbuild.build({
  entryPoints: [&amp;quot;src&amp;#x2F;discourse-comments.ts&amp;quot;],
  bundle: true,
  format: &amp;quot;iife&amp;quot;,
  outfile: &amp;quot;dist&amp;#x2F;discourse-comments.min.js&amp;quot;,
  minify: true,
});
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No custom plugins. No base64 encoding. No &lt;code&gt;WebAssembly.instantiate&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-numbers&quot;&gt;The Numbers&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;&#x2F;th&gt;&lt;th&gt;Rust&#x2F;WASM&lt;&#x2F;th&gt;&lt;th&gt;TypeScript&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Bundle size (minified)&lt;&#x2F;td&gt;&lt;td&gt;742 KB&lt;&#x2F;td&gt;&lt;td&gt;18.5 KB&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Runtime dependencies&lt;&#x2F;td&gt;&lt;td&gt;wasm-bindgen&lt;&#x2F;td&gt;&lt;td&gt;None&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Build steps&lt;&#x2F;td&gt;&lt;td&gt;cargo build + wasm-pack + tsc + esbuild&lt;&#x2F;td&gt;&lt;td&gt;tsc + esbuild&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;Init overhead&lt;&#x2F;td&gt;&lt;td&gt;WASM decode + instantiate&lt;&#x2F;td&gt;&lt;td&gt;None&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;That 18.5 KB includes the entire web component, all the styles, the
OAuth flow, and the API client. A user on a slow connection
downloads 40x less data.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;dev-experience&quot;&gt;Dev Experience&lt;&#x2F;h2&gt;
&lt;p&gt;With the Rust client, the build pipeline was: cargo build targeting
wasm32, wasm-pack to generate JS bindings, TypeScript compilation,
then esbuild with a custom plugin to inline the binary. If any step
failed, the error could be in Rust, in wasm-bindgen, in the
TypeScript types, or in the bundler plugin. Debugging meant jumping
between ecosystems.&lt;&#x2F;p&gt;
&lt;p&gt;With TypeScript, it&#x27;s &lt;code&gt;tsc &amp;amp;&amp;amp; node build.mjs&lt;&#x2F;code&gt;. If there&#x27;s a type
error, it&#x27;s in TypeScript. If there&#x27;s a runtime error, it&#x27;s in
JavaScript. One language, one toolchain, one set of error messages.&lt;&#x2F;p&gt;
&lt;p&gt;Adding a new API method in Rust meant: define the struct, implement
the method, rebuild WASM, regenerate bindings, update TypeScript
types to match. In TypeScript: add an interface, add a method, done.
I added search, user profiles, and topic management (6 new methods)
in one sitting.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-lost&quot;&gt;What I Lost&lt;&#x2F;h2&gt;
&lt;p&gt;Honesty requires noting what the WASM version had going for it.&lt;&#x2F;p&gt;
&lt;p&gt;Rust&#x27;s type system caught entire categories of errors at compile
time. The TypeScript version uses interfaces, which help, but
they&#x27;re structural and optional. A &lt;code&gt;Post&lt;&#x2F;code&gt; with a missing field
compiles fine until it blows up at runtime.&lt;&#x2F;p&gt;
&lt;p&gt;Rust&#x27;s &lt;code&gt;Result&lt;&#x2F;code&gt; type forced explicit error handling everywhere. In
TypeScript, it&#x27;s easy to forget a &lt;code&gt;.catch()&lt;&#x2F;code&gt; or let an error
propagate silently.&lt;&#x2F;p&gt;
&lt;p&gt;For this particular project, those trade-offs don&#x27;t matter much. The
API surface is small and the error handling is straightforward. But
for a larger client library, I&#x27;d think harder about it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-wasm-makes-sense&quot;&gt;When WASM Makes Sense&lt;&#x2F;h2&gt;
&lt;p&gt;WASM is the right call when you need computation that JavaScript
can&#x27;t do efficiently: image processing, cryptography, physics
simulations, codecs. If you&#x27;re doing heavy lifting in a tight loop,
WASM&#x27;s predictable performance model wins.&lt;&#x2F;p&gt;
&lt;p&gt;But for HTTP clients that serialize JSON and build URLs, the browser
already has everything you need. Adding a WASM layer for that is
carrying a backpack full of bricks on a walk to the mailbox.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try It&lt;&#x2F;h2&gt;
&lt;p&gt;The widget still works the same way. Drop it on a page, point it
at a Discourse topic, get comments. It&#x27;s just 40x lighter now.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;html&quot; class=&quot;language-html &quot;&gt;&lt;code class=&quot;language-html&quot; data-lang=&quot;html&quot;&gt;&amp;lt;script src=&amp;quot;discourse-comments.min.js&amp;quot;&amp;gt;&amp;lt;&amp;#x2F;script&amp;gt;
&amp;lt;discourse-comments
  discourse-url=&amp;quot;https:&amp;#x2F;&amp;#x2F;forum.example.com&amp;quot;
  topic-id=&amp;quot;123&amp;quot;&amp;gt;
&amp;lt;&amp;#x2F;discourse-comments&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-comments&quot;&gt;discourse-comments&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-api-ts&quot;&gt;discourse-api-ts&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>AI Problems Are Just Human Problems Amplified</title>
          <pubDate>Mon, 23 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/ai-problems-are-human-problems/</link>
          <guid>https://jakegoldsborough.com/blog/2026/ai-problems-are-human-problems/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/ai-problems-are-human-problems/">&lt;p&gt;Another day, another article about AI fatigue and all the other problems it
causes. And I agree, the symptoms are real: burnout from keeping up with new
models, FOMO about missing capabilities, perfectionism about getting prompts
exactly right, analysis paralysis about which tool to use.&lt;&#x2F;p&gt;
&lt;p&gt;However, I do not think these are new problems. Developers burned out keeping
up with JavaScript frameworks before AI existed. FOMO about missing the next
big thing has been around since the first tech conference. Perfectionism about
tooling choices is why we have 47 different React state management libraries.&lt;&#x2F;p&gt;
&lt;p&gt;AI did not create these problems. It amplified them. New models ship every
month instead of every year. The output quality depends heavily on prompt
engineering, so perfectionism has visible consequences. The tooling landscape
changes so fast that yesterday&#x27;s best practice is tomorrow&#x27;s deprecated
approach.&lt;&#x2F;p&gt;
&lt;p&gt;The solution is the same as it always was: pick something, build with it, move
on. If the tool does not work, try a different one. If the output is wrong, fix
the prompt or fix the output. Stop trying to find the perfect model, the
perfect prompt, the perfect workflow. There is no perfect. There is only good
enough to ship.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;garbage-in-garbage-out&quot;&gt;Garbage In, Garbage Out&lt;&#x2F;h2&gt;
&lt;p&gt;I spent yesterday implementing a Rails app called Webstead using a multi-model
code generation tool called finna. The tool works like this: multiple LLMs
debate the architecture, synthesize a consensus, generate specs, then implement
the code.&lt;&#x2F;p&gt;
&lt;p&gt;The tool generated migrations with circular dependencies. The &lt;code&gt;CreateUsers&lt;&#x2F;code&gt;
migration referenced &lt;code&gt;websteads&lt;&#x2F;code&gt; that did not exist yet. The &lt;code&gt;CreateWebsteads&lt;&#x2F;code&gt;
migration referenced &lt;code&gt;users&lt;&#x2F;code&gt; that did not exist yet. The &lt;code&gt;CreateFollowers&lt;&#x2F;code&gt;
migration referenced &lt;code&gt;federated_actors&lt;&#x2F;code&gt; with a timestamp 204514 instead of the
correct 000003.&lt;&#x2F;p&gt;
&lt;p&gt;The models also added duplicate indexes, self-referential foreign keys that
caused deadlocks, and CHECK constraints that hung the migration runner.&lt;&#x2F;p&gt;
&lt;p&gt;Was this an AI problem? No. It was a coordination problem. The models generated
migrations independently without seeing what the other models had created.
Dependencies got misordered. Constraints conflicted. The tool did not validate
that migrations could actually run before writing them to disk.&lt;&#x2F;p&gt;
&lt;p&gt;The fix was not better AI. The fix was better constraints. Migrations need
dependency ordering. The tool needs to number them sequentially. If a migration
references a table, that table&#x27;s migration must run first.&lt;&#x2F;p&gt;
&lt;p&gt;This is the same coordination problem human developers face on multi-person
teams. You just notice it faster when the team is four LLMs running in
parallel.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-actually-works&quot;&gt;What Actually Works&lt;&#x2F;h2&gt;
&lt;p&gt;I use AI for code generation daily. Here is what works:&lt;&#x2F;p&gt;
&lt;p&gt;Use it for tasks with clear specifications. If you can describe the desired
output precisely, the model will probably generate something close. If you
cannot describe what you want, the model will not guess correctly.&lt;&#x2F;p&gt;
&lt;p&gt;Review the output. The model will make mistakes. It will use deprecated APIs.
It will skip edge cases. It will generate code that compiles but does not work.
Treat it like code review from a junior developer who types fast but does not
check their work.&lt;&#x2F;p&gt;
&lt;p&gt;Iterate on the prompt when the output is wrong. If it generates migrations with
circular dependencies, tell it to output migrations in dependency order. The
model does not know your requirements unless you tell it.&lt;&#x2F;p&gt;
&lt;p&gt;Use multiple models when the task is ambiguous. Different models have different
blind spots. One model catches precision bugs in number handling. Another
catches stack overflow vulnerabilities in recursive parsers. Consensus finds
edge cases no single model would have caught.&lt;&#x2F;p&gt;
&lt;p&gt;Do not expect the model to read your mind. It cannot. It is text prediction,
not telepathy.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-real-problem&quot;&gt;The Real Problem&lt;&#x2F;h2&gt;
&lt;p&gt;The real problem with AI tools is that people expect them to solve problems
humans have not solved yet.&lt;&#x2F;p&gt;
&lt;p&gt;You cannot ask an AI to follow code style if you have not documented the style.
You cannot ask it to implement a feature if you have not specified what the
feature should do.&lt;&#x2F;p&gt;
&lt;p&gt;AI does not eliminate the need for clear requirements, documentation, or code
review. It amplifies the cost of not having them. If your team does not have
coding standards, the AI will generate code in 47 different styles. If your
project does not have tests, the AI will break things you did not know were
fragile. If your specs are ambiguous, the AI will pick the wrong interpretation.&lt;&#x2F;p&gt;
&lt;p&gt;This is not the AI&#x27;s fault. This is your fault for not having standards, tests,
or specs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;using-ai-without-losing-your-mind&quot;&gt;Using AI Without Losing Your Mind&lt;&#x2F;h2&gt;
&lt;p&gt;Here is what I do:&lt;&#x2F;p&gt;
&lt;p&gt;Treat AI as a tool, not a coworker. It does not understand context. It does not
remember what you said three prompts ago. It does not know what you meant
versus what you typed. Give it explicit instructions and check the output.&lt;&#x2F;p&gt;
&lt;p&gt;Use it for tasks with high certainty and low blast radius. Generating test
cases? Great. Refactoring variable names? Fine. Rewriting your entire database
layer? Probably not.&lt;&#x2F;p&gt;
&lt;p&gt;Keep humans in the loop at decision boundaries. Generate the plan, review it,
then execute. Generate the code, review it, then merge. The AI can propose. You
decide.&lt;&#x2F;p&gt;
&lt;p&gt;Do not try to keep up with every new model. Pick one that works and use it
until it does not work anymore. Switching models every week is how you burn
out.&lt;&#x2F;p&gt;
&lt;p&gt;Lower your expectations. The AI will not write production-ready code from a
vague prompt. It will write something that compiles and mostly works. You fix
the rest. That is still faster than writing it yourself from scratch.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;AI problems are human problems. Burnout from keeping up with too many tools.
FOMO about missing the next breakthrough. Perfectionism about getting it
exactly right. Analysis paralysis about which approach to take. Garbage in,
garbage out.&lt;&#x2F;p&gt;
&lt;p&gt;We had these problems before AI. We will have these problems after AI. The only
difference is speed. AI makes bad processes fail faster. It makes unclear
requirements obvious immediately. It makes poor documentation expensive in
minutes instead of months.&lt;&#x2F;p&gt;
&lt;p&gt;If you are struggling with AI tools, the problem is probably not the AI. The
problem is that your process, documentation, or requirements were already
broken. AI just made it obvious.&lt;&#x2F;p&gt;
&lt;p&gt;Fix the process. The AI will follow.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>llm-mux: Why I Rebuilt Lok</title>
          <pubDate>Wed, 11 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/llm-mux-workflow-orchestration/</link>
          <guid>https://jakegoldsborough.com/blog/2026/llm-mux-workflow-orchestration/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/llm-mux-workflow-orchestration/">&lt;p&gt;Lok hit 317 cargo installs. People were using it. So naturally I rewrote it from
scratch.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s not as chaotic as it sounds. Lok grew organically from &quot;query multiple
LLMs&quot; to &quot;run workflows&quot; to &quot;apply edits&quot; to &quot;create GitHub issues.&quot; Each feature
bolted onto the side. The codebase worked but the abstractions were wrong.&lt;&#x2F;p&gt;
&lt;p&gt;llm-mux is what lok should have been from the start.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-was-wrong&quot;&gt;What Was Wrong&lt;&#x2F;h2&gt;
&lt;p&gt;Lok conflates backends with tasks. When you write &lt;code&gt;backend = &quot;claude&quot;&lt;&#x2F;code&gt; in a
workflow step, you&#x27;re coupling your workflow to a specific model. Want to swap
Claude for Gemini? Edit every step.&lt;&#x2F;p&gt;
&lt;p&gt;Lok also has no concept of project context. A Rust project needs &lt;code&gt;cargo test&lt;&#x2F;code&gt;
for verification. A Node project needs &lt;code&gt;npm test&lt;&#x2F;code&gt;. In lok, you hardcode these
per-workflow. Switch projects, rewrite workflows.&lt;&#x2F;p&gt;
&lt;p&gt;The apply_edits feature was bolted on late. It works but there&#x27;s no retry loop,
no structured verification, no rollback without git-agent.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;roles&quot;&gt;Roles&lt;&#x2F;h2&gt;
&lt;p&gt;llm-mux introduces roles. Instead of hardcoding backends:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;# lok style - backend hardcoded
[[steps]]
name = &amp;quot;analyze&amp;quot;
backend = &amp;quot;claude&amp;quot;
prompt = &amp;quot;Find bugs&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You declare what kind of task it is:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;# llm-mux style - role-based
[[steps]]
name = &amp;quot;analyze&amp;quot;
type = &amp;quot;query&amp;quot;
role = &amp;quot;analyzer&amp;quot;
prompt = &amp;quot;Find bugs&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then configure which backends handle which roles:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[roles.analyzer]
description = &amp;quot;Code analysis tasks&amp;quot;
backends = [&amp;quot;claude&amp;quot;, &amp;quot;codex&amp;quot;]
execution = &amp;quot;parallel&amp;quot;

[roles.quick]
description = &amp;quot;Fast local checks&amp;quot;
backends = [&amp;quot;qwen&amp;quot;]
execution = &amp;quot;first&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Swapping backends is a config change, not a workflow rewrite. The workflow says
&quot;I need analysis.&quot; The config decides who does analysis.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;teams&quot;&gt;Teams&lt;&#x2F;h2&gt;
&lt;p&gt;Teams add project context:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[teams.rust]
description = &amp;quot;Rust projects&amp;quot;
detect = [&amp;quot;Cargo.toml&amp;quot;]
verify = &amp;quot;cargo clippy &amp;amp;&amp;amp; cargo test&amp;quot;

[teams.rust.roles.analyzer]
backends = [&amp;quot;claude&amp;quot;, &amp;quot;codex&amp;quot;]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When llm-mux detects &lt;code&gt;Cargo.toml&lt;&#x2F;code&gt;, it activates the rust team. Verification
commands come from the team. Role mappings can be overridden per-team.&lt;&#x2F;p&gt;
&lt;p&gt;Same workflow, different projects, correct tooling.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;http-backends&quot;&gt;HTTP Backends&lt;&#x2F;h2&gt;
&lt;p&gt;Lok only does CLI. You shell out to &lt;code&gt;claude&lt;&#x2F;code&gt;, &lt;code&gt;codex&lt;&#x2F;code&gt;, &lt;code&gt;ollama run&lt;&#x2F;code&gt;. Each query
spawns a process.&lt;&#x2F;p&gt;
&lt;p&gt;llm-mux supports both CLI and HTTP:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[backends.claude]
command = &amp;quot;claude&amp;quot;
args = [&amp;quot;-p&amp;quot;, &amp;quot;--&amp;quot;]

[backends.openai]
command = &amp;quot;https:&amp;#x2F;&amp;#x2F;api.openai.com&amp;#x2F;v1&amp;quot;
model = &amp;quot;gpt-4&amp;quot;
api_key = &amp;quot;${OPENAI_API_KEY}&amp;quot;

[backends.local]
command = &amp;quot;http:&amp;#x2F;&amp;#x2F;localhost:11434&amp;#x2F;v1&amp;quot;
model = &amp;quot;llama3&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the command starts with &lt;code&gt;http&lt;&#x2F;code&gt;, it&#x27;s HTTP. Otherwise CLI. HTTP is faster for
high-volume workflows. No process overhead. Proper streaming. Rate limit handling.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;apply-and-verify&quot;&gt;Apply and Verify&lt;&#x2F;h2&gt;
&lt;p&gt;Lok&#x27;s apply_edits was a boolean flag. llm-mux has a real system:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[[steps]]
name = &amp;quot;fix&amp;quot;
type = &amp;quot;apply&amp;quot;
source = &amp;quot;steps.analyze&amp;quot;
verify = &amp;quot;cargo test&amp;quot;
verify_retries = 3
verify_retry_prompt = &amp;quot;Fix failed: {{ error }}. Try again.&amp;quot;
rollback_on_failure = true
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The flow:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Parse edits from source step&lt;&#x2F;li&gt;
&lt;li&gt;Apply edits&lt;&#x2F;li&gt;
&lt;li&gt;Run verification&lt;&#x2F;li&gt;
&lt;li&gt;If it fails and retries remain, show error to LLM, try again&lt;&#x2F;li&gt;
&lt;li&gt;If all retries fail, rollback&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The retry loop is the difference. Instead of failing on first bad edit, llm-mux
shows the error and asks for a fix. Most failures are small mistakes a second
attempt catches.&lt;&#x2F;p&gt;
&lt;p&gt;Rollback uses git stash. No external tooling.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;an-example&quot;&gt;An Example&lt;&#x2F;h2&gt;
&lt;p&gt;The rust-audit workflow runs four parallel audits and writes structured docs:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;llm-mux run rust-audit
llm-mux run rust-audit outdir=reports&amp;#x2F;feb-audit
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Output:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;docs&amp;#x2F;audit&amp;#x2F;
├── README.md          # Summary table
├── 01-safety.md       # Memory safety
├── 02-performance.md  # Perf issues
├── 03-errors.md       # Error handling
└── 04-idioms.md       # Patterns
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each audit is its own query step. Each saves to a file. The final step synthesizes
a summary. The &lt;code&gt;outdir&lt;&#x2F;code&gt; argument makes it reusable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-llm-mux-is-not&quot;&gt;What llm-mux Is Not&lt;&#x2F;h2&gt;
&lt;p&gt;llm-mux is not a replacement for lok&#x27;s CLI commands. There&#x27;s no &lt;code&gt;llm-mux ask&lt;&#x2F;code&gt; or
&lt;code&gt;llm-mux hunt&lt;&#x2F;code&gt;. It&#x27;s purely a workflow runner.&lt;&#x2F;p&gt;
&lt;p&gt;If you want quick one-off queries, use lok. If you want structured multi-step
pipelines with proper abstractions, use llm-mux.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m keeping both. They solve different problems.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-doesn-t-work-yet&quot;&gt;What Doesn&#x27;t Work Yet&lt;&#x2F;h2&gt;
&lt;p&gt;The template system is powerful but error messages are cryptic. A typo in a Jinja
variable gives you a wall of minijinja internals.&lt;&#x2F;p&gt;
&lt;p&gt;HTTP backend streaming works but the progress output is ugly. You see chunks
arrive but it&#x27;s not as clean as the CLI backend output.&lt;&#x2F;p&gt;
&lt;p&gt;Team auto-detection is basic. It looks for files but doesn&#x27;t understand monorepos
or nested projects yet.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cargo install llm-mux
llm-mux doctor
llm-mux run rust-audit
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Config goes in &lt;code&gt;~&#x2F;.config&#x2F;llm-mux&#x2F;config.toml&lt;&#x2F;code&gt;. Workflows go in
&lt;code&gt;.llm-mux&#x2F;workflows&#x2F;&lt;&#x2F;code&gt; or &lt;code&gt;~&#x2F;.config&#x2F;llm-mux&#x2F;workflows&#x2F;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Source at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;llm-mux&quot;&gt;github.com&#x2F;ducks&#x2F;llm-mux&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Lok was the prototype. llm-mux is the product. The 317 people using lok helped
me figure out what the abstractions should be.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>finna: Multi-Model Debate, Spec, and Implement</title>
          <pubDate>Tue, 10 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/finna-multi-model-spec-implement/</link>
          <guid>https://jakegoldsborough.com/blog/2026/finna-multi-model-spec-implement/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/finna-multi-model-spec-implement/">&lt;p&gt;Lok&#x27;s &lt;code&gt;spec&lt;&#x2F;code&gt; command was useful but felt too coupled to the rest of the tool.
I wanted something standalone: give it an idea, get specs and code. No
configuration files, no backend setup, just a single binary that orchestrates
the models I already have installed.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s finna.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem-with-just-build-it&quot;&gt;The Problem with &quot;Just Build It&quot;&lt;&#x2F;h2&gt;
&lt;p&gt;When you ask an LLM to build something, it starts coding immediately. Maybe it
picks a good architecture, maybe it doesn&#x27;t. By the time you see the output,
you&#x27;re committed to whatever approach it chose. If the foundation is wrong,
you&#x27;re either refactoring generated code or starting over.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is to separate planning from execution. But not just any planning. A
plan that multiple models have debated and agreed on, written down in files you
can review before any code gets generated.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-pipeline&quot;&gt;The Pipeline&lt;&#x2F;h2&gt;
&lt;p&gt;finna runs four stages:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;idea → debate → roadmap → spec → implement
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Debate&lt;&#x2F;strong&gt;: Claude, Codex, and Gemini independently analyze the idea&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Consensus&lt;&#x2F;strong&gt;: Claude synthesizes the proposals into a unified approach&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Roadmap&lt;&#x2F;strong&gt;: Break the consensus into ordered, dependency-aware steps&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Spec&lt;&#x2F;strong&gt;: Write detailed implementation specs for each step&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Implement&lt;&#x2F;strong&gt;: Models propose edits, Claude synthesizes, changes applied&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Each stage reads from the previous stage&#x27;s output. Everything lands in &lt;code&gt;.finna&#x2F;&lt;&#x2F;code&gt;
so you can review, edit, or re-run individual stages.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Run all stages
finna &amp;quot;Add JWT authentication to the API&amp;quot;

# Or run stages separately
finna debate &amp;quot;Add JWT authentication&amp;quot;    # debate → roadmap
finna spec                                # roadmap → specs
finna implement                           # specs → code

# Target specific steps
finna spec --step auth-middleware
finna implement --step auth-middleware
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;a-real-example-toml-parser&quot;&gt;A Real Example: TOML Parser&lt;&#x2F;h2&gt;
&lt;p&gt;I ran finna on building a TOML parser from scratch:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;finna debate &amp;quot;write a toml parser in rust from scratch, no dependencies&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three models debated the architecture. They converged on a lexer-first approach
with recursive descent parsing. The roadmap came out as 30 steps with proper
dependency ordering:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;.finna&amp;#x2F;
├── consensus.json
├── roadmap.arf
└── specs&amp;#x2F;
    ├── 01-project-scaffold&amp;#x2F;spec.arf
    ├── 02-error-types&amp;#x2F;spec.arf
    ├── 03-token-types&amp;#x2F;spec.arf
    ...
    ├── 16-lexer-integration&amp;#x2F;spec.arf
    ├── 17-parser-core&amp;#x2F;spec.arf
    ...
    └── 30-edge-cases&amp;#x2F;spec.arf
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The roadmap groups work into phases: scaffold and types (1-5), lexer (6-16),
parser (17-25), public API and tests (26-30). Each step declares its
dependencies so they can&#x27;t run out of order.&lt;&#x2F;p&gt;
&lt;p&gt;Running &lt;code&gt;finna spec&lt;&#x2F;code&gt; generates detailed ARF specs for each step. Here&#x27;s the
spec for lexing basic strings:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;order = 10
what = &amp;quot;Implement lexing for double-quoted basic strings with full escape support&amp;quot;
why = &amp;quot;Basic strings are TOML&amp;#x27;s primary string format. The lexer must process
  escapes at lex time and detect unterminated strings with precise error
  locations.&amp;quot;
how = &amp;quot;&amp;quot;&amp;quot;
1. Add lex_basic_string() method
2. Main loop: consume until closing quote or error
3. Implement lex_escape_sequence() for \t, \n, \r, \\, \&amp;quot;, \uXXXX, \UXXXXXXXX
4. Implement lex_unicode_escape() with validation
5. Wire into lex_token() dispatch
6. Add unit tests for all escape sequences and error cases
&amp;quot;&amp;quot;&amp;quot;
backup = &amp;quot;Defer escape processing to post-lex pass if implementation is error-prone&amp;quot;

[context]
files = [&amp;quot;src&amp;#x2F;lexer.rs&amp;quot;, &amp;quot;src&amp;#x2F;error.rs&amp;quot;]
dependencies = []
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The specs include test cases. The basic strings spec lists 25+ scenarios:
simple strings, escape sequences, unicode handling, error conditions. The
models know what to test because they debated it during planning.&lt;&#x2F;p&gt;
&lt;p&gt;The 30 specs totaled 2.1k lines. That&#x27;s not wasted tokens. It&#x27;s a contract you
can review before any code exists.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-arf-format&quot;&gt;The ARF Format&lt;&#x2F;h2&gt;
&lt;p&gt;Specs use TOML with a standard structure:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;order = 1
what = &amp;quot;one sentence description&amp;quot;
why = &amp;quot;context and motivation&amp;quot;
how = &amp;quot;&amp;quot;&amp;quot;
Step-by-step implementation plan
&amp;quot;&amp;quot;&amp;quot;
backup = &amp;quot;fallback approach if primary fails&amp;quot;

[context]
files = [&amp;quot;paths&amp;#x2F;to&amp;#x2F;files&amp;quot;]
dependencies = [&amp;quot;step names this depends on&amp;quot;]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The format is simple on purpose. No special tooling needed to read or edit.
Any text editor works. The structure enforces that every step has a rationale,
a plan, and a fallback.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-separate-stages&quot;&gt;Why Separate Stages&lt;&#x2F;h2&gt;
&lt;p&gt;The stages are separate because you need intervention points.&lt;&#x2F;p&gt;
&lt;p&gt;After debate, review the roadmap. Does the architecture make sense? Are the
steps in the right order? Edit &lt;code&gt;.finna&#x2F;roadmap.arf&lt;&#x2F;code&gt; if not.&lt;&#x2F;p&gt;
&lt;p&gt;After spec, review the specs. Is the implementation plan correct? Are the test
cases comprehensive? Edit the spec files or re-run &lt;code&gt;finna spec --step X&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;After implement, review the changes. Did the edits apply cleanly? Is the code
what you expected? The specs told you what would happen; now verify it did.&lt;&#x2F;p&gt;
&lt;p&gt;If you run &lt;code&gt;finna &quot;idea&quot;&lt;&#x2F;code&gt; without the subcommands, it runs all stages in
sequence. That&#x27;s fine for exploration. But for real work, you probably want
the review gates.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;multi-model-consensus&quot;&gt;Multi-Model Consensus&lt;&#x2F;h2&gt;
&lt;p&gt;The debate phase isn&#x27;t just asking three models and picking one. All three
responses get synthesized:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Claude, Codex, and Gemini each propose an approach in parallel&lt;&#x2F;li&gt;
&lt;li&gt;Claude sees all three proposals and synthesizes consensus&lt;&#x2F;li&gt;
&lt;li&gt;Disagreements become explicit tradeoffs in the final plan&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;One model might over-engineer authentication. Another might skip edge cases.
The synthesis catches both failure modes. You get architecture that multiple
models have pressure-tested.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;&#x2F;h2&gt;
&lt;p&gt;finna is ~500 lines of Rust. It shells out to &lt;code&gt;claude&lt;&#x2F;code&gt;, &lt;code&gt;codex&lt;&#x2F;code&gt;, and &lt;code&gt;npx @google&#x2F;gemini-cli&lt;&#x2F;code&gt; for the actual model calls. No API keys in the binary,
no config files. If you have the CLIs installed, finna works.&lt;&#x2F;p&gt;
&lt;p&gt;The implementation phase runs models in parallel for each step, synthesizes
their edit proposals, and applies the changes. Edits are JSON with &lt;code&gt;path&lt;&#x2F;code&gt;,
&lt;code&gt;old&lt;&#x2F;code&gt;, and &lt;code&gt;new&lt;&#x2F;code&gt; fields. Simple find-and-replace, no AST manipulation.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[derive(Debug, serde::Deserialize)]
struct Edit {
    path: String,
    old: String,
    new: String,
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If an edit can&#x27;t find the target text, it warns and continues. If the file
doesn&#x27;t exist, it creates it. The implementation is deliberately simple
because the specs already contain the complexity.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-finna-is-not&quot;&gt;What finna Is Not&lt;&#x2F;h2&gt;
&lt;p&gt;finna is not a replacement for writing code. It&#x27;s a planning tool that happens
to also generate code. The value is in the specs, not the implementation.&lt;&#x2F;p&gt;
&lt;p&gt;If the generated code is wrong, you fix the spec and re-run. If the
architecture is wrong, you fix the roadmap and re-spec. The code is a
side effect of getting the plan right.&lt;&#x2F;p&gt;
&lt;p&gt;finna is also not trying to be general-purpose. It solves one problem: turn an
idea into a structured plan with implementation. No plugins, no extensibility,
no configuration. One tool, one job.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Clone and build
git clone https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;finna
cd finna
nix-shell
cargo build --release

# Run on your idea
.&amp;#x2F;target&amp;#x2F;release&amp;#x2F;finna &amp;quot;your idea here&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Requires &lt;code&gt;claude&lt;&#x2F;code&gt;, &lt;code&gt;codex&lt;&#x2F;code&gt;, and &lt;code&gt;npx @google&#x2F;gemini-cli&lt;&#x2F;code&gt; to be installed and
authenticated. If a model fails, finna continues with the others.&lt;&#x2F;p&gt;
&lt;p&gt;The source is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;finna&quot;&gt;github.com&#x2F;ducks&#x2F;finna&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;The test project (TOML parser specs) is at
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;finna-toml-parser&quot;&gt;github.com&#x2F;ducks&#x2F;finna-toml-parser&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building a JSON Parser with Multi-LLM Orchestration (Part 1)</title>
          <pubDate>Sat, 07 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/lok-json-parser-multi-llm/</link>
          <guid>https://jakegoldsborough.com/blog/2026/lok-json-parser-multi-llm/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/lok-json-parser-multi-llm/">&lt;p&gt;I&#x27;ve been building lok, a multi-LLM orchestration tool, and I wanted to put it
through its paces on a real project. What better than a JSON parser? It&#x27;s a
classic learning project with enough nuance to surface interesting design
decisions.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s the premise: instead of just diving into code, what if I let multiple
LLMs debate the design first? Then synthesize their consensus into specs. Then
have them collaboratively implement it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-setup&quot;&gt;The Setup&lt;&#x2F;h2&gt;
&lt;p&gt;I started with a simple question:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok debate &amp;quot;We want to write a JSON parser from scratch as a learning project.
Debate: What language should we use? What features should it support?
What should the architecture look like?&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Four models participated: Claude, Codex (GPT-5.2), Gemini, and Qwen 3 Coder
(running locally via Ollama).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;round-1-surprising-agreement&quot;&gt;Round 1: Surprising Agreement&lt;&#x2F;h2&gt;
&lt;p&gt;All four converged on Rust. The reasoning varied:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Claude&lt;&#x2F;strong&gt; focused on the learning angle: &quot;Forces you to think about ownership
and memory layout. You&#x27;ll learn more writing a parser in Rust than in Python&#x2F;JS
where you can be sloppy.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Codex&lt;&#x2F;strong&gt; got practical: &quot;Ownership semantics force you to think about buffer
management. &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;&#x2F;code&gt; makes error handling explicit.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Gemini&lt;&#x2F;strong&gt; went for performance: &quot;Zero-cost abstractions mean clean code
compiles to efficient machine code.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Qwen&lt;&#x2F;strong&gt; hit safety: &quot;Enums with exhaustive matching prevent invalid parser
states.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;On architecture, unanimous: lexer + recursive descent parser. No surprises
there.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;round-2-the-interesting-bits&quot;&gt;Round 2: The Interesting Bits&lt;&#x2F;h2&gt;
&lt;p&gt;Numbers sparked actual debate. The naive approach stores JSON numbers as &lt;code&gt;f64&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;Number(f64)  &amp;#x2F;&amp;#x2F; Simple but WRONG
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Codex caught it: &quot;Treating numbers as f64 is not spec-correct. JSON allows
arbitrary precision. The safest approach is storing the original string.&quot;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;Number(&amp;amp;&amp;#x27;a str)  &amp;#x2F;&amp;#x2F; Preserves original, validates grammar
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is why multi-model debate works. I might have defaulted to f64 and hit
precision bugs later.&lt;&#x2F;p&gt;
&lt;p&gt;Another good catch from Gemini: recursive descent needs depth limits. Without
them, an adversarial input like &lt;code&gt;[[[[[[[[...&lt;&#x2F;code&gt; blows the stack. Simple fix, easy
to forget.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-consensus&quot;&gt;The Consensus&lt;&#x2F;h2&gt;
&lt;p&gt;After three rounds, the models settled on:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Language&lt;&#x2F;strong&gt;: Rust&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Architecture&lt;&#x2F;strong&gt;: Lexer (iterator-based) + Recursive Descent Parser&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Number handling&lt;&#x2F;strong&gt;: Store as string, not f64&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Zero-copy&lt;&#x2F;strong&gt;: Use &lt;code&gt;Cow&amp;lt;&#x27;a, str&amp;gt;&lt;&#x2F;code&gt; where possible&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Safety&lt;&#x2F;strong&gt;: Configurable depth limits&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Features&lt;&#x2F;strong&gt;: RFC 8259 strict first, extensions later&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;generating-specs&quot;&gt;Generating Specs&lt;&#x2F;h2&gt;
&lt;p&gt;With design decisions locked, I fed the debate conclusions into &lt;code&gt;lok spec&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok spec &amp;quot;Build a JSON parser in Rust with these design decisions:
- Lexer + Recursive Descent Parser
- Numbers stored as string, not f64
- Zero-copy with Cow&amp;lt;&amp;#x27;a, str&amp;gt;
- Depth limits for safety
- RFC 8259 strict compliance&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This queries multiple backends, synthesizes a consensus roadmap, then breaks
each step into subtasks. The output:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;.arf&amp;#x2F;specs&amp;#x2F;
  roadmap.arf
  01-core_types&amp;#x2F;     (5 subtasks) - Span, Error, Token, Value
  02-lexer&amp;#x2F;          (5 subtasks) - Iterator-based tokenizer
  03-parser&amp;#x2F;         (4 subtasks) - Recursive descent
  04-number_validation&amp;#x2F; (2 subtasks) - RFC 8259 number format
  05-error_reporting&amp;#x2F;   (4 subtasks) - Line&amp;#x2F;column errors
  06-test_suite&amp;#x2F;        (3 subtasks) - JSONTestSuite compliance
  07-extension_hooks&amp;#x2F;   (4 subtasks) - Future comments&amp;#x2F;trailing commas
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each subtask is an &lt;code&gt;.arf&lt;&#x2F;code&gt; file (Agent Reasoning Format) with structured fields:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;order = 1
what = &amp;quot;Core Lexer struct implementing Iterator over tokens&amp;quot;
file = &amp;quot;src&amp;#x2F;lexer&amp;#x2F;lexer.rs&amp;quot;
why = &amp;quot;Main lexing logic that transforms input bytes into token stream&amp;quot;
how = &amp;quot;&amp;quot;&amp;quot;
Struct Lexer&amp;lt;&amp;#x27;a&amp;gt; with input: &amp;amp;&amp;#x27;a str, pos: usize. Implement
Iterator&amp;lt;Item = Result&amp;lt;Token&amp;lt;&amp;#x27;a&amp;gt;, LexError&amp;gt;&amp;gt;. Dispatch on current
byte: punctuation returns immediately, keywords verify literals,
strings handle escapes, numbers capture as slice.
&amp;quot;&amp;quot;&amp;quot;

[context]
inputs = &amp;quot;Raw JSON string&amp;quot;
outputs = &amp;quot;Stream of Token results&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;These specs become the contract for implementation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-multi-model-debate-surfaces&quot;&gt;What Multi-Model Debate Surfaces&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Edge cases no single model catches.&lt;&#x2F;strong&gt; The f64 precision issue came from Codex.
The depth limit vulnerability came from Gemini. Each model has blind spots.
Claude focused on educational value, Codex on spec correctness, Gemini on
performance, Qwen on safety.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Consensus beats any single model.&lt;&#x2F;strong&gt; Not because the average is smarter, but
because different models catch different things. Three rounds of debate with
four models surfaced issues I&#x27;d have hit weeks into implementation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Part 2 will cover &lt;code&gt;lok implement&lt;&#x2F;code&gt;, which takes these specs and:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Queries multiple backends in parallel for each subtask&lt;&#x2F;li&gt;
&lt;li&gt;Synthesizes consensus code from the proposals&lt;&#x2F;li&gt;
&lt;li&gt;Writes the file and verifies it compiles&lt;&#x2F;li&gt;
&lt;li&gt;Commits each file with an atomic git commit&lt;&#x2F;li&gt;
&lt;li&gt;Records structured reasoning traces (ARF) alongside the code&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The implementation phase is where things get interesting. Backends disagree on
details, synthesis has to resolve conflicts, and verification catches when the
generated code doesn&#x27;t actually compile.&lt;&#x2F;p&gt;
&lt;p&gt;Stay tuned.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Lok Part 5: Multi-Agent Planning with lok spec</title>
          <pubDate>Fri, 06 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/lok-spec-multi-agent-planning/</link>
          <guid>https://jakegoldsborough.com/blog/2026/lok-spec-multi-agent-planning/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/lok-spec-multi-agent-planning/">&lt;p&gt;The hardest part of building software isn&#x27;t writing code. It&#x27;s figuring out what
to build and in what order. LLMs are great at generating code, but they&#x27;re also
great at generating the wrong code because they started implementing before
thinking through the structure.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s what &lt;code&gt;lok spec&lt;&#x2F;code&gt; solves.&lt;&#x2F;p&gt;
&lt;p&gt;This post describes the shape of the system: how lok structures planning, what
guarantees the spec format provides, and why separation between planner and
executor matters. The implementation of the executor (agents actually writing
code) is not covered here. This is about constraints and contracts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-lok-is-not&quot;&gt;What Lok is not&lt;&#x2F;h2&gt;
&lt;p&gt;Before going further:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Not a chatbot framework or conversational interface&lt;&#x2F;li&gt;
&lt;li&gt;Not a marketplace or plugin system for third-party agents&lt;&#x2F;li&gt;
&lt;li&gt;Not trying to replace developers with automation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Lok is a planner that structures work and an executor that requires explicit
human approval at every phase boundary. The design assumes humans review specs,
trigger execution, and validate results.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem-with-just-build-it&quot;&gt;The Problem with &quot;Just Build It&quot;&lt;&#x2F;h2&gt;
&lt;p&gt;When you give an LLM a big task like &quot;build a C compiler,&quot; it starts coding
immediately. Maybe it picks the lexer first, maybe the parser. Maybe it designs
types that won&#x27;t work for later phases. By the time you realize the architecture
is wrong, you&#x27;ve burned tokens and time.&lt;&#x2F;p&gt;
&lt;p&gt;The fix is obvious in retrospect: plan before you build. But not just any plan.
A plan that multiple models have debated and converged on.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;propose-refine-pick&quot;&gt;Propose, Refine, Pick&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;lok spec&lt;&#x2F;code&gt; uses a three-step process:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Propose&lt;&#x2F;strong&gt;: All backends generate roadmaps in parallel&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Refine&lt;&#x2F;strong&gt;: Synthesize roadmaps into a consensus plan&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Pick&lt;&#x2F;strong&gt;: Generate detailed specs from the consensus&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;This is the same pattern as debate mode, but applied to planning instead of
analysis. Each backend proposes how they&#x27;d structure the project. Then we
synthesize the best ideas and resolve contradictions. Finally, we generate
specs that capture the agreed approach.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok spec &amp;quot;Build a C compiler in Rust. Take C source as input, produce x86-64
  assembly output. Include lexer, parser, semantic analysis, and code
  generation. Focus on correctness over optimization.&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;spec: Planning: Build a C compiler in Rust...

spec: Step 1&amp;#x2F;3: Getting roadmaps from 4 backends...
  ✓ 4&amp;#x2F;4 backends responded
spec: Step 2&amp;#x2F;3: Synthesizing consensus roadmap...
  ✓ Consensus reached
spec: Step 3&amp;#x2F;3: Generating detailed specs...
spec: Step 4&amp;#x2F;4: Breaking specs into subtasks...
  ✓ lexer → 4 subtasks
  ✓ ast → 5 subtasks
  ✓ parser → 5 subtasks
  ✓ semantic → 5 subtasks
  ✓ ir → 5 subtasks
  ✓ codegen → 5 subtasks
  ✓ driver → 4 subtasks

==================================================
spec: Generated 7 specs in .arf&amp;#x2F;specs&amp;#x2F;:

  + roadmap.arf
  + 01-lexer&amp;#x2F;spec.arf
  + 01-lexer&amp;#x2F;01-token.arf
  + 01-lexer&amp;#x2F;02-cursor.arf
  + 01-lexer&amp;#x2F;03-scanner.arf
  + 01-lexer&amp;#x2F;04-keywords.arf
  + 02-ast&amp;#x2F;spec.arf
  ...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Four backends looked at &quot;build a C compiler&quot; and each proposed a roadmap. Claude
synthesized them into a unified plan. Then we generated specs for each step, and
broke each step into subtasks (individual files).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-output-structure&quot;&gt;The Output Structure&lt;&#x2F;h2&gt;
&lt;p&gt;Specs land in &lt;code&gt;.arf&#x2F;specs&#x2F;&lt;&#x2F;code&gt; with a nested structure:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;.arf&amp;#x2F;specs&amp;#x2F;
  roadmap.arf
  01-lexer&amp;#x2F;
    spec.arf
    01-token.arf
    02-cursor.arf
    03-scanner.arf
    04-keywords.arf
  02-ast&amp;#x2F;
    spec.arf
    01-types.arf
    02-expr.arf
    ...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The roadmap is the high-level plan. Each numbered directory is a step. Inside
each step is a &lt;code&gt;spec.arf&lt;&#x2F;code&gt; with the overall component spec, plus subtask files
for individual pieces.&lt;&#x2F;p&gt;
&lt;p&gt;A spec file looks like this:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;order = 1
what = &amp;quot;Build the lexer to tokenize C source code&amp;quot;
why = &amp;quot;Parser needs a stream of tokens, not raw characters&amp;quot;
how = &amp;quot;Hand-written scanner with keyword trie, handles preprocessor directives&amp;quot;
backup = &amp;quot;Fall back to regex-based tokenizer if performance is acceptable&amp;quot;

[context]
inputs = &amp;quot;C source code as string or file path&amp;quot;
outputs = &amp;quot;Iterator of Token structs with location info&amp;quot;
dependencies = []
tests = &amp;quot;Tokenize test files, compare against expected token sequences&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A subtask file adds a &lt;code&gt;file&lt;&#x2F;code&gt; field:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;order = 1
what = &amp;quot;Define token types and structures&amp;quot;
file = &amp;quot;src&amp;#x2F;lexer&amp;#x2F;token.rs&amp;quot;
why = &amp;quot;All other lexer components depend on token definitions&amp;quot;
how = &amp;quot;Enum for token kinds, struct for token with span and value&amp;quot;

[context]
inputs = &amp;quot;None - foundational types&amp;quot;
outputs = &amp;quot;Token enum, TokenKind enum, Span struct&amp;quot;
tests = &amp;quot;Unit tests for token construction and display&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;planning-to-execution-flow&quot;&gt;Planning to Execution Flow&lt;&#x2F;h2&gt;
&lt;p&gt;Here is the minimal end-to-end for a boring but real task: refactoring Rust
error handling to use &lt;code&gt;thiserror&lt;&#x2F;code&gt; crate. Replace manual &lt;code&gt;impl Display&lt;&#x2F;code&gt; with
derive macro. Update all error construction sites.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok spec &amp;quot;Refactor error types to use thiserror crate. Replace manual Display
  impls with derive macro. Update all error construction sites.&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Output:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;.arf&amp;#x2F;specs&amp;#x2F;
  roadmap.arf
  01-add-dependency&amp;#x2F;spec.arf
  02-refactor-errors&amp;#x2F;spec.arf
  02-refactor-errors&amp;#x2F;01-lexer-error.arf
  02-refactor-errors&amp;#x2F;02-parser-error.arf
  03-update-callsites&amp;#x2F;spec.arf
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Review the roadmap and specs. If the plan looks wrong, edit the &lt;code&gt;.arf&lt;&#x2F;code&gt; files or
regenerate. Once satisfied:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok implement .arf&amp;#x2F;specs&amp;#x2F;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Agents read each spec and implement the changes directly. Each agent works on
its assigned file according to the spec. The boundary between planning and
execution is the review gate - you decide when to run &lt;code&gt;lok implement&lt;&#x2F;code&gt; after
reviewing the specs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;&#x2F;h2&gt;
&lt;p&gt;The spec files are &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf&quot;&gt;ARF&lt;&#x2F;a&gt; format. That means
they&#x27;re structured, parseable, and auditable. When you review a spec, you&#x27;re
reviewing the plan before any code exists. If the approach is wrong, you catch
it here, not after 10,000 lines of generated code.&lt;&#x2F;p&gt;
&lt;p&gt;The subtask breakdown is what enables parallel execution. Once you have specs
for &lt;code&gt;01-token.arf&lt;&#x2F;code&gt;, &lt;code&gt;02-cursor.arf&lt;&#x2F;code&gt;, &lt;code&gt;03-scanner.arf&lt;&#x2F;code&gt;, and &lt;code&gt;04-keywords.arf&lt;&#x2F;code&gt;,
different agents can work on different files simultaneously. They&#x27;re not
stepping on each other because each owns their file.&lt;&#x2F;p&gt;
&lt;p&gt;The pattern is recursive. &lt;code&gt;lok spec&lt;&#x2F;code&gt; on a project gives you steps. Each step
has subtasks. If a subtask is still too big, you could spec it again. Same
process, different scale.&lt;&#x2F;p&gt;
&lt;p&gt;The spec format enforces three invariants:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Serializable plans&lt;&#x2F;strong&gt;: Every decision is written down in parseable TOML.
You can diff plans, version them, replay them.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Attributable actions&lt;&#x2F;strong&gt;: Each spec names the file it will modify or create.
No agent can touch code without a spec that declares intent.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Human intervention points&lt;&#x2F;strong&gt;: Spec generation is separate from execution.
You review the plan before any code changes happen.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;These are not optimizations. They are constraints that prevent the system from
becoming a black box.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;human-in-the-loop-by-design&quot;&gt;Human in the Loop by Design&lt;&#x2F;h2&gt;
&lt;p&gt;The separation between &lt;code&gt;lok spec&lt;&#x2F;code&gt; and execution is not a temporary limitation.
It is the core design. The system does not &quot;learn&quot; to skip human review or
&quot;graduate&quot; to autonomous operation.&lt;&#x2F;p&gt;
&lt;p&gt;Every phase boundary requires explicit human approval:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Spec generation produces files you review before execution starts&lt;&#x2F;li&gt;
&lt;li&gt;Execution runs subtasks but does not merge or deploy results&lt;&#x2F;li&gt;
&lt;li&gt;Integration is a separate manual step&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This is not because the LLMs are not capable enough. It is because software
built by delegation requires inspection and veto power at decision boundaries.
The moment you remove that, you have an agent that makes choices you cannot
audit until after the damage is done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;single-backend-fallback&quot;&gt;Single Backend Fallback&lt;&#x2F;h2&gt;
&lt;p&gt;If you only have one backend configured, &lt;code&gt;lok spec&lt;&#x2F;code&gt; skips the multi-model
consensus and just asks that backend to plan and spec directly. You still get
the structured output, just without the debate.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok spec --backend claude &amp;quot;Build a REST API&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The value of multi-backend consensus is catching blind spots. One model might
over-engineer the auth system while another keeps it simple. Synthesis finds
the middle ground. But if you&#x27;re just exploring or don&#x27;t have multiple backends
set up, single-model works fine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;token-conservation&quot;&gt;Token Conservation&lt;&#x2F;h2&gt;
&lt;p&gt;The multi-agent pattern has a cost: API calls. Four backends generating roadmaps
is four API calls. Synthesis is another. Spec generation is another. Subtask
generation is one per step.&lt;&#x2F;p&gt;
&lt;p&gt;For a 7-step project, that&#x27;s roughly: 4 (roadmaps) + 1 (synthesize) + 1 (specs)&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;7 (subtasks) = 13 API calls. Not cheap if you&#x27;re using expensive models.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But here&#x27;s the key: every backend&#x27;s contribution gets used. We&#x27;re not querying
four models and throwing away three responses. We&#x27;re synthesizing all four into
something better than any single response.&lt;&#x2F;p&gt;
&lt;p&gt;This is the rule I&#x27;ve been following: if you fire off multiple agents, they
must debate or synthesize. Never query N backends just to pick one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;The specs exist. The next step is execution: read the specs, assign agents to
subtasks, run them in parallel, integrate the results. That is a bigger piece of
machinery, but the foundation is here.&lt;&#x2F;p&gt;
&lt;p&gt;For now, &lt;code&gt;lok spec&lt;&#x2F;code&gt; gives you a structured plan that multiple models have
agreed on. Review it, tweak it if needed, then start building with confidence
that the architecture makes sense. The system does not get smarter by hiding
decisions from you. It gets more useful by making decisions inspectable.&lt;&#x2F;p&gt;
&lt;p&gt;The source is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&quot;&gt;github.com&#x2F;ducks&#x2F;lok&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Previous: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;lok-the-self-healing-loop&quot;&gt;Part 4: The Self-Healing Loop&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>cfgs.dev: A Directory for Developer Setups</title>
          <pubDate>Tue, 03 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/cfgs-dev-a-directory-for-developer-setups/</link>
          <guid>https://jakegoldsborough.com/blog/2026/cfgs-dev-a-directory-for-developer-setups/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/cfgs-dev-a-directory-for-developer-setups/">&lt;p&gt;Every developer eventually builds a setup they are proud of. Not because it is perfect, but because it is earned - shaped by years of work, mistakes, migrations, and small personal decisions.&lt;&#x2F;p&gt;
&lt;p&gt;The problem is discovery. These hard-won configurations are scattered across GitHub repos, half-documented READMEs, gists, and blog posts. You can find them if you already know what to search for. But browsing? Exploring what tools people actually use together? That is hard.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;cfgs.dev&quot;&gt;cfgs.dev&lt;&#x2F;a&gt; exists to fix that gap.&lt;&#x2F;p&gt;
&lt;p&gt;It is not a new config system. Not a dotfile manager. Not a package manager. It is a directory - a browsable index of how developers actually work, across tools, operating systems, and philosophies.&lt;&#x2F;p&gt;
&lt;p&gt;Think of it like browsing other developers&#x27; desks. You can see what tools they use, how they configure them, and what their workflow looks like. Then follow the link to their actual dotfiles repo to see the details.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-cfgs-dev-is&quot;&gt;What cfgs.dev is&lt;&#x2F;h2&gt;
&lt;p&gt;cfgs.dev scans GitHub repos and extracts what tools people use. Not just &quot;this repo has Neovim&quot; but &quot;this repo uses Neovim with lazy.nvim, gruvbox, lualine, telescope, and nvim-cmp.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;An entry is simple: a username, a link to the dotfiles repo, and a structured breakdown of what the scanner found. Anyone can submit any public repo - the owner can later claim it by logging in. You can browse by category (editors, terminals, window managers) or by specific tool. Want to see how people configure Hyprland? There&#x27;s a page for that.&lt;&#x2F;p&gt;
&lt;p&gt;The detection is declarative. Each tool has a JSON config that specifies file patterns to match and content to extract:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;json&quot; class=&quot;language-json &quot;&gt;&lt;code class=&quot;language-json&quot; data-lang=&quot;json&quot;&gt;{
  &amp;quot;name&amp;quot;: &amp;quot;Neovim&amp;quot;,
  &amp;quot;patterns&amp;quot;: [&amp;quot;nvim&amp;#x2F;init.lua&amp;quot;, &amp;quot;.config&amp;#x2F;nvim&amp;#x2F;**&amp;#x2F;*.lua&amp;quot;],
  &amp;quot;contentFlags&amp;quot;: {
    &amp;quot;pluginManager&amp;quot;: {
      &amp;quot;lazy.nvim&amp;quot;: [&amp;quot;lazy.nvim&amp;quot;, &amp;quot;folke&amp;#x2F;lazy&amp;quot;],
      &amp;quot;packer&amp;quot;: [&amp;quot;packer.nvim&amp;quot;, &amp;quot;wbthomason&amp;#x2F;packer&amp;quot;]
    },
    &amp;quot;theme&amp;quot;: {
      &amp;quot;gruvbox&amp;quot;: [&amp;quot;gruvbox&amp;quot;],
      &amp;quot;tokyonight&amp;quot;: [&amp;quot;tokyonight&amp;quot;]
    }
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When the scanner finds a matching file, it checks the content for these
patterns. First match wins per category. The result is a structured view of
what the config actually contains.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;deep-detection&quot;&gt;Deep Detection&lt;&#x2F;h2&gt;
&lt;p&gt;The latest version goes deeper on Neovim specifically. It now detects 14
categories of config details:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Distribution (LazyVim, AstroNvim, NvChad)&lt;&#x2F;li&gt;
&lt;li&gt;Plugin manager (lazy.nvim, packer, vim-plug)&lt;&#x2F;li&gt;
&lt;li&gt;LSP setup (nvim-lspconfig, coc.nvim)&lt;&#x2F;li&gt;
&lt;li&gt;Completion (nvim-cmp, coq, deoplete)&lt;&#x2F;li&gt;
&lt;li&gt;Statusline (lualine, lightline, airline)&lt;&#x2F;li&gt;
&lt;li&gt;File explorer (nvim-tree, neo-tree, oil.nvim)&lt;&#x2F;li&gt;
&lt;li&gt;Fuzzy finder (telescope, fzf-lua)&lt;&#x2F;li&gt;
&lt;li&gt;Theme (gruvbox, tokyonight, catppuccin, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;And more&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The tricky part was handling multi-file configs. A typical Neovim setup has
&lt;code&gt;init.lua&lt;&#x2F;code&gt; that just requires other files, with the actual plugin configs in
&lt;code&gt;lua&#x2F;plugins&#x2F;*.lua&lt;&#x2F;code&gt;. The scanner now merges details from all matching files
instead of stopping at the first match.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;badges&quot;&gt;Badges&lt;&#x2F;h2&gt;
&lt;p&gt;You can embed your setup in your README:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;markdown&quot; class=&quot;language-markdown &quot;&gt;&lt;code class=&quot;language-markdown&quot; data-lang=&quot;markdown&quot;&gt;![setup](https:&amp;#x2F;&amp;#x2F;cfgs.dev&amp;#x2F;api&amp;#x2F;badge&amp;#x2F;yourusername)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This returns a shields.io style badge showing your top tools. There are options
for filtering by category and changing the style.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-cfgs-dev-is-not&quot;&gt;What cfgs.dev is not&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Not a dotfile manager (use chezmoi, Stow, dotter, or bare git repos)&lt;&#x2F;li&gt;
&lt;li&gt;Not a package manager (use Nix, Homebrew, or your distro&#x27;s package manager)&lt;&#x2F;li&gt;
&lt;li&gt;Not enforcing standards or best practices (your config, your choices)&lt;&#x2F;li&gt;
&lt;li&gt;Not replacing existing tools (it points to them, does not replace them)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-works&quot;&gt;What Works&lt;&#x2F;h2&gt;
&lt;p&gt;The declarative detector system is nice. Adding a new tool is just appending to a JSON file. No TypeScript, no build step, no tests to write. The refactor from code-based detectors to config-based ones removed 455 lines.&lt;&#x2F;p&gt;
&lt;p&gt;Browse-by-tool is useful for discovery. Instead of searching through random repos, you can see &quot;here are 47 people using Helix&quot; and look at their configs.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-doesn-t&quot;&gt;What Doesn&#x27;t&lt;&#x2F;h2&gt;
&lt;p&gt;Scanning requires cloning repos locally, which takes time. A shallow clone of a typical dotfiles repo is fast, but large repos or slow connections can timeout. I cache scan results so subsequent visits are instant.&lt;&#x2F;p&gt;
&lt;p&gt;Detection accuracy varies. Simple tools are easy (if &lt;code&gt;kitty.conf&lt;&#x2F;code&gt; exists, they use Kitty). Complex tools with many config locations are harder. Neovim alone has like 6 different places people put their config.&lt;&#x2F;p&gt;
&lt;p&gt;The data model is flat right now. There&#x27;s no way to express relationships like &quot;this person switched from packer to lazy.nvim&quot; or &quot;these configs are forks of each other.&quot; That might be interesting but it is a lot more complexity.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Multi-tool filtering. Right now you can browse by single tool, but &quot;show me users with both Neovim and Kitty&quot; isn&#x27;t possible yet.&lt;&#x2F;p&gt;
&lt;p&gt;Similar setups. If you use Neovim with these 5 plugins, here are other people with similar configs. This requires some fuzzy matching logic but the data is there.&lt;&#x2F;p&gt;
&lt;p&gt;Better search. Right now it&#x27;s browse-only. Being able to search by tool combinations, categories, or specific plugins would be useful.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;If you have a dotfiles repo, add it. Anyone can submit any public dotfile repo - no login required. Paste the URL and the scanner does the rest. If you are the owner, you can log in with GitHub to claim your entry and update it later. The more setups in the index, the more useful it becomes as a living map of real developer workflows.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;cfgs.dev&quot;&gt;cfgs.dev&lt;&#x2F;a&gt; |
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;cfgs.dev&quot;&gt;GitHub&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ARF: Structured Reasoning for AI Agents</title>
          <pubDate>Mon, 02 Feb 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/arf-structured-reasoning-for-ai-agents/</link>
          <guid>https://jakegoldsborough.com/blog/2026/arf-structured-reasoning-for-ai-agents/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/arf-structured-reasoning-for-ai-agents/">&lt;h3 id=&quot;the-problem-with-prompts&quot;&gt;The Problem with Prompts&lt;&#x2F;h3&gt;
&lt;p&gt;The deeper I get into building with LLMs, the more I realize the &quot;chat prompt&quot;
model works but could be better.&lt;&#x2F;p&gt;
&lt;p&gt;When you give an agent an unstructured prompt, it runs with it. Sometimes
brilliantly. Sometimes it hallucinates a photographer named &quot;Chris Lawton&quot;
because it couldn&#x27;t parse a webpage and decided to make something up instead
of saying &quot;I don&#x27;t know.&quot; The lack of structure means the agent decides what
matters, what to skip, and what to invent.&lt;&#x2F;p&gt;
&lt;p&gt;This is fine for casual Q&amp;amp;A. It falls apart when you&#x27;re building tools that
need to be auditable, reviewable, or predictable.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;from-lok-to-arf&quot;&gt;From lok to ARF&lt;&#x2F;h3&gt;
&lt;p&gt;I&#x27;ve been building &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&quot;&gt;lok&lt;&#x2F;a&gt;, an LLM orchestration
tool that runs multi-step workflows across different backends. One thing
became clear: the more structure I added, the better the results.&lt;&#x2F;p&gt;
&lt;p&gt;Workflows with explicit steps, defined inputs, and expected outputs work.
Workflows that just say &quot;figure it out&quot; produce garbage as often as gold.&lt;&#x2F;p&gt;
&lt;p&gt;This led to a question: what if we standardized how agents communicate their
reasoning? Not just &quot;here&#x27;s my answer&quot; but &quot;here&#x27;s what I&#x27;m doing, why I&#x27;m
doing it, how I plan to do it, and what I&#x27;ll do if it fails.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The result is ARF (Agent Reasoning Format), a simple spec for structured
agent reasoning.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-format&quot;&gt;The Format&lt;&#x2F;h3&gt;
&lt;p&gt;ARF records are TOML files with required and optional fields:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;what = &amp;quot;Add retry logic to API client&amp;quot;
why = &amp;quot;Transient failures causing user-visible errors&amp;quot;
how = &amp;quot;Exponential backoff with 3 retries, circuit breaker after 5 failures&amp;quot;
backup = &amp;quot;Revert to synchronous error handling if latency increases&amp;quot;
timestamp = &amp;quot;2026-02-02T15:14:32Z&amp;quot;
commit = &amp;quot;8ae882e6&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Two fields are required:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;what&lt;&#x2F;strong&gt;: The concrete action being taken&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;why&lt;&#x2F;strong&gt;: The reasoning behind the approach&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Two fields are recommended:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;how&lt;&#x2F;strong&gt;: Implementation details&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;backup&lt;&#x2F;strong&gt;: Rollback plan if it fails&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The backup field is the interesting one. Forcing agents to declare a rollback
plan before acting means they have to think about failure modes. It&#x27;s the
difference between &quot;I&#x27;ll refactor this function&quot; and &quot;I&#x27;ll refactor this
function, and if tests fail I&#x27;ll revert to the original implementation.&quot;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;storage-orphan-branches&quot;&gt;Storage: Orphan Branches&lt;&#x2F;h3&gt;
&lt;p&gt;ARF records need to live somewhere. The options were:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Git notes (invisible, sync friction)&lt;&#x2F;li&gt;
&lt;li&gt;Nested repo (coordination overhead)&lt;&#x2F;li&gt;
&lt;li&gt;Orphan branch (separate history, same repo)&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;I ran a &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&quot;&gt;lok debate&lt;&#x2F;a&gt; across four LLM backends
to evaluate the tradeoffs. All four converged on orphan branches.&lt;&#x2F;p&gt;
&lt;p&gt;The approach uses git worktrees to mount an orphan branch at &lt;code&gt;.arf&#x2F;&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;your-repo&amp;#x2F;
├── .arf&amp;#x2F;              # Mounted worktree (arf branch)
│   └── records&amp;#x2F;
│       └── 8ae882e6&amp;#x2F;  # Records by commit SHA
│           └── claude-20260202-151432.toml
├── .git&amp;#x2F;
└── src&amp;#x2F;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Records are organized by commit SHA. When you record reasoning, it links to
the commit you&#x27;re working on. The &lt;code&gt;.arf&#x2F;&lt;&#x2F;code&gt; directory is gitignored from the
main branch but has its own history on the orphan branch.&lt;&#x2F;p&gt;
&lt;p&gt;This keeps reasoning history completely separate from code history. You can
push, pull, and sync reasoning records without touching your main branch.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-cli&quot;&gt;The CLI&lt;&#x2F;h3&gt;
&lt;p&gt;The reference implementation is a Rust CLI:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Initialize ARF tracking
arf init

# Record reasoning for current work
arf record --what &amp;quot;Add graph command&amp;quot; \
           --why &amp;quot;Need unified view of git history with reasoning&amp;quot;

# View reasoning log
arf log

# Combined visualization
arf graph
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;arf graph&lt;&#x2F;code&gt; command shows git commits alongside their reasoning records:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Git + ARF History:

├─● 8ae882e Add diff command with ARF reasoning context
│  └─ what: Add diff command
│      why: Combine git diff with ARF reasoning for full context review
│      how: Shows reasoning header then git show output
├─● 5604413 Add graph command for unified git+arf visualization
│  └─ what: Add graph command
│      why: User requested visualization combining git commits with reasoning
│      how: Matches commit SHAs to .arf&amp;#x2F;records&amp;#x2F; directories
├─● 8ec6c98 Add ARF CLI reference implementation
│  └─ what: Implement ARF CLI v0.1
│      why: Need reference implementation for spec
│      how: Rust CLI with init&amp;#x2F;record&amp;#x2F;log&amp;#x2F;sync commands
└─● 3384a83 Initial ARF spec v0.1
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;arf diff&lt;&#x2F;code&gt; command shows a single commit with reasoning context:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;═══════════════════════════════════════════════════════════════
Commit: 8ae882e Add diff command with ARF reasoning context
═══════════════════════════════════════════════════════════════

REASONING:
  what: Add diff command
  why:  Combine git diff with ARF reasoning for full context review
  how:  Shows reasoning header then git show output

───────────────────────────────────────────────────────────────
CHANGES:

 src&amp;#x2F;main.rs | 118 +++++++++++++++++++++++++++
 1 file changed, 118 insertions(+)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is &quot;review the reasoning, not just the diff.&quot;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;&#x2F;h3&gt;
&lt;p&gt;The shift happening in AI tooling is from unstructured to structured. Chat
interfaces are training wheels. Production systems need:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Declared intent&lt;&#x2F;strong&gt;: What are you trying to do?&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Explicit reasoning&lt;&#x2F;strong&gt;: Why this approach?&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Failure planning&lt;&#x2F;strong&gt;: What if it breaks?&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Audit trails&lt;&#x2F;strong&gt;: What happened and why?&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;ARF is one piece of this. It&#x27;s not a replacement for git commit messages or
PR descriptions. It&#x27;s a parallel track for capturing reasoning that doesn&#x27;t
belong in code history but shouldn&#x27;t be lost.&lt;&#x2F;p&gt;
&lt;p&gt;When an agent makes a change, the diff shows what changed. The ARF record
shows why that approach was chosen over alternatives, what tradeoffs were
considered, and what the rollback plan is.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;using-it&quot;&gt;Using It&lt;&#x2F;h3&gt;
&lt;p&gt;The spec and CLI are on GitHub:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;arf&quot;&gt;github.com&#x2F;ducks&#x2F;arf&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Install with Cargo:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cargo install --git https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;arf
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The format is intentionally minimal. Four fields, two required. Easy to
generate, easy to parse, easy to extend.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re building agent tooling and want structured reasoning, try it out.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>I Built a Robot to Help Me Understand People</title>
          <pubDate>Sat, 31 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/building-a-robot-to-understand-humans/</link>
          <guid>https://jakegoldsborough.com/blog/2026/building-a-robot-to-understand-humans/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/building-a-robot-to-understand-humans/">&lt;p&gt;I am good with machines and bad with people.&lt;&#x2F;p&gt;
&lt;p&gt;This is not false modesty. I can trace a request through twelve microservices and
find the one misconfigured timeout. I can read a stack trace and know which
library is lying. I can debug race conditions that only happen on Tuesdays.&lt;&#x2F;p&gt;
&lt;p&gt;But ask me to read a room? To pick up on what someone is really saying in a
meeting? To notice when a coworker is frustrated before they explicitly say so?
That is where I struggle.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;writing-is-easier&quot;&gt;Writing Is Easier&lt;&#x2F;h2&gt;
&lt;p&gt;I do better with written communication. Forum posts, Slack messages, pull
request descriptions. When people write, they leave a trail I can actually
follow. I can re-read it. I can search it. I can take my time.&lt;&#x2F;p&gt;
&lt;p&gt;In person, the signal moves too fast. By the time I have processed what someone
said, we have moved on. I miss the subtext because I am still working on the
text.&lt;&#x2F;p&gt;
&lt;p&gt;This is probably why I ended up in infrastructure. Machines do not have subtext.
A 502 error is a 502 error.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;graham-duncan-s-question&quot;&gt;Graham Duncan&#x27;s Question&lt;&#x2F;h2&gt;
&lt;p&gt;A coworker recently wrote about Graham Duncan&#x27;s framework for evaluating people.
I had never heard of it, so I did some research. Duncan is an investor
known for his approach to assessing people. His core question is:&lt;&#x2F;p&gt;
&lt;p&gt;&quot;What&#x27;s going on here, with this human?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Not &quot;are they qualified?&quot; or &quot;do they have the right experience?&quot; but something
deeper. What patterns show up in how they communicate? What do they care about?
How do they handle disagreement? What are they not saying?&lt;&#x2F;p&gt;
&lt;p&gt;Duncan&#x27;s framework breaks this down into dimensions:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Self-awareness and blind spots&lt;&#x2F;li&gt;
&lt;li&gt;Hidden motivations and fears&lt;&#x2F;li&gt;
&lt;li&gt;How they respond to stress and uncertainty&lt;&#x2F;li&gt;
&lt;li&gt;Contextual fit (right person, wrong role?)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I realized this is exactly what I struggle to do in real-time with coworkers.
But what if I could do it asynchronously, with written artifacts, with a
machine&#x27;s help?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-data-is-already-there&quot;&gt;The Data Is Already There&lt;&#x2F;h2&gt;
&lt;p&gt;At Discourse, we use our own product internally. Years of forum posts, technical
discussions, weekly updates, casual banter. A written record of how people
think, communicate, and collaborate.&lt;&#x2F;p&gt;
&lt;p&gt;This is the kind of data I can actually work with.&lt;&#x2F;p&gt;
&lt;p&gt;So I built a tool. It pulls someone&#x27;s forum activity, reads through their posts,
and generates a structured evaluation. Communication style. Areas of expertise.
How they handle disagreement. Patterns that emerge over time.&lt;&#x2F;p&gt;
&lt;p&gt;It is not a replacement for actually talking to people. But it gives me a
starting point. A cheat sheet for understanding someone before I walk into a
1:1 or try to give feedback.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-actually-produces&quot;&gt;What It Actually Produces&lt;&#x2F;h2&gt;
&lt;p&gt;The output is a structured report:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Executive summary (what stands out about this person?)&lt;&#x2F;li&gt;
&lt;li&gt;Core strengths with evidence from actual posts&lt;&#x2F;li&gt;
&lt;li&gt;Growth areas, framed constructively&lt;&#x2F;li&gt;
&lt;li&gt;Communication profile (tone, clarity, patterns)&lt;&#x2F;li&gt;
&lt;li&gt;Collaboration dynamics (how do they work with others?)&lt;&#x2F;li&gt;
&lt;li&gt;Representative quotes that capture their voice&lt;&#x2F;li&gt;
&lt;li&gt;Discussion prompts for 1:1s&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That last section is the most useful for me. Specific questions I can ask based
on what someone has actually written about. Not generic &quot;how&#x27;s it going?&quot; but
&quot;you mentioned being frustrated with X last month, how&#x27;s that going?&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-uncomfortable-part&quot;&gt;The Uncomfortable Part&lt;&#x2F;h2&gt;
&lt;p&gt;There is something uncomfortable about using a machine to understand people.
It feels like cheating. Or like admitting a deficiency that maybe I should just
work on directly.&lt;&#x2F;p&gt;
&lt;p&gt;But I have been &quot;working on it directly&quot; for decades and I am still bad at it.
At some point you have to accept your constraints and build around them.&lt;&#x2F;p&gt;
&lt;p&gt;I use a calendar because I cannot remember appointments. I use a todo list
because I cannot hold tasks in my head. I use version control because I cannot
trust myself to not break things.&lt;&#x2F;p&gt;
&lt;p&gt;Why not use a tool to help me understand people?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;not-a-replacement&quot;&gt;Not a Replacement&lt;&#x2F;h2&gt;
&lt;p&gt;This does not replace actual human interaction. It is prep work. The same way
you might review someone&#x27;s recent commits before a code review, or skim their
last few PRs before a 1:1.&lt;&#x2F;p&gt;
&lt;p&gt;The conversations still happen. The relationship still matters. I just show up
slightly less clueless about what is going on with this human.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try It&lt;&#x2F;h2&gt;
&lt;p&gt;The tool is open source if you want to try it:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;claude plugin marketplace add ducks&amp;#x2F;person-eval
claude plugin install person-eval@person-eval --scope user
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then in Claude Code:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;#x2F;person-eval username https:&amp;#x2F;&amp;#x2F;your-discourse-site.com
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Or with a timeframe for yearly reviews:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;#x2F;person-eval username https:&amp;#x2F;&amp;#x2F;your-discourse-site.com 2025
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It requires the discourse-mcp server to be configured, which gives Claude Code
access to Discourse data.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-irony&quot;&gt;The Irony&lt;&#x2F;h2&gt;
&lt;p&gt;The irony is not lost on me. I built a machine to help me understand humans
because I understand machines better than humans.&lt;&#x2F;p&gt;
&lt;p&gt;But maybe that is fine. We all have different strengths. Mine happen to involve
making robots do things. If I can make a robot help me be a better coworker,
that seems like a reasonable trade.&lt;&#x2F;p&gt;
&lt;p&gt;The goal is not to replace human connection with machine analysis. The goal is
to show up more prepared, more aware, more able to actually connect when the
moment comes.&lt;&#x2F;p&gt;
&lt;p&gt;I am still bad with people. But I am getting better at compensating for it.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Lok Part 4: The Self-Healing Loop</title>
          <pubDate>Wed, 28 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/lok-the-self-healing-loop/</link>
          <guid>https://jakegoldsborough.com/blog/2026/lok-the-self-healing-loop/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/lok-the-self-healing-loop/">&lt;p&gt;Last time I showed lok finding 25 bugs in itself and creating GitHub issues
automatically. Today it fixed one of those bugs, submitted a PR, and then
found a real bug in Discourse that I just pushed upstream.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;agentic-workflows&quot;&gt;Agentic Workflows&lt;&#x2F;h2&gt;
&lt;p&gt;The missing piece was letting lok actually do things, not just talk about them.
I added a few fields to the workflow TOML:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[[steps]]
name = &amp;quot;fix&amp;quot;
backend = &amp;quot;claude&amp;quot;
apply_edits = true
verify = &amp;quot;cargo build&amp;quot;
prompt = &amp;quot;&amp;quot;&amp;quot;
Fix this issue. Output JSON:
{&amp;quot;edits&amp;quot;: [{&amp;quot;file&amp;quot;: &amp;quot;src&amp;#x2F;main.rs&amp;quot;, &amp;quot;old&amp;quot;: &amp;quot;...&amp;quot;, &amp;quot;new&amp;quot;: &amp;quot;...&amp;quot;}], &amp;quot;summary&amp;quot;: &amp;quot;...&amp;quot;}
&amp;quot;&amp;quot;&amp;quot;

[[steps]]
name = &amp;quot;commit&amp;quot;
shell = &amp;quot;git add -A &amp;amp;&amp;amp; git commit -m &amp;#x27;{{ steps.fix.summary }}&amp;#x27;&amp;quot;
depends_on = [&amp;quot;fix&amp;quot;]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three new things:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;shell&lt;&#x2F;code&gt; runs a command instead of querying an LLM&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;apply_edits&lt;&#x2F;code&gt; parses JSON from the LLM and patches files&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;verify&lt;&#x2F;code&gt; runs after edits to make sure they work&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The &lt;code&gt;{{ steps.fix.summary }}&lt;&#x2F;code&gt; bit extracts a field from the JSON output. You
can also do &lt;code&gt;{{ arg.1 }}&lt;&#x2F;code&gt; for positional arguments, so &lt;code&gt;lok run fix-issue 42&lt;&#x2F;code&gt;
passes 42 into the workflow.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;lok-fixes-itself&quot;&gt;Lok Fixes Itself&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&#x2F;issues&#x2F;25&quot;&gt;Issue #25&lt;&#x2F;a&gt; was about a redundant &lt;code&gt;Clone&lt;&#x2F;code&gt; implementation. The &lt;code&gt;Delegator&lt;&#x2F;code&gt; struct
already derives Clone, but there was a manual impl at the bottom of the file
doing the same thing.&lt;&#x2F;p&gt;
&lt;p&gt;I ran the fix workflow:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok run fix-issue 25
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It analyzed the issue, generated an edit to delete the redundant impl, applied
it, ran &lt;code&gt;cargo build&lt;&#x2F;code&gt; to verify, committed with a message based on the fix
summary, pushed the branch, and opened a PR.&lt;&#x2F;p&gt;
&lt;p&gt;The whole loop took maybe 30 seconds. I reviewed the diff, looked reasonable,
merged it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;finding-bugs-in-discourse&quot;&gt;Finding Bugs in Discourse&lt;&#x2F;h2&gt;
&lt;p&gt;Feeling confident, I pointed lok at the main Discourse codebase:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok hunt ~&amp;#x2F;discourse&amp;#x2F;discourse
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both Codex and Claude found issues. Most were minor (confusing patterns, style
things) but two stood out:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;A chat job with a TODO saying &quot;delete after 2025-01-01&quot; that was still there
in January 2026. Pure dead code.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;A bug in the thread serializer. It had this pattern:&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;@opts[:include_thread_original_message].presence || true
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The problem is &lt;code&gt;.presence&lt;&#x2F;code&gt; on &lt;code&gt;false&lt;&#x2F;code&gt; returns &lt;code&gt;nil&lt;&#x2F;code&gt;. So if you explicitly pass
&lt;code&gt;include_thread_original_message: false&lt;&#x2F;code&gt;, it gets converted to &lt;code&gt;nil&lt;&#x2F;code&gt;, then
&lt;code&gt;|| true&lt;&#x2F;code&gt; kicks in, and your option is ignored.&lt;&#x2F;p&gt;
&lt;p&gt;One controller was passing &lt;code&gt;false&lt;&#x2F;code&gt; and getting &lt;code&gt;true&lt;&#x2F;code&gt; back. Nobody noticed
because the behavior difference is subtle, but it was definitely wrong.&lt;&#x2F;p&gt;
&lt;p&gt;I created two PRs:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;discourse&#x2F;pull&#x2F;37333&quot;&gt;#37333&lt;&#x2F;a&gt;
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;discourse&#x2F;pull&#x2F;37334&quot;&gt;#37334&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-vision&quot;&gt;The Vision&lt;&#x2F;h2&gt;
&lt;p&gt;The pieces are coming together for a fully autonomous loop:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;lok hunt --issues&lt;&#x2F;code&gt; finds bugs and creates GitHub issues&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;lok run fix-issue 42&lt;&#x2F;code&gt; analyzes, fixes, verifies, commits, opens PR&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;lok run review-pr 43&lt;&#x2F;code&gt; has multiple backends review the diff&lt;&#x2F;li&gt;
&lt;li&gt;If they agree, merge&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Humans become exception handlers. You get pinged when the LLMs disagree or
flag something they are not confident about. Otherwise the codebase quietly
improves itself.&lt;&#x2F;p&gt;
&lt;p&gt;We are not there yet. The fix workflow needs better error recovery, the review
workflow needs the debate mode integrated, and I want confidence scores on
the merge decision. But the foundation is solid.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Honestly, I&#x27;m not 100% sure. I plan to just keep using it and adding&#x2F;fixing things
that I want or need. Stay tuned to see how the project evolves.&lt;&#x2F;p&gt;
&lt;p&gt;Next stop: unknown. But the tracks are laid.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Previous: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;lok-dogfooding-and-code-review&quot;&gt;Part 3: Dogfooding and Code Review&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Lok Part 3: Dogfooding and Code Review</title>
          <pubDate>Tue, 27 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/lok-dogfooding-and-code-review/</link>
          <guid>https://jakegoldsborough.com/blog/2026/lok-dogfooding-and-code-review/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/lok-dogfooding-and-code-review/">&lt;p&gt;Part 2 ended with two promises: parallel workflow execution and dead code cleanup.
Both happened. But the more interesting development was lok creating GitHub issues
for bugs it found in itself.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;hunt-with-issues&quot;&gt;Hunt with Issues&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;code&gt;lok hunt&lt;&#x2F;code&gt; command scans for bugs using multiple LLM backends. The new
&lt;code&gt;--issues&lt;&#x2F;code&gt; flag takes it further: parse the findings and create GitHub issues
automatically.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok hunt --issues                    # Find bugs, create issues
lok hunt --issues -y                 # Skip confirmation prompt
lok hunt --issues --issue-backend gitlab  # Force GitLab instead of GitHub
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The implementation auto-detects whether to use &lt;code&gt;gh&lt;&#x2F;code&gt; (GitHub CLI) or &lt;code&gt;glab&lt;&#x2F;code&gt;
(GitLab CLI) by checking the git remote URL:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;fn detect_from_remote(dir: &amp;amp;Path) -&amp;gt; Option&amp;lt;Self&amp;gt; {
    let output = Command::new(&amp;quot;git&amp;quot;)
        .args([&amp;quot;remote&amp;quot;, &amp;quot;get-url&amp;quot;, &amp;quot;origin&amp;quot;])
        .current_dir(dir)
        .output()
        .ok()?;

    let url = String::from_utf8_lossy(&amp;amp;output.stdout).to_lowercase();

    if url.contains(&amp;quot;github.com&amp;quot;) {
        Some(IssueBackend::GitHub)
    } else if url.contains(&amp;quot;gitlab.com&amp;quot;) || url.contains(&amp;quot;gitlab.&amp;quot;) {
        Some(IssueBackend::GitLab)
    } else {
        &amp;#x2F;&amp;#x2F; Fall back to checking which CLI is installed
        &amp;#x2F;&amp;#x2F; ...
    }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Self-hosted GitLab instances work too (the &lt;code&gt;gitlab.&lt;&#x2F;code&gt; check catches
&lt;code&gt;gitlab.mycompany.com&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;lok-reviews-itself&quot;&gt;Lok Reviews Itself&lt;&#x2F;h2&gt;
&lt;p&gt;The real test: run &lt;code&gt;lok hunt --issues -y&lt;&#x2F;code&gt; on the lok repository itself.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;$ lok hunt --issues -y

Task: hunt
Find bugs and code issues
==================================================

[errors]

=== CLAUDE ===

1. **Unchecked path canonicalization** - `src&amp;#x2F;main.rs:271`
   - Uses `unwrap_or_else` to silently fall back on failure
   - Backends expecting absolute paths may behave unexpectedly

2. **Unvalidated PR URL parsing** - `src&amp;#x2F;main.rs:1320-1323`
   - No validation that URL has enough path segments
   - Malformed URLs like `https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;foo` will panic
...

=== CODEX ===

1) src&amp;#x2F;cache.rs:121 — `read_to_string(...).ok()?` silently drops IO errors
2) src&amp;#x2F;workflow.rs:241 — `find(...).unwrap()` can panic on duplicate steps
...

[perf]

=== CLAUDE ===

1. **O(n×m) consensus checking** - `src&amp;#x2F;debate.rs:176-189`
2. **Regex compiled per interpolation call** - `src&amp;#x2F;workflow.rs:282-294`
...

=== CODEX ===

1. `src&amp;#x2F;workflow.rs:89` — Linear `.find` over steps, O(n^2) with many steps
2. `src&amp;#x2F;workflow.rs:279` — Regex recompiled on every `interpolate` call
...

[dead-code]

=== CLAUDE ===

1. **src&amp;#x2F;backend&amp;#x2F;mod.rs:1-9** - Bedrock module feature-gated but never enabled
2. **src&amp;#x2F;cache.rs** - `Cache::clear()` method defined but never called
...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Both backends found real issues. Claude and Codex agreed on several (the regex
recompilation, the O(n^2) workflow lookups) and each caught things the other
missed.&lt;&#x2F;p&gt;
&lt;p&gt;Then the issues got created:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;==================================================
issues: Creating GitHub issues in ducks&amp;#x2F;lok

Found 25 potential issues:
  1. src&amp;#x2F;cache.rs:121 — silently drops IO errors
  2. src&amp;#x2F;workflow.rs:241 — find(...).unwrap() can panic
  3. Regex compiled per interpolation call
  ...

Creating issue: src&amp;#x2F;cache.rs:121...  ✓ https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;lok&amp;#x2F;issues&amp;#x2F;1
Creating issue: src&amp;#x2F;workflow.rs:241... ✓ https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;lok&amp;#x2F;issues&amp;#x2F;2
...
Creating issue: src&amp;#x2F;spawn.rs:381-384... ✓ https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;lok&amp;#x2F;issues&amp;#x2F;25

✓ Created 25 issues
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;25 GitHub issues from a single command. The tool found bugs in itself and opened
tickets to track them. Each issue body includes the full finding context and
which backend reported it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pr-review&quot;&gt;PR Review&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;code&gt;lok pr&lt;&#x2F;code&gt; command reviews GitHub pull requests:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok pr 123                              # Current repo
lok pr owner&amp;#x2F;repo#123                   # Specific repo
lok pr https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;o&amp;#x2F;r&amp;#x2F;pull&amp;#x2F;123  # From URL
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It fetches PR metadata and diff via &lt;code&gt;gh&lt;&#x2F;code&gt;, constructs a review prompt, and sends
it to the configured backends. Feedback is organized by severity (critical,
important, minor, nitpick).&lt;&#x2F;p&gt;
&lt;p&gt;The value is having it as a single command instead of copy-pasting diffs into
chat windows. Run &lt;code&gt;lok pr&lt;&#x2F;code&gt; on your own PRs before requesting review. It catches
the obvious stuff so human reviewers can focus on architecture.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;explain-mode&quot;&gt;Explain Mode&lt;&#x2F;h2&gt;
&lt;p&gt;The &lt;code&gt;lok explain&lt;&#x2F;code&gt; command explains codebases:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok explain                         # Current directory
lok explain &amp;#x2F;path&amp;#x2F;to&amp;#x2F;project        # Specific project
lok explain --focus auth            # Focus on specific aspect
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It gathers context automatically: README, package manifests (Cargo.toml,
package.json, etc.), and a two-level directory tree. Then asks the backend to
explain purpose, architecture, key files, and entry points.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;--focus&lt;&#x2F;code&gt; flag is useful for large codebases. Instead of explaining
everything, ask specifically about auth, database access, or API structure.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok explain ~&amp;#x2F;work&amp;#x2F;discourse --focus &amp;quot;background jobs&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;context-detection&quot;&gt;Context Detection&lt;&#x2F;h2&gt;
&lt;p&gt;Part 2 mentioned false positives from lok flagging N+1 queries in codebases that
use auto-eager-loading. Context detection fixes this.&lt;&#x2F;p&gt;
&lt;p&gt;Lok scans for framework and tooling markers before constructing prompts:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub struct CodebaseContext {
    pub detected_language: Option&amp;lt;String&amp;gt;,
    pub is_rails: bool,
    pub has_goldiloader: bool,
    pub has_bullet: bool,
    pub has_brakeman: bool,
    &amp;#x2F;&amp;#x2F; ...
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When running N+1 detection on a Rails app with Goldiloader, the prompt includes:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;Note: This codebase uses Goldiloader for automatic eager loading. Many
apparent N+1 patterns may be handled automatically. Focus on cases where
Goldiloader wouldn&#x27;t help.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;This reduced false positives significantly. Check what lok detects with:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok context .
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;parallel-workflows&quot;&gt;Parallel Workflows&lt;&#x2F;h2&gt;
&lt;p&gt;Steps without dependencies now run in parallel:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[[steps]]
name = &amp;quot;patterns&amp;quot;
backend = &amp;quot;codex&amp;quot;
prompt = &amp;quot;Find code patterns&amp;quot;

[[steps]]
name = &amp;quot;dead-code&amp;quot;
backend = &amp;quot;codex&amp;quot;
prompt = &amp;quot;Find dead code&amp;quot;

# These two run in parallel (no dependencies)

[[steps]]
name = &amp;quot;synthesize&amp;quot;
backend = &amp;quot;claude&amp;quot;
depends_on = [&amp;quot;patterns&amp;quot;, &amp;quot;dead-code&amp;quot;]
prompt = &amp;quot;Combine: {{ steps.patterns.output }} {{ steps.dead-code.output }}&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;For workflows that hit multiple backends, this cuts total time significantly.
A three-backend scan that took 15 seconds sequentially now takes 6 seconds.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;diff-review&quot;&gt;Diff Review&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;lok diff&lt;&#x2F;code&gt; reviews local changes before committing:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok diff                    # Staged changes
lok diff --unstaged         # All uncommitted changes
lok diff main..HEAD         # Branch vs main
lok diff HEAD~3             # Last 3 commits
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Same idea as PR review, but catches issues before they become PR comments.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-pattern&quot;&gt;The Pattern&lt;&#x2F;h2&gt;
&lt;p&gt;A few days in, a usage pattern has emerged:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Exploratory work&lt;&#x2F;strong&gt;: Let Claude Code call &lt;code&gt;lok ask&lt;&#x2F;code&gt; with different backends
as needed. The LLM decides when to get second opinions.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Repeatable analysis&lt;&#x2F;strong&gt;: Define workflows in TOML. Run with &lt;code&gt;lok run&lt;&#x2F;code&gt;.
Parallel execution makes multi-backend scans fast.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Code review&lt;&#x2F;strong&gt;: &lt;code&gt;lok diff&lt;&#x2F;code&gt; before committing, &lt;code&gt;lok pr&lt;&#x2F;code&gt; before requesting
review. Catches obvious issues early.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bug tracking&lt;&#x2F;strong&gt;: &lt;code&gt;lok hunt --issues&lt;&#x2F;code&gt; to find bugs and create tickets in one
command.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Lok isn&#x27;t trying to replace your LLM session. It&#x27;s a tool your LLM session can
use. The orchestration intelligence stays in the conductor (Claude, GPT,
whatever you&#x27;re chatting with). Lok just provides the interface to multiple
specialized backends.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;Those 25 issues on the lok repo need fixing. The O(n^2) workflow lookups and
regex recompilation are the most impactful. The silent error swallowing in the
cache layer should probably at least log warnings.&lt;&#x2F;p&gt;
&lt;p&gt;The PR review could be smarter about large diffs. Right now it truncates at 50k
characters. Chunking with overlap would preserve context better.&lt;&#x2F;p&gt;
&lt;p&gt;And the issue creation could get smarter about deduplication. Right now it
dedupes by title within a single run, but doesn&#x27;t check for existing open
issues with similar titles.&lt;&#x2F;p&gt;
&lt;p&gt;The source is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&quot;&gt;github.com&#x2F;ducks&#x2F;lok&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Previous: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;lok-workflows-and-local-llms&quot;&gt;Part 2: Workflows and Local LLMs&lt;&#x2F;a&gt; |
Next: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;lok-the-self-healing-loop&quot;&gt;Part 4: The Self-Healing Loop&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Lok Part 2: Workflows and Local LLMs</title>
          <pubDate>Sun, 25 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/lok-workflows-and-local-llms/</link>
          <guid>https://jakegoldsborough.com/blog/2026/lok-workflows-and-local-llms/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/lok-workflows-and-local-llms/">&lt;p&gt;Since &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;introducing-lok-multi-llm-orchestration&#x2F;&quot;&gt;introducing Lok&lt;&#x2F;a&gt;, two
features emerged from actual use: local LLM support via Ollama, and a declarative
workflow engine. Both solve real problems I hit while using the tool.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;ollama-local-llms-without-the-api-tax&quot;&gt;Ollama: Local LLMs Without the API Tax&lt;&#x2F;h2&gt;
&lt;p&gt;Cloud APIs are great until they&#x27;re not. Rate limits, quota exhaustion, latency
spikes, privacy concerns. Sometimes you just want to run a model locally and not
worry about any of that.&lt;&#x2F;p&gt;
&lt;p&gt;Ollama runs LLMs on your machine via a simple HTTP API. Lok now supports it as a
first-class backend:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;# ~&amp;#x2F;.config&amp;#x2F;lok&amp;#x2F;lok.toml
[backends.ollama]
enabled = true
command = &amp;quot;http:&amp;#x2F;&amp;#x2F;localhost:11434&amp;quot;
model = &amp;quot;llama3.2&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok ask --backend ollama &amp;quot;Explain this function&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The implementation is straightforward. Ollama exposes a &lt;code&gt;&#x2F;api&#x2F;chat&lt;&#x2F;code&gt; endpoint that
accepts JSON. No CLI binary to shell out to, no stdout parsing. Just HTTP.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;async fn chat(&amp;amp;self, prompt: &amp;amp;str) -&amp;gt; Result&amp;lt;String&amp;gt; {
    let request = ChatRequest {
        model: self.model.clone(),
        messages: vec![ChatMessage {
            role: &amp;quot;user&amp;quot;.to_string(),
            content: prompt.to_string(),
        }],
        stream: false,
    };

    let response = self.client
        .post(format!(&amp;quot;{}&amp;#x2F;api&amp;#x2F;chat&amp;quot;, self.base_url))
        .json(&amp;amp;request)
        .send()
        .await?;

    &amp;#x2F;&amp;#x2F; Parse response...
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;When to use Ollama:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Privacy-sensitive codebases that can&#x27;t hit external APIs&lt;&#x2F;li&gt;
&lt;li&gt;Avoiding rate limits during intensive analysis sessions&lt;&#x2F;li&gt;
&lt;li&gt;Cost control (no per-token billing)&lt;&#x2F;li&gt;
&lt;li&gt;Offline development environments&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Slower than cloud APIs on most hardware&lt;&#x2F;li&gt;
&lt;li&gt;Model quality depends on what you can run locally&lt;&#x2F;li&gt;
&lt;li&gt;Requires Ollama running as a daemon&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In practice, I use Ollama for synthesis steps where I&#x27;m combining outputs from
faster cloud models. The final summarization doesn&#x27;t need to be fast, it needs
to be private and reliable.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;workflows-declarative-multi-step-pipelines&quot;&gt;Workflows: Declarative Multi-Step Pipelines&lt;&#x2F;h2&gt;
&lt;p&gt;Single-shot LLM calls are useful, but real analysis often requires multiple
passes. First a fast scan, then a deep investigation, then synthesis. Doing this
manually means copy-pasting outputs between commands.&lt;&#x2F;p&gt;
&lt;p&gt;Workflows solve this by defining multi-step pipelines in TOML:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;# ~&amp;#x2F;.config&amp;#x2F;lok&amp;#x2F;workflows&amp;#x2F;security-review.toml
name = &amp;quot;security-review&amp;quot;
description = &amp;quot;Multi-pass security review with synthesis&amp;quot;

[[steps]]
name = &amp;quot;initial-scan&amp;quot;
backend = &amp;quot;codex&amp;quot;
prompt = &amp;quot;Find obvious security issues: injection, auth bypass, hardcoded secrets&amp;quot;

[[steps]]
name = &amp;quot;deep-audit&amp;quot;
backend = &amp;quot;claude&amp;quot;
depends_on = [&amp;quot;initial-scan&amp;quot;]
prompt = &amp;quot;&amp;quot;&amp;quot;
Review these findings and investigate deeper:
{{ steps.initial-scan.output }}
&amp;quot;&amp;quot;&amp;quot;

[[steps]]
name = &amp;quot;synthesize&amp;quot;
backend = &amp;quot;ollama&amp;quot;
depends_on = [&amp;quot;initial-scan&amp;quot;, &amp;quot;deep-audit&amp;quot;]
prompt = &amp;quot;&amp;quot;&amp;quot;
Combine into a prioritized report:
Initial: {{ steps.initial-scan.output }}
Deep: {{ steps.deep-audit.output }}
&amp;quot;&amp;quot;&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Run it with:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok run security-review
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The output:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Running workflow: security-review
Multi-pass security review with synthesis
==================================================

[step] initial-scan
  ✓ (2.3s)
[step] deep-audit
  ✓ (8.1s)
[step] synthesize
  ✓ (4.2s)

==================================================

Results:

[OK] initial-scan (2.3s)

  Found 5 potential issues:
  1. src&amp;#x2F;api&amp;#x2F;auth.rs:45 - SQL string interpolation
  ...

[OK] deep-audit (8.1s)

  Investigated the SQL interpolation finding...
  ...

[OK] synthesize (4.2s)

  ## Security Review Summary

  ### Critical (1)
  - SQL injection in auth.rs...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;variable-interpolation&quot;&gt;Variable Interpolation&lt;&#x2F;h3&gt;
&lt;p&gt;The &lt;code&gt;{{ steps.NAME.output }}&lt;&#x2F;code&gt; syntax passes previous step outputs into subsequent
prompts. The workflow engine does a simple regex replacement before sending the
prompt to the backend.&lt;&#x2F;p&gt;
&lt;p&gt;This is the key feature that makes workflows useful. Without it, you&#x27;d need
manual copy-paste between steps. With it, you can build arbitrary pipelines
where each step builds on previous results.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;dependency-resolution&quot;&gt;Dependency Resolution&lt;&#x2F;h3&gt;
&lt;p&gt;Steps declare dependencies with &lt;code&gt;depends_on&lt;&#x2F;code&gt;. The engine performs a topological
sort to determine execution order. Steps without dependencies can run in
parallel (though the current implementation runs sequentially for simplicity).&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[[steps]]
name = &amp;quot;patterns&amp;quot;
backend = &amp;quot;codex&amp;quot;
prompt = &amp;quot;Find code patterns&amp;quot;

[[steps]]
name = &amp;quot;dead-code&amp;quot;
backend = &amp;quot;codex&amp;quot;
prompt = &amp;quot;Find dead code&amp;quot;

[[steps]]
name = &amp;quot;synthesize&amp;quot;
backend = &amp;quot;claude&amp;quot;
depends_on = [&amp;quot;patterns&amp;quot;, &amp;quot;dead-code&amp;quot;]
prompt = &amp;quot;&amp;quot;&amp;quot;
Combine findings:
Patterns: {{ steps.patterns.output }}
Dead code: {{ steps.dead-code.output }}
&amp;quot;&amp;quot;&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first two steps have no dependencies, so they could run concurrently. The
third step waits for both to complete before running.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;conditional-execution&quot;&gt;Conditional Execution&lt;&#x2F;h3&gt;
&lt;p&gt;Steps can include a &lt;code&gt;when&lt;&#x2F;code&gt; clause for conditional execution:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[[steps]]
name = &amp;quot;emergency-fix&amp;quot;
backend = &amp;quot;claude&amp;quot;
depends_on = [&amp;quot;scan&amp;quot;]
when = &amp;quot;steps.scan.output contains &amp;#x27;critical&amp;#x27;&amp;quot;
prompt = &amp;quot;Propose immediate fixes for critical issues...&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the condition isn&#x27;t met, the step is skipped with a &lt;code&gt;[skip]&lt;&#x2F;code&gt; message. This
keeps workflows from doing unnecessary work.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;workflow-discovery&quot;&gt;Workflow Discovery&lt;&#x2F;h3&gt;
&lt;p&gt;Workflows are loaded from two locations:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.lok&#x2F;workflows&#x2F;&lt;&#x2F;code&gt; in the current directory (project-local)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;~&#x2F;.config&#x2F;lok&#x2F;workflows&#x2F;&lt;&#x2F;code&gt; (global)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Project-local workflows override global ones with the same name. This lets you
define team-wide workflows globally while allowing project-specific overrides.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok workflow list
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;Available workflows:

  security-review (global)
    Multi-pass security review with synthesis
    3 steps

  code-quality (global)
    Multi-backend code quality analysis
    3 steps

  rails-audit (local)
    Rails-specific security checks
    4 steps
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;the-plugin-system-realization&quot;&gt;The Plugin System Realization&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s the thing I didn&#x27;t plan: workflows are plugins.&lt;&#x2F;p&gt;
&lt;p&gt;When you define a workflow, you&#x27;re creating a reusable command. &lt;code&gt;lok run security-review&lt;&#x2F;code&gt; isn&#x27;t calling a built-in feature. It&#x27;s loading a TOML file and
executing it. The &quot;plugin&quot; is just configuration.&lt;&#x2F;p&gt;
&lt;p&gt;This means:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;No compilation needed to add new capabilities&lt;&#x2F;li&gt;
&lt;li&gt;Share workflows by copying TOML files&lt;&#x2F;li&gt;
&lt;li&gt;Customize existing workflows without touching Rust code&lt;&#x2F;li&gt;
&lt;li&gt;Version control your workflows alongside your code&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;A future &lt;code&gt;lok workflow install&lt;&#x2F;code&gt; could fetch workflows from URLs:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok workflow install https:&amp;#x2F;&amp;#x2F;example.com&amp;#x2F;rails-audit.toml
lok run rails-audit
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The plugin system is the workflow system. No separate concepts needed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-real-conductor-your-llm-session&quot;&gt;The Real Conductor: Your LLM Session&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s the pattern that actually works best: use your existing LLM as the
conductor, and call lok as a tool.&lt;&#x2F;p&gt;
&lt;p&gt;I run Claude Code as my daily driver. When I need multi-model analysis, I don&#x27;t
switch to &lt;code&gt;lok conduct&lt;&#x2F;code&gt;. I just ask Claude to use lok:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Me: Find performance issues in this codebase

Claude: [runs: lok ask --backend codex &amp;quot;Find N+1 queries and performance issues&amp;quot;]

Claude: I found 3 N+1 queries in the controllers. Let me get a second
        opinion on the caching strategy...

Claude: [runs: lok ask --backend gemini &amp;quot;Review caching approach in lib&amp;#x2F;cache.rb&amp;quot;]

Claude: Based on both analyses, here&amp;#x27;s what I&amp;#x27;d prioritize...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The LLM session becomes the orchestration layer. It sees results, reasons about
them, decides when to query other backends. No need for lok to implement its own
multi-round conversation loop.&lt;&#x2F;p&gt;
&lt;p&gt;This works because lok is just a command. It doesn&#x27;t try to be a chat interface
or maintain conversation state. It does one thing: send a prompt to backends and
return results. The intelligence stays in the LLM that&#x27;s already running.&lt;&#x2F;p&gt;
&lt;p&gt;The workflow engine complements this. For repeatable multi-step analysis, define
a workflow. For exploratory work where you need to reason about intermediate
results, let your LLM call lok directly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;putting-it-together&quot;&gt;Putting It Together&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s my actual workflow for auditing a new codebase:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Me: Audit this codebase for security issues

Claude: [runs: lok run security-review]

Claude: The workflow found 5 issues. The SQL interpolation in auth.rs
        looks serious. Let me investigate...

Claude: [runs: lok ask --backend gemini &amp;quot;Is the SQL in auth.rs:45 exploitable?&amp;quot;]

Claude: Gemini confirms it&amp;#x27;s exploitable. I&amp;#x27;ll draft a fix...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Workflows handle the repeatable multi-step analysis. Claude handles the
reasoning and follow-up questions. Lok is just the interface to multiple
backends. Each layer does what it&#x27;s good at.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;The dead code that &lt;code&gt;lok hunt&lt;&#x2F;code&gt; found in its own codebase still needs cleanup.
There&#x27;s also the question of parallel step execution in workflows, which would
make multi-backend pipelines faster.&lt;&#x2F;p&gt;
&lt;p&gt;But the core loop is solid: define workflows in TOML, run them with one command,
get multi-model analysis without the manual coordination tax.&lt;&#x2F;p&gt;
&lt;p&gt;The source is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&quot;&gt;github.com&#x2F;ducks&#x2F;lok&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Previous: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;introducing-lok-multi-llm-orchestration&quot;&gt;Introducing Lok&lt;&#x2F;a&gt; |
Next: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;lok-dogfooding-and-code-review&quot;&gt;Part 3: Dogfooding and Code Review&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>I Don&#x27;t Type Every Word You Read</title>
          <pubDate>Sun, 25 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/no-i-dont-type-every-word-you-read/</link>
          <guid>https://jakegoldsborough.com/blog/2026/no-i-dont-type-every-word-you-read/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/no-i-dont-type-every-word-you-read/">&lt;p&gt;Most modern work is already mediated by machines. Writing is just late to admit it.&lt;&#x2F;p&gt;
&lt;p&gt;Commerical pilots rarely fly planes by hand anymore. Chip designers do not solder
transistors. Photographers do not grind their own lenses. Infrastructure
engineers rarely flip physical switches in a datacenter.&lt;&#x2F;p&gt;
&lt;p&gt;In all of these fields, the work did not disappear.
It moved upstream.&lt;&#x2F;p&gt;
&lt;p&gt;Writing is undergoing the same shift.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-the-work-actually-is&quot;&gt;Where the Work Actually Is&lt;&#x2F;h2&gt;
&lt;p&gt;I do not type every word you read here.&lt;&#x2F;p&gt;
&lt;p&gt;That does not mean this writing is automated, synthetic, or detached from real
experience. It means the locus of effort has changed.&lt;&#x2F;p&gt;
&lt;p&gt;The work now lives in:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;deciding what is worth saying&lt;&#x2F;li&gt;
&lt;li&gt;knowing when something is wrong&lt;&#x2F;li&gt;
&lt;li&gt;recognizing when a paragraph feels hollow&lt;&#x2F;li&gt;
&lt;li&gt;choosing what to delete&lt;&#x2F;li&gt;
&lt;li&gt;setting constraints&lt;&#x2F;li&gt;
&lt;li&gt;maintaining direction&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The model generates text.
I decide what survives.&lt;&#x2F;p&gt;
&lt;p&gt;That distinction matters.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-i-actually-write-now&quot;&gt;How I Actually Write Now&lt;&#x2F;h2&gt;
&lt;p&gt;Most posts start the same way they always have: with a thought that will not
leave me alone.&lt;&#x2F;p&gt;
&lt;p&gt;Sometimes it comes from building something. Sometimes from frustration.
Sometimes from noticing the same pattern repeat across tools, systems, or
conversations.&lt;&#x2F;p&gt;
&lt;p&gt;I outline roughly. I write fragments. I ask an LLM to help me move past
blank-page friction, not to decide what I believe.&lt;&#x2F;p&gt;
&lt;p&gt;I frequently discard its output entirely.&lt;&#x2F;p&gt;
&lt;p&gt;I interrupt it mid-paragraph when it drifts. I reframe prompts when the tone
is wrong. I delete sections that feel technically correct but experientially
false.&lt;&#x2F;p&gt;
&lt;p&gt;The process is not faster because I think less.
It is faster because I spend less time fighting inertia.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;delegating-friction-not-thinking&quot;&gt;Delegating Friction, Not Thinking&lt;&#x2F;h2&gt;
&lt;p&gt;This is the part people often get backwards.&lt;&#x2F;p&gt;
&lt;p&gt;I am not delegating authorship.
I am delegating friction.&lt;&#x2F;p&gt;
&lt;p&gt;The same way a pilot delegates routine control to an autopilot in order to
focus on situational awareness, failure modes, and judgment.&lt;&#x2F;p&gt;
&lt;p&gt;Automation does not reduce responsibility.
It increases expectations.&lt;&#x2F;p&gt;
&lt;p&gt;When something goes wrong, there is still a human in the loop.
There always is.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;is-this-still-worth-reading&quot;&gt;Is This Still Worth Reading?&lt;&#x2F;h2&gt;
&lt;p&gt;That is the question people tend to circle without asking directly.&lt;&#x2F;p&gt;
&lt;p&gt;If a post reflects real experience, real constraints, and real judgment, I do
not care which tool helped shape the sentences.&lt;&#x2F;p&gt;
&lt;p&gt;Authenticity does not come from keystrokes.
It comes from context.&lt;&#x2F;p&gt;
&lt;p&gt;You can feel the difference between writing that emerged from lived systems
and writing that emerged from nowhere. Tools do not erase that distinction.&lt;&#x2F;p&gt;
&lt;p&gt;They amplify it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;writing-did-not-end-it-moved&quot;&gt;Writing Did Not End. It Moved.&lt;&#x2F;h2&gt;
&lt;p&gt;We already accept this shift everywhere else.&lt;&#x2F;p&gt;
&lt;p&gt;We do not demand that pilots prove their worth by flying without instruments.
We do not ask engineers to manage infrastructure without automation. We do
not measure photographers by how manual their camera is.&lt;&#x2F;p&gt;
&lt;p&gt;We judge outcomes, judgment, and reliability.&lt;&#x2F;p&gt;
&lt;p&gt;Writing is no different.&lt;&#x2F;p&gt;
&lt;p&gt;The work did not disappear.
It moved.&lt;&#x2F;p&gt;
&lt;p&gt;And learning where it lives now is part of the craft.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Introducing Lok: A Local Multi-LLM Orchestration Control Plane</title>
          <pubDate>Sat, 24 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/introducing-lok-multi-llm-orchestration/</link>
          <guid>https://jakegoldsborough.com/blog/2026/introducing-lok-multi-llm-orchestration/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/introducing-lok-multi-llm-orchestration/">&lt;p&gt;Large language models are getting better, but they&#x27;re also getting more
specialized. Some are fast and direct for pattern matching. Others are slower
but excel at deep, multi-step reasoning. If you work on real codebases, you&#x27;ve
probably felt the pain: no single model is &quot;best&quot; for every task, and switching
between tools manually is a constant tax.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the problem Lok solves.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-brain-that-controls-the-arms-you-already-have&quot;&gt;The Brain That Controls the Arms You Already Have&lt;&#x2F;h2&gt;
&lt;p&gt;Lok is a local orchestration layer that coordinates multiple LLM backends
through one control plane. It wraps existing CLIs like OpenAI&#x27;s Codex and
Google&#x27;s Gemini, treating them as pluggable backends with a unified interface.&lt;&#x2F;p&gt;
&lt;p&gt;The key insight: model choice isn&#x27;t a preference anymore. It&#x27;s part of the
engineering workflow. When your toolchain includes multiple LLMs, you need
orchestration.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok hunt .          # Bug hunt with smart backend selection
lok audit .         # Security audit
lok team &amp;quot;analyze&amp;quot;  # Coordinated multi-model analysis
lok debate &amp;quot;async?&amp;quot; # Let the models argue
lok spawn &amp;quot;task&amp;quot;    # Parallel agents on subtasks
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;                            ┌─────────────┐
                            │    USER     │
                            │   (task)    │
                            └──────┬──────┘
                                   │
                                   ▼
            ┌──────────────────────────────────────────┐
            │           CONDUCTOR (BRAIN)              │
            │                                          │
            │  • Analyze task complexity               │
            │  • Break into parallel subtasks          │
            │  • Assign backends via delegator         │
            └──────────────────┬───────────────────────┘
                               │
      ┌────────────────────────┼────────────────────────┐
      │                        │                        │
      ▼                        ▼                        ▼
┌───────────┐            ┌───────────┐            ┌───────────┐
│  AGENT 1  │            │  AGENT 2  │            │  AGENT 3  │
│ &amp;quot;frontend&amp;quot;│            │ &amp;quot;backend&amp;quot; │            │ &amp;quot;database&amp;quot;│
│  [CODEX]  │            │ [GEMINI]  │            │  [CODEX]  │
└─────┬─────┘            └─────┬─────┘            └─────┬─────┘
      │                        │                        │
      │     ══ PARALLEL EXECUTION ══                    │
      │                        │                        │
      └────────────────────────┼────────────────────────┘
                               │
                               ▼
            ┌──────────────────────────────────────────┐
            │         SUMMARIZATION PHASE              │
            │                                          │
            │  • Collect all agent outputs             │
            │  • Report success&amp;#x2F;failure per agent      │
            │  • Aggregate into final summary          │
            └──────────────────────────────────────────┘
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;mode-comparison&quot;&gt;Mode Comparison&lt;&#x2F;h2&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Mode&lt;&#x2F;th&gt;&lt;th&gt;Backends Used&lt;&#x2F;th&gt;&lt;th&gt;Execution&lt;&#x2F;th&gt;&lt;th&gt;Use Case&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;smart&lt;&#x2F;td&gt;&lt;td&gt;1 (best fit)&lt;&#x2F;td&gt;&lt;td&gt;Single call&lt;&#x2F;td&gt;&lt;td&gt;Fast, targeted tasks&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;team&lt;&#x2F;td&gt;&lt;td&gt;1-3&lt;&#x2F;td&gt;&lt;td&gt;Sequential&lt;&#x2F;td&gt;&lt;td&gt;Analysis + optional peer review&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;debate&lt;&#x2F;td&gt;&lt;td&gt;2+&lt;&#x2F;td&gt;&lt;td&gt;3 rounds&lt;&#x2F;td&gt;&lt;td&gt;High-stakes decisions&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;spawn&lt;&#x2F;td&gt;&lt;td&gt;2-4&lt;&#x2F;td&gt;&lt;td&gt;Parallel&lt;&#x2F;td&gt;&lt;td&gt;Complex tasks with subtasks&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;h2 id=&quot;smart-delegation-the-right-tool-for-the-job&quot;&gt;Smart Delegation: The Right Tool for the Job&lt;&#x2F;h2&gt;
&lt;p&gt;Not every task requires the most expensive, reasoning-heavy model. Conversely,
complex security audits shouldn&#x27;t be handled by a model optimized for speed.&lt;&#x2F;p&gt;
&lt;p&gt;Lok&#x27;s delegator (&lt;code&gt;src&#x2F;delegation.rs&lt;&#x2F;code&gt;) routes tasks based on keyword matching and
task classification:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;N+1 queries, code smells, dead code&lt;&#x2F;strong&gt;: fast, pattern-matching models (Codex, Claude Haiku)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Security audits, architecture reviews&lt;&#x2F;strong&gt;: thorough, investigative models (Gemini, o1)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;General questions&lt;&#x2F;strong&gt;: whatever&#x27;s available (first responsive backend)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The routing logic is straightforward: task descriptions are tokenized and matched
against known patterns. If the task contains &quot;security&quot;, &quot;audit&quot;, &quot;vulnerability&quot;,
it routes to investigative models. If it contains &quot;find&quot;, &quot;search&quot;, &quot;pattern&quot;, it
routes to fast models. No ML involved - just conditional routing based on task
characteristics.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok smart &amp;quot;Find N+1 queries&amp;quot;           # Routes to Codex
lok smart &amp;quot;Security audit&amp;quot;              # Routes to Gemini
lok suggest &amp;quot;Find SQL injection&amp;quot;        # Shows routing decision without running
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;When routing fails&lt;&#x2F;strong&gt;: If the chosen backend is unavailable, Lok falls back to
the next-best available backend. If all backends fail, you get a clear error
message listing what&#x27;s offline.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;debate-mode-built-in-skepticism&quot;&gt;Debate Mode: Built-In Skepticism&lt;&#x2F;h2&gt;
&lt;p&gt;Single-model answers are often too confident. Debate mode turns that into a
feature by making backends disagree on purpose.&lt;&#x2F;p&gt;
&lt;p&gt;In &lt;code&gt;lok debate&lt;&#x2F;code&gt;, each backend responds in multiple rounds. They see each other&#x27;s
answers and can challenge them. Round 1 is initial positions. Round 2 is
responses to each other&#x27;s positions. Round 3 is final synthesis by a judge
model that weighs all perspectives.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok debate &amp;quot;What&amp;#x27;s the best way to handle auth?&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;How synthesis works&lt;&#x2F;strong&gt;: The judge model receives all responses with their
round numbers and prompts: &quot;Given these competing perspectives, identify points
of agreement, highlight unresolved disagreements, and synthesize a final
recommendation that acknowledges tradeoffs.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;This catches two failure modes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;False confidence&lt;&#x2F;strong&gt;: A single model confidently recommending an antipattern&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Blind spots&lt;&#x2F;strong&gt;: One model missing a constraint that another catches&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The cost is 3x the API calls and 2-4x the latency. Use it for decisions where
being wrong is expensive.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;team-mode-coordinated-analysis&quot;&gt;Team Mode: Coordinated Analysis&lt;&#x2F;h2&gt;
&lt;p&gt;Team mode combines smart delegation with optional debate. It orchestrates the
backends like a small group:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Choose the best available backend for the task&lt;&#x2F;li&gt;
&lt;li&gt;If debate is enabled, ask others to review or challenge&lt;&#x2F;li&gt;
&lt;li&gt;Synthesize a final result&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok team &amp;quot;Analyze this codebase for issues&amp;quot;
lok team --debate &amp;quot;Should we use async here?&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This gives you both the speed of a model that&#x27;s good at the task and the rigor
of peer review. In practice, it feels like having a lead engineer and two
reviewers that don&#x27;t get tired.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;spawn-mode-parallel-agent-execution&quot;&gt;Spawn Mode: Parallel Agent Execution&lt;&#x2F;h2&gt;
&lt;p&gt;Spawn takes the coordination further. Instead of routing a single task to the
best backend, it breaks a complex task into parallel subtasks and runs multiple
agents simultaneously.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok spawn &amp;quot;Build a todo app with frontend and backend&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The flow:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Plan&lt;&#x2F;strong&gt;: An LLM breaks the task into 2-4 independent subtasks&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Delegate&lt;&#x2F;strong&gt;: Each subtask gets assigned to the best available backend&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Execute&lt;&#x2F;strong&gt;: All agents run in parallel with shared context&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Summarize&lt;&#x2F;strong&gt;: Results are collected and aggregated&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;You can also specify agents manually:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;lok spawn &amp;quot;Build an app&amp;quot; \
  --agent &amp;quot;api:Build REST endpoints&amp;quot; \
  --agent &amp;quot;ui:Build React components&amp;quot; \
  --agent &amp;quot;db:Design the schema&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is the conductor pattern in CLI form. A brain that plans, delegates to
specialized workers, and synthesizes results. The same pattern that makes
human teams effective, applied to LLM orchestration.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-naming-story&quot;&gt;The Naming Story&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;Lok&quot; has two meanings, both relevant.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Locomotive&lt;&#x2F;strong&gt; (Swedish&#x2F;German: lokomotiv). Lok has a &lt;code&gt;conduct&lt;&#x2F;code&gt; command, and the
metaphor is intentional: a conductor sends trained models down the tracks. The
pun on &quot;trained&quot; models is deliberate.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Sanskrit&#x2F;Hindi लोक&lt;&#x2F;strong&gt; (&quot;world&quot; or &quot;people&quot;), as in Lok Sabha, the People&#x27;s
Assembly. Lok&#x27;s philosophy is a collection of agents working together, not a
single monolithic mind.&lt;&#x2F;p&gt;
&lt;p&gt;The name captures both the engineering (orchestration, routing, coordination)
and the philosophy (collective intelligence, multiple perspectives).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;configuration-encode-your-team-s-knowledge&quot;&gt;Configuration: Encode Your Team&#x27;s Knowledge&lt;&#x2F;h2&gt;
&lt;p&gt;Lok works out of the box, but gets more powerful with &lt;code&gt;lok.toml&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[tasks.hunt]
description = &amp;quot;Find bugs and code issues&amp;quot;
backends = [&amp;quot;codex&amp;quot;]
prompts = [
  { name = &amp;quot;n+1&amp;quot;, prompt = &amp;quot;Search for N+1 query issues...&amp;quot; },
  { name = &amp;quot;dead-code&amp;quot;, prompt = &amp;quot;Find unused code...&amp;quot; },
]

[tasks.audit]
description = &amp;quot;Security audit&amp;quot;
backends = [&amp;quot;gemini&amp;quot;]
prompts = [
  { name = &amp;quot;injection&amp;quot;, prompt = &amp;quot;Find SQL injection...&amp;quot; },
  { name = &amp;quot;auth&amp;quot;, prompt = &amp;quot;Find auth bypass...&amp;quot; },
]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This isn&#x27;t just configuration. It&#x27;s a way to encode your team&#x27;s knowledge about
which model to trust for what. Tasks become repeatable workflows, not one-off
experiments.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;&#x2F;h2&gt;
&lt;p&gt;We&#x27;re moving away from the era of &quot;Prompt Engineering&quot; and into the era of Flow
Engineering. The quality of an AI output is no longer determined solely by how
clever your prompt is, but by the architecture of the workflow that processes
it.&lt;&#x2F;p&gt;
&lt;p&gt;By formalizing these flows into a CLI tool, Lok brings determinism and
reliability to AI interactions:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Higher signal, lower noise&lt;&#x2F;strong&gt;: Smart delegation keeps the right model on the
right task&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Built-in skepticism&lt;&#x2F;strong&gt;: Debate and team modes catch errors and broaden
coverage&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Parallel execution&lt;&#x2F;strong&gt;: Spawn mode runs multiple agents simultaneously,
turning sequential workflows into concurrent ones&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Repeatable workflows&lt;&#x2F;strong&gt;: Tasks like &lt;code&gt;lok hunt&lt;&#x2F;code&gt; become part of your
engineering rhythm&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Local control plane&lt;&#x2F;strong&gt;: No hidden SaaS layer, no opaque routing. You can see
and customize how it chooses backends&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;before-and-after-a-real-example&quot;&gt;Before and After: A Real Example&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Without Lok:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Manual workflow for finding Rails performance issues
$ claude &amp;quot;Find N+1 queries in app&amp;#x2F;controllers&amp;quot;
# Review output, switch tools
$ gemini &amp;quot;Are there better caching strategies?&amp;quot;
# Manually synthesize both answers
# Total time: 5-10 minutes of context switching
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;With Lok:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Single command, automatic backend selection and synthesis
$ lok team --debate &amp;quot;Analyze Rails app for performance issues&amp;quot;
# Codex finds N+1 queries (fast, pattern-matching)
# Gemini suggests caching strategies (thorough, investigative)
# Judge model synthesizes into prioritized action items
# Total time: 2 minutes, no context switching
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The value isn&#x27;t just speed - it&#x27;s that you get both the exhaustive pattern
matching and the strategic recommendations in one pass, with built-in
skepticism from debate mode catching false positives.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;&#x2F;h2&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Check what backends you have
lok doctor

# Ask all backends
lok ask &amp;quot;Find performance issues&amp;quot;

# Let them debate
lok debate &amp;quot;Best approach for caching?&amp;quot;

# Smart routing
lok smart &amp;quot;Find N+1 queries&amp;quot;

# Parallel agents
lok spawn &amp;quot;Build a REST API with tests&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Lok doesn&#x27;t replace your LLMs. It coordinates them. That means you keep the
tools you already trust and add orchestration on top.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Performance characteristics&lt;&#x2F;strong&gt;: Smart routing adds ~50-100ms overhead for task
classification. Debate mode runs 3 rounds sequentially, so expect 3x the
single-model latency. Spawn mode runs agents in parallel, so wall-clock time is
determined by the slowest agent, not the sum of all agents.&lt;&#x2F;p&gt;
&lt;p&gt;The source is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;lok&quot;&gt;github.com&#x2F;ducks&#x2F;lok&lt;&#x2F;a&gt;. It&#x27;s
Rust, it&#x27;s fast, and it&#x27;s the brain that makes your AI arms work together.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Next: &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;lok-workflows-and-local-llms&quot;&gt;Part 2: Workflows and Local LLMs&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>How Jennifer Aniston and Friends Cost Us 377GB and Broke ext4 Hardlinks</title>
          <pubDate>Fri, 23 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/how-a-friends-gif-broke-our-filesystem/</link>
          <guid>https://jakegoldsborough.com/blog/2026/how-a-friends-gif-broke-our-filesystem/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/how-a-friends-gif-broke-our-filesystem/">&lt;h2 id=&quot;intro&quot;&gt;Intro&lt;&#x2F;h2&gt;
&lt;p&gt;It started with backup issues. Sites with hundreds of gigabytes of uploads
were running out of disk space during backup generation. One site had 600+ GB
of uploads and the backup process kept dying.&lt;&#x2F;p&gt;
&lt;p&gt;While looking into reliable large backups, we discovered something wild in one
of those sites: the actual unique content was a fraction of the reported size.
They were storing the same files over and over again, each with a different
filename. The duplication was absurd.&lt;&#x2F;p&gt;
&lt;p&gt;So we shipped an optimization. Detect duplicate files by their content hash, use
hardlinks instead of downloading each copy. I wrote some new tests, they all
passed, it got approved and merged. But unfortunately, a fix like this is kind
of hard to actually fully test.&lt;&#x2F;p&gt;
&lt;p&gt;Then someone ran it on a real production backup and hit a filesystem limit I
didn&#x27;t know existed. The culprit? A single reaction GIF, duplicated 246,173
times.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h2&gt;
&lt;p&gt;Discourse has a feature called secure uploads. When a file moves between
security contexts (say, from a private message to a public post), the system
creates a new copy with a randomized SHA1. The original content is identical,
but Discourse treats it as a new file.&lt;&#x2F;p&gt;
&lt;p&gt;This happens constantly with reaction GIFs and popular images. Users share them
across posts, embed them in PMs, repost in different categories. Each context
creates another copy.&lt;&#x2F;p&gt;
&lt;p&gt;This is mostly fine for normal operation. But for backups, it&#x27;s a disaster.&lt;&#x2F;p&gt;
&lt;p&gt;One customer had 432 GB of uploads. Unique content? 26 GB. The rest was
duplicates. A 16x inflation factor, all going into the backup archive.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The Fix&lt;&#x2F;h2&gt;
&lt;p&gt;The fix seemed straightforward. Discourse tracks the original content hash in
&lt;code&gt;original_sha1&lt;&#x2F;code&gt;. During backup:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Group uploads by &lt;code&gt;original_sha1&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Download the first file in each group&lt;&#x2F;li&gt;
&lt;li&gt;Create hardlinks for the duplicates&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Hardlinks point multiple filenames to the same data on disk. GNU tar preserves
them, so the archive stores the data once. Download 26 GB, archive 26 GB,
everyone wins.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def process_upload_group(upload_group)
  primary = upload_group.first
  primary_filename = upload_path_in_archive(primary)

  return if !download_upload_to_file(primary, primary_filename)

  # Create hardlinks for all duplicates in this group
  upload_group.drop(1).each do |duplicate|
    duplicate_filename = upload_path_in_archive(duplicate)
    hardlink_or_download(primary_filename, duplicate, duplicate_filename)
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;hardlink_or_download&lt;&#x2F;code&gt; method falls back to downloading if the hardlink
fails:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def hardlink_or_download(source_filename, upload_data, target_filename)
  FileUtils.mkdir_p(File.dirname(target_filename))
  FileUtils.ln(source_filename, target_filename)  # Create hardlink
  increment_and_log_progress(:hardlinked)
rescue StandardError =&amp;gt; ex
  # Fallback: download if hardlink fails
  log &amp;quot;Failed to create hardlink, downloading instead&amp;quot;, ex
  download_upload_to_file(upload_data, target_filename)
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Shipped it and got positive feedback.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-limit&quot;&gt;The Limit&lt;&#x2F;h2&gt;
&lt;p&gt;A colleague then used the new version to run a backup on a large site. The logs looked great:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;53000 files processed (25 downloaded, 52975 hardlinked). Still processing...
54000 files processed (25 downloaded, 53975 hardlinked). Still processing...
...
64000 files processed (25 downloaded, 63975 hardlinked). Still processing...
65000 files processed (25 downloaded, 64975 hardlinked). Still processing...
Failed to create hardlink for upload ID 482897, downloading instead
Failed to create hardlink for upload ID 457497, downloading instead
Failed to create hardlink for upload ID 867574, downloading instead
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;At 65,000 hardlinks, it started failing. Turns out ext4 has a limit: roughly
65,000 hardlinks per inode. One file can only have 65,000 names pointing to it.&lt;&#x2F;p&gt;
&lt;p&gt;The fallback worked and it didn&#x27;t fail completely. The backup finished. But
instead of one download for all 246,173 duplicates, we got one download plus
~181,000 fallback downloads after hitting the limit.&lt;&#x2F;p&gt;
&lt;p&gt;Still better than 246,173 downloads. But not the win I expected.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-gif&quot;&gt;The GIF&lt;&#x2F;h2&gt;
&lt;p&gt;So what file had 246,173 copies?&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;Upload.where(original_sha1: &amp;#x27;27b7a62e34...&amp;#x27;).count
=&amp;gt; 246173

Upload.where(original_sha1: &amp;#x27;27b7a62e34...&amp;#x27;).first.filesize
=&amp;gt; 1643869
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;1.6 MB. Duplicated a quarter million times. That&#x27;s 377 GB of backup bloat from
a single image.&lt;&#x2F;p&gt;
&lt;p&gt;And then I saw what it was...&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;jennifer-aniston.gif&quot; alt=&quot;Jennifer Aniston dancing from Friends&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A reaction GIF. Used constantly in posts, PMs, everywhere. Each use in a
different security context creates a new copy. 246,173 copies of Rachel from
Friends doing a happy dance.&lt;&#x2F;p&gt;
&lt;p&gt;One GIF broke the hardlink limit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-math&quot;&gt;The Math&lt;&#x2F;h2&gt;
&lt;p&gt;Without deduplication: 246,173 downloads, 377 GB transferred.&lt;&#x2F;p&gt;
&lt;p&gt;With deduplication (hitting limit): ~4 downloads, ~6.4 MB transferred.&lt;&#x2F;p&gt;
&lt;p&gt;The filesystem limit turned my &quot;download once&quot; into &quot;download four times.&quot; I
can live with that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-fix-for-the-fix&quot;&gt;The Fix for the Fix&lt;&#x2F;h2&gt;
&lt;p&gt;My first instinct was to track hardlink counts and proactively rotate before
hitting the limit. But a colleague pointed out the flaw: we have no idea what
filesystem is being used. ext4 has one limit, XFS another, ZFS another. Picking
a magic number is fragile.&lt;&#x2F;p&gt;
&lt;p&gt;Better approach: let the filesystem tell us when we&#x27;ve hit the limit.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def create_hardlink(source_filename, upload_data, target_filename)
  FileUtils.mkdir_p(File.dirname(target_filename))
  FileUtils.ln(source_filename, target_filename)
  source_filename
rescue Errno::EMLINK
  # Filesystem hardlink limit reached - copy and use as new primary
  FileUtils.cp(source_filename, target_filename)
  target_filename
rescue StandardError =&amp;gt; ex
  download_upload_to_file(upload_data, target_filename)
  source_filename
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When &lt;code&gt;Errno::EMLINK&lt;&#x2F;code&gt; fires, we already have the file locally. No need to
re-download. Just copy it and use the copy as the new primary for subsequent
hardlinks. Works on any filesystem, no configuration needed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;&#x2F;h2&gt;
&lt;p&gt;Filesystems have opinions. ext4&#x27;s hardlink limit exists to prevent certain
classes of bugs and attacks. It&#x27;s not arbitrary.&lt;&#x2F;p&gt;
&lt;p&gt;The fallback saved the feature. Without graceful degradation, that backup would
have failed entirely. Instead it completed, just slower than optimal.&lt;&#x2F;p&gt;
&lt;p&gt;Production always finds the edge cases. 246,000 copies of one file is absurd.
But absurd things happen at scale.&lt;&#x2F;p&gt;
&lt;p&gt;A few concrete takeaways:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Test for failure modes, not just success paths. The hardlink fallback was
built-in from the start, but I never expected to actually need it.&lt;&#x2F;li&gt;
&lt;li&gt;Optimizations that reduce work by 16x still need to handle edge cases. A
99.998% improvement with graceful degradation beats a 100% improvement that
crashes.&lt;&#x2F;li&gt;
&lt;li&gt;Track filesystem-level constraints early. Hardlink limits, inode counts, path
lengths - these are real operational boundaries, not theoretical concerns.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And now I know Jennifer Aniston can stress-test infrastructure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;discourse&#x2F;pull&#x2F;37261&quot;&gt;Discourse PR #37261&lt;&#x2F;a&gt; -
The backup deduplication fix&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;discourse&#x2F;pull&#x2F;37293&quot;&gt;Discourse PR #37293&lt;&#x2F;a&gt; -
The hardlink limit fix&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>The Unholy Trinity: Nix Shells, SSH Config, and Claude Code</title>
          <pubDate>Wed, 21 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/nix-shells-ssh-claude-code/</link>
          <guid>https://jakegoldsborough.com/blog/2026/nix-shells-ssh-claude-code/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/nix-shells-ssh-claude-code/">&lt;p&gt;I wrote about &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;how-i-am-using-claude-code&#x2F;&quot;&gt;using Claude Code for daily development&lt;&#x2F;a&gt;
a few months ago. That post was about workflow and mindset. This one is about
infrastructure.&lt;&#x2F;p&gt;
&lt;p&gt;Turns out, the real multiplier isn&#x27;t how you prompt the AI. It&#x27;s what you let
it touch.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem-with-ai-coding-assistants&quot;&gt;The Problem With AI Coding Assistants&lt;&#x2F;h2&gt;
&lt;p&gt;Most AI coding tools work in a sandbox. They can read your code, suggest
changes, maybe run a linter. But the moment you need to actually test
something, build something, deploy something? You&#x27;re back to copy-pasting
commands.&lt;&#x2F;p&gt;
&lt;p&gt;&quot;Here&#x27;s how you&#x27;d run the tests&quot; is not the same as running the tests.&lt;&#x2F;p&gt;
&lt;p&gt;Claude Code is different because it has shell access. It can run commands,
read output, iterate. But that only matters if the environment is set up for
it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;nix-shells-run-anything-anytime&quot;&gt;Nix Shells: Run Anything, Anytime&lt;&#x2F;h2&gt;
&lt;p&gt;Every project I work on has a &lt;code&gt;shell.nix&lt;&#x2F;code&gt;. When Claude needs to run something,
the command is:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nix-shell --run &amp;quot;bundle exec rspec&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s it. No &quot;first install Rust nightly, then set up ALSA, then configure
pkg-config paths.&quot; The shell.nix declares everything. Claude doesn&#x27;t need to
know how to set up a Rust audio environment. It just needs to know &lt;code&gt;nix-shell&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s what my &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;shelltrax&lt;&#x2F;a&gt; shell looks like:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;{ pkgs ? import &amp;lt;nixpkgs&amp;gt; {} }:
let
  rust-overlay = import (builtins.fetchTarball
    &amp;quot;https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;oxalica&amp;#x2F;rust-overlay&amp;#x2F;archive&amp;#x2F;master.tar.gz&amp;quot;);
  pkgs&amp;#x27; = import &amp;lt;nixpkgs&amp;gt; { overlays = [ rust-overlay ]; };
  rust = pkgs&amp;#x27;.rust-bin.nightly.latest.default;
in
pkgs&amp;#x27;.mkShell {
  buildInputs = with pkgs&amp;#x27;; [
    rust rust-analyzer
    pkg-config alsa-lib openssl gcc
  ];
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Claude can run tests, build releases, check for warnings. All without me
explaining how to install Rust nightly or configure audio libraries.&lt;&#x2F;p&gt;
&lt;p&gt;The pattern works everywhere. Rust project? &lt;code&gt;nix-shell --run &quot;cargo test&quot;&lt;&#x2F;code&gt;.
Ruby project? &lt;code&gt;nix-shell --run &quot;bundle exec rspec&quot;&lt;&#x2F;code&gt;. Node? Same deal. The AI
doesn&#x27;t need to understand package managers. It just needs one command that
always works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;ssh-config-the-revelation&quot;&gt;SSH Config: The Revelation&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;ve had SSH config set up for years. &lt;code&gt;Host pond&lt;&#x2F;code&gt; instead of typing IPs,
key-based auth, the usual. Standard stuff for anyone who manages servers.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Host pond
    HostName 199.68.196.244
    User ducks
    IdentityFile ~&amp;#x2F;.ssh&amp;#x2F;pond_ed25519
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;What I hadn&#x27;t considered: Claude can use it too.&lt;&#x2F;p&gt;
&lt;p&gt;I was debugging why my analytics service was down. Normally I&#x27;d open another
terminal tab, SSH in, check logs, come back and describe what I found. The
usual dance.&lt;&#x2F;p&gt;
&lt;p&gt;Instead, on a whim, I just asked Claude to check the logs. And it did.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;ssh pond &amp;quot;journalctl -u goatcounter -n 50&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It found the error, suggested the fix, and I approved the command to restart
the service. I never left the conversation.&lt;&#x2F;p&gt;
&lt;p&gt;That was the moment it clicked. My SSH config wasn&#x27;t just for me anymore. Any
host I can reach, Claude can reach. Any command I can run remotely, Claude can
run remotely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-combination&quot;&gt;The Combination&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s where it gets interesting. These two things compound.&lt;&#x2F;p&gt;
&lt;p&gt;Locally, nix-shell means Claude can build, test, and run anything in any of my
projects. Remotely, SSH config means Claude can check, restart, and deploy
anything on my servers.&lt;&#x2F;p&gt;
&lt;p&gt;The AI goes from &quot;helpful for writing code&quot; to &quot;helpful for everything.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Example from this week: I was debugging why a webhook wasn&#x27;t firing. Claude:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Read the local webhook handler code&lt;&#x2F;li&gt;
&lt;li&gt;SSH&#x27;d to the server to check the logs&lt;&#x2F;li&gt;
&lt;li&gt;Found the error (SSL cert issue on callback URL)&lt;&#x2F;li&gt;
&lt;li&gt;Suggested the fix&lt;&#x2F;li&gt;
&lt;li&gt;I approved, it deployed&lt;&#x2F;li&gt;
&lt;li&gt;SSH&#x27;d back to verify the fix worked&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;That&#x27;s five context switches I didn&#x27;t have to make. Five terminal tabs I
didn&#x27;t have to open. And I stayed focused on understanding the problem instead
of typing commands.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-enables&quot;&gt;What This Enables&lt;&#x2F;h2&gt;
&lt;p&gt;With proper setup, Claude becomes useful for:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Local development&lt;&#x2F;strong&gt; - Run tests, check linting, build assets. Not &quot;here&#x27;s
how to run tests&quot; but actually running them and reading the output.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Server management&lt;&#x2F;strong&gt; - Check service status, read logs, restart processes.
Actual ops work, not just suggesting commands.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Debugging across boundaries&lt;&#x2F;strong&gt; - Read local code, check remote logs,
correlate the two. The AI can see both sides of the problem.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Deployments&lt;&#x2F;strong&gt; - On my NixOS server, &lt;code&gt;nixos-rebuild switch&lt;&#x2F;code&gt; is the entire
deploy process. Claude can run it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-trust-question&quot;&gt;The Trust Question&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;But do you really want AI SSH&#x27;d into your production server?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Fair question. My answer: it&#x27;s my personal VPS running hobby projects. The
worst case is I rebuild it from my NixOS config. If this were production
infrastructure at work, I&#x27;d be more careful.&lt;&#x2F;p&gt;
&lt;p&gt;But for personal stuff? The productivity gain is massive. And honestly,
Claude is less likely to &lt;code&gt;rm -rf &#x2F;&lt;&#x2F;code&gt; than I am after a long day.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;setting-it-up&quot;&gt;Setting It Up&lt;&#x2F;h2&gt;
&lt;p&gt;If you want to try this:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;1. Create shell.nix files for your projects&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Even a minimal one helps:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;{ pkgs ? import &amp;lt;nixpkgs&amp;gt; {} }:
pkgs.mkShell {
  buildInputs = with pkgs; [ ruby nodejs yarn ];
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;2. Set up SSH config with key auth&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Host myserver
    HostName your.ip.here
    User youruser
    IdentityFile ~&amp;#x2F;.ssh&amp;#x2F;your_key
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;3. Tell Claude about it&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;In your CLAUDE.md or context file:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&quot;Use &lt;code&gt;nix-shell --run&lt;&#x2F;code&gt; for commands in projects with shell.nix&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&quot;SSH host &lt;code&gt;myserver&lt;&#x2F;code&gt; is available for server operations&quot;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That&#x27;s it. Now your AI assistant has actual hands instead of just a mouth.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-multiplier-effect&quot;&gt;The Multiplier Effect&lt;&#x2F;h2&gt;
&lt;p&gt;Good tools multiply your output. AI is a good tool. But AI with proper
environment access? That&#x27;s a different category.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m not faster because the AI writes better code. I&#x27;m faster because the AI
can actually verify what it writes. It can run tests, check logs, and iterate
without me being the middleman.&lt;&#x2F;p&gt;
&lt;p&gt;The unholy trinity: Nix makes everything reproducible. SSH makes everything
reachable. Claude ties it together.&lt;&#x2F;p&gt;
&lt;p&gt;Set up your environment right, and AI coding assistants stop being a novelty.
They become infrastructure.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Letting AI Pick the Project</title>
          <pubDate>Mon, 19 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/letting-ai-pick-the-project/</link>
          <guid>https://jakegoldsborough.com/blog/2026/letting-ai-pick-the-project/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/letting-ai-pick-the-project/">&lt;p&gt;With the rise of AI and LLMs, a common theme has been &quot;don&#x27;t let the machine do
the thinking&quot;. The argument is we simply become expensive copy and pasters. And
while I mostly agree, I was curious.&lt;&#x2F;p&gt;
&lt;p&gt;Well, I tried an experiment today. Instead of coming to Claude Code with a
specific task, I asked: &quot;Based on everything you know, what would you build?
Create a repo, use whatever language you want, document it along the way.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;No constraints. No requirements. Just: build something.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-chose&quot;&gt;What It Chose&lt;&#x2F;h2&gt;
&lt;p&gt;Claude chose to build &lt;code&gt;why&lt;&#x2F;code&gt;, a CLI tool for code archaeology. The pitch:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;I spend so much of my time helping people understand codebases, and the
hardest question is always &quot;why was this decision made?&quot; The answer is
scattered across git blame, old PRs, commit messages, issue trackers, maybe
a Slack thread somewhere that&#x27;s now lost.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;The tool points at a file or line range and synthesizes a narrative: who
introduced this code, when, what commits modified it, what issues and PRs are
referenced, what type of changes they were (bug fix, feature, security patch).&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;why app&amp;#x2F;models&amp;#x2F;user.rb --range 100-150
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;pre&gt;&lt;code&gt;The Story
=========

This code was first introduced by Jeff Atwood on 2020-05-22.
The initial commit said: &amp;quot;Revert &amp;#x27;Merge branch master&amp;#x27;&amp;quot;

Since then, it has been modified 5 time(s):

  * FIX: do not send rejection emails to auto-deleted users (#12160)
    by Arpit Jalan
    Type: Bug fix

  * SECURITY: Limit name field length of TOTP authenticators
    by OsamaSayegh
    Type: Security
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;the-process&quot;&gt;The Process&lt;&#x2F;h2&gt;
&lt;p&gt;What struck me was how Claude approached it. It started by reasoning about
what problems it encounters repeatedly. Not what would be cool to build, but
what it genuinely wished existed.&lt;&#x2F;p&gt;
&lt;p&gt;Then it moved fast. Within minutes: Rust project scaffolded, git blame
parsing working, commit categorization logic, GitHub API integration. It
tested on its own source code, then on Discourse (a large real-world
codebase I work on).&lt;&#x2F;p&gt;
&lt;p&gt;When I suggested adding Discourse as a context source (we use it for work
discussions), it designed a pluggable trait system:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[async_trait]
pub trait ContextSource: Send + Sync {
    fn name(&amp;amp;self) -&amp;gt; &amp;amp;str;
    fn detect_references(&amp;amp;self, text: &amp;amp;str) -&amp;gt; Vec&amp;lt;Reference&amp;gt;;
    async fn fetch_reference(&amp;amp;self, reference: &amp;amp;Reference) -&amp;gt; Result&amp;lt;Option&amp;lt;ContextItem&amp;gt;&amp;gt;;
    async fn search(&amp;amp;self, query: &amp;amp;str) -&amp;gt; Result&amp;lt;Vec&amp;lt;ContextItem&amp;gt;&amp;gt;;
    fn is_available(&amp;amp;self) -&amp;gt; bool;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;GitHub and Discourse implementations, easy to add more. The architecture
emerged naturally from the feature request.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-tangents&quot;&gt;The Tangents&lt;&#x2F;h2&gt;
&lt;p&gt;We went down a few rabbit holes. I mentioned that real context often lives in
Slack threads that never got linked to the code. Claude immediately saw the
harder problem: semantic search across communication history, not just
following explicit links.&lt;&#x2F;p&gt;
&lt;p&gt;It outlined what that would require: embeddings, RAG pipelines, LLM synthesis.
Then it asked the right question: &quot;Worth building? Or is this scope creep from
a useful simple tool into a research project?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;We also briefly explored turning it into an MCP server so Claude could call it
during conversations. Another good idea, but another scope expansion.&lt;&#x2F;p&gt;
&lt;p&gt;I kept pulling back to &quot;what&#x27;s actually useful now&quot; and Claude adjusted. No
ego about its ideas. Just: &quot;Good call. Let me clean up and commit.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;&#x2F;h2&gt;
&lt;p&gt;A few observations from this experiment:&lt;&#x2F;p&gt;
&lt;p&gt;First, the AI had genuine preferences. It didn&#x27;t pick something random or
impressive-sounding. It picked a tool that would help it do its job better.
There&#x27;s something interesting about that.&lt;&#x2F;p&gt;
&lt;p&gt;Second, the iteration loop was fast. Feature idea to working code in minutes.
Not because the code was simple, but because there was no translation layer.
Claude understood what it wanted and knew how to build it.&lt;&#x2F;p&gt;
&lt;p&gt;Third, scope management mattered. Left unchecked, we could have spent hours
on semantic search and MCP servers. The human role was less about technical
direction and more about &quot;is this the thing we&#x27;re building right now?&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-tool&quot;&gt;The Tool&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;why&lt;&#x2F;code&gt; is at &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;why&quot;&gt;github.com&#x2F;ducks&#x2F;why&lt;&#x2F;a&gt;. Rust, MIT
licensed, about 900 lines. It actually works.&lt;&#x2F;p&gt;
&lt;p&gt;Whether I&#x27;ll use it daily, I don&#x27;t know. But it exists because I asked an AI
what it would build if it could build anything, and it had an answer.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>From Bash Scripts to NixOS: Real Declarative Infrastructure on a VPS</title>
          <pubDate>Sun, 18 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/real-declarative-infrastructure-nixos/</link>
          <guid>https://jakegoldsborough.com/blog/2026/real-declarative-infrastructure-nixos/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/real-declarative-infrastructure-nixos/">&lt;p&gt;In my &lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;running-infrastructure-with-systemd&#x2F;&quot;&gt;previous post&lt;&#x2F;a&gt;, I
described running infrastructure with systemd services and bash scripts. It
worked, but the update script kept growing with edge cases.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# This kept getting longer
safe_download() { ... }
update_goatcounter() {
    # Download binary
    # Stop service
    # Run migrations
    # Start service
}
# Repeat for each service...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Every service needed special handling. GoatCounter needed database migrations.
Scrob had &quot;text file busy&quot; errors. Downloads could 404 and block everything.&lt;&#x2F;p&gt;
&lt;p&gt;Three weeks in, the update script was 300+ lines with &lt;code&gt;|| true&lt;&#x2F;code&gt; scattered
everywhere.&lt;&#x2F;p&gt;
&lt;p&gt;I wasn&#x27;t managing infrastructure declaratively. I was writing imperative bash
scripts to approximate declarative config.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-inevitable-upgrade&quot;&gt;The Inevitable Upgrade&lt;&#x2F;h2&gt;
&lt;p&gt;I already use NixOS for local development. My laptop runs it. My dev
environments are nix-shell. I know how good it is for reproducibility.&lt;&#x2F;p&gt;
&lt;p&gt;The bash script approach was always temporary. I knew I&#x27;d migrate to NixOS
eventually - I just wanted to get the services running first, understand what
they needed, then declare it properly.&lt;&#x2F;p&gt;
&lt;p&gt;But after three weeks of bash edge cases, &quot;eventually&quot; became &quot;now.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;creating-pond-nix&quot;&gt;Creating pond-nix&lt;&#x2F;h2&gt;
&lt;p&gt;I created a new repo called &lt;code&gt;pond-nix&lt;&#x2F;code&gt; with NixOS configuration for the same
services:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;# configuration.nix
{
  imports = [
    .&amp;#x2F;services&amp;#x2F;gitea.nix
    .&amp;#x2F;services&amp;#x2F;goatcounter.nix
    .&amp;#x2F;services&amp;#x2F;woodpecker.nix
    .&amp;#x2F;services&amp;#x2F;scrob.nix
    .&amp;#x2F;services&amp;#x2F;caddy.nix
  ];

  networking = {
    hostName = &amp;quot;pond&amp;quot;;
    useDHCP = false;
    interfaces.ens3.ipv4.addresses = [{
      address = &amp;quot;199.68.196.244&amp;quot;;
      prefixLength = 24;
    }];
    defaultGateway = &amp;quot;199.68.196.1&amp;quot;;
    nameservers = [ &amp;quot;176.10.124.177&amp;quot; &amp;quot;176.10.124.136&amp;quot; ];
    firewall.allowedTCPPorts = [ 22 80 443 ];
  };

  users.users.ducks = {
    isNormalUser = true;
    extraGroups = [ &amp;quot;wheel&amp;quot; ];
    openssh.authorizedKeys.keys = [
      &amp;quot;ssh-ed25519 AAAAC3... ducks@pond&amp;quot;
    ];
  };

  security.sudo.wheelNeedsPassword = false;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each service gets its own module:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;# services&amp;#x2F;goatcounter.nix
{ config, pkgs, ... }:

{
  systemd.services = {
    goatcounter-jg = {
      description = &amp;quot;GoatCounter - stats.jakegoldsborough.com&amp;quot;;
      after = [ &amp;quot;network.target&amp;quot; ];
      wantedBy = [ &amp;quot;multi-user.target&amp;quot; ];

      serviceConfig = {
        ExecStart = &amp;quot;${pkgs.goatcounter}&amp;#x2F;bin&amp;#x2F;goatcounter serve -listen localhost:8081 -tls none -db sqlite+&amp;#x2F;var&amp;#x2F;lib&amp;#x2F;goatcounter-jg&amp;#x2F;goatcounter.db&amp;quot;;
        User = &amp;quot;goatcounter&amp;quot;;
        WorkingDirectory = &amp;quot;&amp;#x2F;var&amp;#x2F;lib&amp;#x2F;goatcounter-jg&amp;quot;;
        Restart = &amp;quot;always&amp;quot;;
      };
    };
    # ... other instances
  };
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No update scripts. No migrations to remember. Nix handles dependencies, service
ordering, and restarts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-installation-challenge&quot;&gt;The Installation Challenge&lt;&#x2F;h2&gt;
&lt;p&gt;Installing NixOS on a VPS remotely is risky. Get the network config wrong and
you&#x27;re locked out.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-nixos-infect-trap&quot;&gt;The nixos-infect Trap&lt;&#x2F;h3&gt;
&lt;p&gt;My first thought was &lt;code&gt;nixos-infect&lt;&#x2F;code&gt; - a script that converts an existing Linux
system to NixOS in-place. Seems clever, right?&lt;&#x2F;p&gt;
&lt;p&gt;Maybe too clever?&lt;&#x2F;p&gt;
&lt;p&gt;I tried it on a fresh Arch install. The script started building, then hit an
error:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;swapon: &amp;#x2F;tmp&amp;#x2F;nixos-infect.swp: Invalid argument
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;The problem:&lt;&#x2F;strong&gt; On Linux, &lt;code&gt;&#x2F;tmp&lt;&#x2F;code&gt; is mounted as tmpfs - a RAM-based filesystem.
nixos-infect tried to create a 1GB swap file &lt;em&gt;in RAM&lt;&#x2F;em&gt;. That&#x27;s like trying to
extend your RAM by using... RAM.&lt;&#x2F;p&gt;
&lt;p&gt;I patched the script to use &lt;code&gt;&#x2F;root&#x2F;tmp&lt;&#x2F;code&gt; instead:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;curl -o nixos-infect.sh
https:&amp;#x2F;&amp;#x2F;raw.githubusercontent.com&amp;#x2F;elitak&amp;#x2F;nixos-infect&amp;#x2F;master&amp;#x2F;nixos-infect
sed -i &amp;#x27;s|mktemp &amp;#x2F;tmp&amp;#x2F;|mktemp &amp;#x2F;root&amp;#x2F;tmp&amp;#x2F;|g&amp;#x27; nixos-infect.sh
mkdir -p &amp;#x2F;root&amp;#x2F;tmp
NIX_CHANNEL=nixos-24.05 bash nixos-infect.sh
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This time it worked! The build completed, the system rebooted...&lt;&#x2F;p&gt;
&lt;p&gt;And I was locked out.&lt;&#x2F;p&gt;
&lt;p&gt;nixos-infect generated a config, but with no console password and broken SSH
access. I could see the NixOS login screen through the console, but couldn&#x27;t log
in. SSH timed out.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;&#x2F;strong&gt; In-place conversions are clever until they&#x27;re not. When
you&#x27;re remote, clever is dangerous.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-iso-approach&quot;&gt;The ISO Approach&lt;&#x2F;h3&gt;
&lt;p&gt;I wiped the VPS and started over with the NixOS installation ISO.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Step 1: Mount the ISO&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Downloaded the &lt;a href=&quot;https:&#x2F;&#x2F;nixos.org&#x2F;download&#x2F;&quot;&gt;NixOS 25.11 minimal
ISO&lt;&#x2F;a&gt; and mounted it through Fornex&#x27;s control
panel, then I rebooted the VPS.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Step 2: Set up networking&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The installer gives you a root shell with no password. But before anything else,
networking:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Check interface name (mine was ens3, not eth0!)
ip addr

# Configure static IP
ip addr add 199.68.196.244&amp;#x2F;24 dev ens3
ip link set ens3 up
ip route add default via 199.68.196.1
echo &amp;quot;nameserver 176.10.124.177&amp;quot; &amp;gt; &amp;#x2F;etc&amp;#x2F;resolv.conf

# Test it
ping -c 2 google.com
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3: Enable SSH&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;passwd root  # Set a temporary password
systemctl start sshd
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now I could SSH in from my local machine and use a proper terminal instead of
the janky web console.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Step 4: Partition the disk&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;GPT with GRUB needs a BIOS boot partition:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;parted &amp;#x2F;dev&amp;#x2F;vda -- mklabel gpt
parted &amp;#x2F;dev&amp;#x2F;vda -- mkpart primary 1MB 512MB    # BIOS boot
parted &amp;#x2F;dev&amp;#x2F;vda -- mkpart primary 512MB 100%   # Root filesystem
parted &amp;#x2F;dev&amp;#x2F;vda -- set 1 bios_grub on

mkfs.ext4 &amp;#x2F;dev&amp;#x2F;vda2
mount &amp;#x2F;dev&amp;#x2F;vda2 &amp;#x2F;mnt
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first partition stays unformatted - GRUB just needs it for bootloader code.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Step 5: Generate and customize config&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nixos-generate-config --root &amp;#x2F;mnt
nano &amp;#x2F;mnt&amp;#x2F;etc&amp;#x2F;nixos&amp;#x2F;configuration.nix
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Key settings:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;{
  boot.loader.grub.enable = true;
  boot.loader.grub.device = &amp;quot;&amp;#x2F;dev&amp;#x2F;vda&amp;quot;;

  networking = {
    hostName = &amp;quot;pond&amp;quot;;
    useDHCP = false;
    interfaces.ens3 = {  # Not eth0!
      ipv4.addresses = [{
        address = &amp;quot;199.68.196.244&amp;quot;;
        prefixLength = 24;
      }];
    };
    defaultGateway = &amp;quot;199.68.196.1&amp;quot;;
    nameservers = [ &amp;quot;176.10.124.177&amp;quot; &amp;quot;176.10.124.136&amp;quot; ];
    firewall.allowedTCPPorts = [ 22 80 443 ];
  };

  users.users.ducks = {
    isNormalUser = true;
    extraGroups = [ &amp;quot;wheel&amp;quot; ];
    openssh.authorizedKeys.keys = [
      &amp;quot;ssh-ed25519 AAAAC3... ducks@pond&amp;quot;
    ];
  };

  security.sudo.wheelNeedsPassword = false;

  services.openssh = {
    enable = true;
    settings = {
      PasswordAuthentication = false;
      PermitRootLogin = &amp;quot;prohibit-password&amp;quot;;
    };
  };
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Step 6: Install&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nixos-install
# Set root password when prompted
reboot
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;After reboot, unmount the ISO through the control panel.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Step 7: SSH in with your user&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;ssh ducks@199.68.196.244
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It worked. NixOS booted, networking was correct, SSH keys worked. No lockouts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;deploying-the-real-config&quot;&gt;Deploying the Real Config&lt;&#x2F;h2&gt;
&lt;p&gt;Now that NixOS is installed, time to deploy pond-nix:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Clone your config
git clone https:&amp;#x2F;&amp;#x2F;git.jakegoldsborough.com&amp;#x2F;ducks&amp;#x2F;pond-nix.git
cd pond-nix

# Deploy it (automatically fetches hashes, copies files, and builds)
make install
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The Makefile handles everything: fetching binary hashes for packages, copying
config files to &lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;&lt;&#x2F;code&gt;, and running &lt;code&gt;nixos-rebuild&lt;&#x2F;code&gt;. One command builds
and activates all services: Gitea, GoatCounter, Woodpecker, Scrob, Caddy.
Everything starts declaratively.&lt;&#x2F;p&gt;
&lt;p&gt;If something breaks:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;sudo nixos-rebuild switch --rollback
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One command. Back to the previous working state.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;migrating-the-data&quot;&gt;Migrating the Data&lt;&#x2F;h2&gt;
&lt;p&gt;With NixOS running and services configured, time to migrate data from the old
server (burrow). I wrote a migration script to handle this:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# scripts&amp;#x2F;migrate-from-burrow.sh
#!&amp;#x2F;usr&amp;#x2F;bin&amp;#x2F;env bash

# 1. Stop services on pond (avoid database corruption)
ssh pond &amp;#x27;sudo systemctl stop scrob goatcounter-jg gitea&amp;#x27;

# 2. Back up databases from burrow
ssh burrow &amp;#x27;sudo tar czf &amp;#x2F;tmp&amp;#x2F;goatcounter-backups.tar.gz &amp;#x2F;var&amp;#x2F;lib&amp;#x2F;goatcounter-*&amp;#x27;
ssh burrow &amp;#x27;sudo tar czf &amp;#x2F;tmp&amp;#x2F;gitea-backup.tar.gz &amp;#x2F;var&amp;#x2F;lib&amp;#x2F;gitea&amp;#x27;
ssh burrow &amp;#x27;sudo -u postgres pg_dump scrob | gzip &amp;gt; &amp;#x2F;tmp&amp;#x2F;scrob-db.sql.gz&amp;#x27;

# 3. Copy to local machine (keep backups!)
scp burrow:&amp;#x2F;tmp&amp;#x2F;*.tar.gz burrow:&amp;#x2F;tmp&amp;#x2F;*.sql.gz &amp;#x2F;tmp&amp;#x2F;backups&amp;#x2F;

# 4. Copy to pond and restore
scp &amp;#x2F;tmp&amp;#x2F;backups&amp;#x2F;* pond:&amp;#x2F;tmp&amp;#x2F;
ssh pond &amp;#x27;cd &amp;#x2F;tmp &amp;amp;&amp;amp; sudo tar xzf goatcounter-backups.tar.gz -C &amp;#x2F;&amp;#x27;
ssh pond &amp;#x27;cd &amp;#x2F;tmp &amp;amp;&amp;amp; sudo tar xzf gitea-backup.tar.gz -C &amp;#x2F;&amp;#x27;
ssh pond &amp;#x27;gunzip &amp;lt; &amp;#x2F;tmp&amp;#x2F;scrob-db.sql.gz | sudo -u postgres psql scrob&amp;#x27;

# 5. Fix permissions
ssh pond &amp;#x27;sudo chown -R goatcounter:goatcounter &amp;#x2F;var&amp;#x2F;lib&amp;#x2F;goatcounter-*&amp;#x27;
ssh pond &amp;#x27;sudo chown -R gitea:gitea &amp;#x2F;var&amp;#x2F;lib&amp;#x2F;gitea&amp;#x27;

# 6. Start services
ssh pond &amp;#x27;sudo systemctl start goatcounter-jg gitea scrob&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Run it:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;.&amp;#x2F;scripts&amp;#x2F;migrate-from-burrow.sh
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;All data migrated: Git repositories, analytics databases, scrobble history.
Everything preserved.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;updating-dns&quot;&gt;Updating DNS&lt;&#x2F;h2&gt;
&lt;p&gt;Final step: point DNS to the new server. I use name.com&#x27;s API:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# scripts&amp;#x2F;update-dns.sh
export NAMECOM_USERNAME=&amp;quot;your_username&amp;quot;
export NAMECOM_TOKEN=&amp;quot;your_api_token&amp;quot;
.&amp;#x2F;scripts&amp;#x2F;update-dns.sh
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The script updates specific service A records to pond&#x27;s IP (199.68.196.244):&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;code.jakegoldsborough.com&lt;&#x2F;code&gt; → Gitea&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;ci.jakegoldsborough.com&lt;&#x2F;code&gt; → Woodpecker&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;stats.jakegoldsborough.com&lt;&#x2F;code&gt; → GoatCounter&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;scrob.jakegoldsborough.com&lt;&#x2F;code&gt; → Scrob API&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;ui.scrob.jakegoldsborough.com&lt;&#x2F;code&gt; → Scrob UI&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;stats.date-ver.com&lt;&#x2F;code&gt; → GoatCounter&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;stats.gnarlyvoid.com&lt;&#x2F;code&gt; → GoatCounter&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;DNS propagates in a few minutes. Test with &lt;code&gt;dig&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;dig code.jakegoldsborough.com +short
# 199.68.196.244
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Once DNS updates, Caddy automatically provisions Let&#x27;s Encrypt certificates for
all domains. No manual cert management needed.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What Changed&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Before (burrow-systemd):&lt;&#x2F;strong&gt; - 300+ lines of bash scripts - Manual version
checking - Special migration handling per service - &quot;Text file busy&quot; errors -
Download failures blocking other updates - &lt;code&gt;|| true&lt;&#x2F;code&gt; everywhere&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;After (pond-nix):&lt;&#x2F;strong&gt; - Declarative config in Nix - &lt;code&gt;nixos-rebuild switch --upgrade&lt;&#x2F;code&gt; updates everything - Migrations run automatically (defined in service
modules) - Atomic updates - One command rollback&lt;&#x2F;p&gt;
&lt;h2 id=&quot;service-updates&quot;&gt;Service Updates&lt;&#x2F;h2&gt;
&lt;p&gt;With systemd + bash:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;sudo .&amp;#x2F;bin&amp;#x2F;update check        # Check for updates
sudo .&amp;#x2F;bin&amp;#x2F;update apply        # Apply them
# Hope nothing breaks
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With NixOS:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;cd pond-nix
git pull
make install
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That&#x27;s it. Pull the latest config, and the Makefile handles the rest: updating
hashes, testing the config, and deploying atomically. If anything fails, the old
generation still works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-tradeoff&quot;&gt;The Tradeoff&lt;&#x2F;h2&gt;
&lt;p&gt;NixOS has a learning curve. Nix expressions are different from bash scripts.&lt;&#x2F;p&gt;
&lt;p&gt;But once you understand it, everything clicks: - System is reproducible -
Updates are atomic - Rollbacks are instant - Configuration is versioned in git&lt;&#x2F;p&gt;
&lt;p&gt;No more &quot;it works on my machine&quot;. The configuration &lt;em&gt;is&lt;&#x2F;em&gt; the machine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;was-it-worth-it&quot;&gt;Was It Worth It?&lt;&#x2F;h2&gt;
&lt;p&gt;My update script was approaching 500 lines with &lt;code&gt;safe_download()&lt;&#x2F;code&gt;, per-service
migration logic, and error handling for every edge case.&lt;&#x2F;p&gt;
&lt;p&gt;Now it&#x27;s ~500 lines of readable Nix across multiple modules. But those lines
are: - Declarative - Type-checked - Composable - Reproducible&lt;&#x2F;p&gt;
&lt;p&gt;The difference: With bash, I was writing &lt;em&gt;procedures&lt;&#x2F;em&gt;. With Nix, I&#x27;m declaring
&lt;em&gt;state&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s real declarative infrastructure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;pond-nix&quot;&gt;pond-nix&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;elitak&#x2F;nixos-infect&quot;&gt;nixos-infect&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;&quot;&gt;NixOS Manual&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2026&#x2F;running-infrastructure-with-systemd&#x2F;&quot;&gt;Previous post: Running Infrastructure with Systemd&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Building Embedded Discourse Comments with Rust and WASM</title>
          <pubDate>Wed, 14 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/building-discourse-embedded-comments/</link>
          <guid>https://jakegoldsborough.com/blog/2026/building-discourse-embedded-comments/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/building-discourse-embedded-comments/">&lt;p&gt;I built an embedded comment widget that lets you add Discourse-powered comments
to any static site with a single script tag. No iframe, no build step, just
drop it in and it works.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;html&quot; class=&quot;language-html &quot;&gt;&lt;code class=&quot;language-html&quot; data-lang=&quot;html&quot;&gt;&amp;lt;script src=&amp;quot;https:&amp;#x2F;&amp;#x2F;unpkg.com&amp;#x2F;discourse-comments&amp;#x2F;dist&amp;#x2F;discourse-comments.min.js&amp;quot;&amp;gt;&amp;lt;&amp;#x2F;script&amp;gt;
&amp;lt;discourse-comments
  discourse-url=&amp;quot;https:&amp;#x2F;&amp;#x2F;forum.example.com&amp;quot;
  topic-id=&amp;quot;123&amp;quot;&amp;gt;
&amp;lt;&amp;#x2F;discourse-comments&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The widget fetches comments from a Discourse topic, renders them with a clean
UI, and lets authenticated users post replies and like posts. All in a 725KB
bundle with the WASM runtime inlined.&lt;&#x2F;p&gt;
&lt;p&gt;You can see the code here:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-comments&quot;&gt;discourse-comments on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-build-this&quot;&gt;Why Build This?&lt;&#x2F;h2&gt;
&lt;p&gt;Discourse has had an embedding feature for years, but it uses iframes. Iframes
work, but they come with baggage: height management issues, styling isolation,
auth cookie problems, SEO invisibility, accessibility complications. It&#x27;s 2026
and we have web components, Shadow DOM, and ES modules. We can do better.&lt;&#x2F;p&gt;
&lt;p&gt;I wanted to see if a standalone component talking directly to Discourse&#x27;s REST
API could provide a cleaner experience. And I wanted an excuse to push some
Rust into the browser.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;&#x2F;h2&gt;
&lt;p&gt;The widget is built in three layers:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-api-rs&quot;&gt;discourse-api-rs&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt; - A Rust
client library for Discourse&#x27;s API, compiled to WASM with wasm-bindgen&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-comments&quot;&gt;discourse-comments&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt; - A
TypeScript web component that uses the WASM client&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;esbuild bundler&lt;&#x2F;strong&gt; - Inlines the WASM as base64 so the whole thing is a
single file&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The Rust layer handles HTTP requests, JSON parsing, and the OAuth flow. The
TypeScript layer handles DOM rendering and user interactions. The bundler
glues it together into a drop-in script.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-hard-part-oauth&quot;&gt;The Hard Part: OAuth&lt;&#x2F;h2&gt;
&lt;p&gt;Getting read-only comments working was straightforward. The Discourse API is
well-documented and predictable. But letting users post comments required
authentication.&lt;&#x2F;p&gt;
&lt;p&gt;Discourse supports User API Keys, which are perfect for third-party clients.
The flow works like this:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Generate an RSA key pair in the browser&lt;&#x2F;li&gt;
&lt;li&gt;Redirect to Discourse with the public key&lt;&#x2F;li&gt;
&lt;li&gt;User approves the app&lt;&#x2F;li&gt;
&lt;li&gt;Discourse encrypts an API key with the public key&lt;&#x2F;li&gt;
&lt;li&gt;Redirect back with the encrypted payload&lt;&#x2F;li&gt;
&lt;li&gt;Decrypt the payload with the private key&lt;&#x2F;li&gt;
&lt;li&gt;Store the API key for future requests&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Sounds simple but there were issues.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-base64-whitespace-problem&quot;&gt;The Base64 Whitespace Problem&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse returns the encrypted payload as base64 in a query parameter. My
code called &lt;code&gt;atob(payload)&lt;&#x2F;code&gt; to decode it.&lt;&#x2F;p&gt;
&lt;p&gt;Problem: Discourse includes newlines in the base64 encoding. &lt;code&gt;atob()&lt;&#x2F;code&gt; chokes
on whitespace.&lt;&#x2F;p&gt;
&lt;p&gt;Fix: Strip whitespace first: &lt;code&gt;payload.replace(&#x2F;\s&#x2F;g, &#x27;&#x27;)&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-rsa-padding-problem&quot;&gt;The RSA Padding Problem&lt;&#x2F;h3&gt;
&lt;p&gt;WebCrypto only supports RSA-OAEP for encryption&#x2F;decryption. Discourse
historically used PKCS1 padding, which WebCrypto can&#x27;t handle.&lt;&#x2F;p&gt;
&lt;p&gt;Turns out Discourse added OAEP support in December 2025 (PR #36592). You pass
&lt;code&gt;padding=oaep&lt;&#x2F;code&gt; in the OAuth request and it encrypts with OAEP instead. But if
your Discourse instance is older than that, you&#x27;re stuck.&lt;&#x2F;p&gt;
&lt;p&gt;This one cost me hours of debugging before I found the version requirement.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-like-button&quot;&gt;The Like Button&lt;&#x2F;h2&gt;
&lt;p&gt;Once posting worked, adding likes was straightforward. The Discourse API has
&lt;code&gt;POST &#x2F;post_actions&lt;&#x2F;code&gt; to like and &lt;code&gt;DELETE &#x2F;post_actions&#x2F;:id&lt;&#x2F;code&gt; to unlike.&lt;&#x2F;p&gt;
&lt;p&gt;The UI shows a heart icon that toggles between outline (not liked) and filled
(liked). Clicking it calls the API and updates the count. Nothing fancy, but
it makes the widget feel more interactive.&lt;&#x2F;p&gt;
&lt;p&gt;One gotcha: you can&#x27;t like your own posts. Discourse returns 403. I added a
check to hide the like button on posts where &lt;code&gt;yours: true&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-bundle&quot;&gt;The Bundle&lt;&#x2F;h2&gt;
&lt;p&gt;Getting this to work as a single script tag required some bundler gymnastics.
The WASM client needs its binary loaded somehow. Options:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Fetch it from a URL at runtime (requires hosting the .wasm file)&lt;&#x2F;li&gt;
&lt;li&gt;Inline it as base64 in the JavaScript bundle&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;I went with option 2. The esbuild plugin intercepts the WASM import, reads the
binary, base64-encodes it, and injects code that decodes and instantiates it
at runtime.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;const wasmBuffer = fs.readFileSync(wasmPath);
const wasmBase64 = wasmBuffer.toString(&amp;#x27;base64&amp;#x27;);

&amp;#x2F;&amp;#x2F; In the bundle:
const bytes = Uint8Array.from(atob(wasmBase64), c =&amp;gt; c.charCodeAt(0));
await WebAssembly.instantiate(bytes, imports);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This adds ~500KB to the bundle (the WASM binary), but eliminates the need for
any separate file hosting. One script tag, everything works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-community-response&quot;&gt;The Community Response&lt;&#x2F;h2&gt;
&lt;p&gt;I shared this experiment internally. The response was encouraging but also
clarifying. Some on the core team pushed back on the WASM approach, preferring
to stay within the existing Ember&#x2F;Rails stack. Ember recently added support for
rendering components to a DOM without spinning up an entire application
instance, which could serve the same use case without introducing a new
runtime.&lt;&#x2F;p&gt;
&lt;p&gt;I get it. Maintaining a Rust&#x2F;WASM layer alongside an existing codebase adds
complexity. But an &quot;ever moving API target&quot; concern feels overstated. This is
a REST API with stable endpoints for fetching topics, creating posts, and
liking. Compare that to AI integrations that build on LLM APIs from multiple
vendors with breaking changes every few months.&lt;&#x2F;p&gt;
&lt;p&gt;The iframe suggestion also came up. That feels like a step backward. We have
better tools now.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;WASM is production-ready.&lt;&#x2F;strong&gt; The toolchain (wasm-pack, wasm-bindgen) just
works. Cross-compiling Rust to run in browsers isn&#x27;t experimental anymore.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;OAuth in the browser is tricky.&lt;&#x2F;strong&gt; The combination of cross-origin redirects,
browser storage limitations, and encryption requirements creates a lot of edge
cases. Test with real redirects, not just mocked flows.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Experiments spark conversations.&lt;&#x2F;strong&gt; Even if the official team goes a
different direction, building a working prototype moved the discussion
forward. Sometimes the value of an experiment is proving that a use case
matters, not necessarily that your implementation is the right one.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try It&lt;&#x2F;h2&gt;
&lt;p&gt;The widget works today with any Discourse instance running December 2025 or
later (for the OAEP OAuth support).&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-comments&quot;&gt;discourse-comments on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-api-rs&quot;&gt;discourse-api-rs on GitHub&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.npmjs.com&#x2F;package&#x2F;discourse-comments&quot;&gt;npm package&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Running VPS Infrastructure with Systemd</title>
          <pubDate>Sat, 10 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/running-infrastructure-with-systemd/</link>
          <guid>https://jakegoldsborough.com/blog/2026/running-infrastructure-with-systemd/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/running-infrastructure-with-systemd/">&lt;p&gt;I run all my personal infrastructure on a single VPS using systemd
services instead of Docker. No containers, no orchestration, just
systemd units and bash scripts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-not-docker&quot;&gt;Why Not Docker?&lt;&#x2F;h2&gt;
&lt;p&gt;Docker is fine, but for a single VPS it felt like overhead. I wanted something simpler
and built-in. I initially started with Docker but was constantly hitting
networking issues with services communicating.&lt;&#x2F;p&gt;
&lt;p&gt;Systemd already handles process supervision, logging, and restarts. Why
add another layer?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-running&quot;&gt;What&#x27;s Running&lt;&#x2F;h2&gt;
&lt;p&gt;The VPS hosts:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gitea&lt;&#x2F;strong&gt; - Git hosting at code.jakegoldsborough.com&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Woodpecker CI&lt;&#x2F;strong&gt; - Continuous integration (idle for now)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Scrob&lt;&#x2F;strong&gt; - Music scrobbling server (see &lt;a href=&quot;https:&#x2F;&#x2F;jakegoldsborough.com&#x2F;blog&#x2F;2026&#x2F;building-scrob-self-hosted-scrobbling&#x2F;&quot;&gt;previous
post&lt;&#x2F;a&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;GoatCounter&lt;&#x2F;strong&gt; (3 instances) - Analytics for multiple sites&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Caddy&lt;&#x2F;strong&gt; - Reverse proxy with automatic HTTPS&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL&lt;&#x2F;strong&gt; - Shared database for Gitea, Woodpecker, and Scrob&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Everything runs as dedicated system users with systemd units managing
their lifecycle.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;&#x2F;h2&gt;
&lt;pre&gt;&lt;code&gt;Caddy (reverse proxy + TLS)
    |
    +-- code.jakegoldsborough.com -&amp;gt; Gitea (port 3000)
    +-- ci.jakegoldsborough.com -&amp;gt; Woodpecker (port 8000)
    +-- scrob.jakegoldsborough.com -&amp;gt; Scrob (port 3002)
    +-- ui.scrob.jakegoldsborough.com -&amp;gt; Scrob UI (static files)
    +-- stats.jakegoldsborough.com -&amp;gt; GoatCounter (port 8081)
    +-- stats.date-ver.com -&amp;gt; GoatCounter (port 8082)
    +-- stats.gnarlyvoid.com -&amp;gt; GoatCounter (port 8083)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each service runs as a systemd unit. Caddy handles TLS termination and
proxies requests to the appropriate backend.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-repository-structure&quot;&gt;The Repository Structure&lt;&#x2F;h2&gt;
&lt;p&gt;The entire setup lives in a git repo called &lt;code&gt;burrow-systemd&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;burrow-systemd&amp;#x2F;
├── systemd&amp;#x2F;            # Service unit files
│   ├── gitea.service
│   ├── woodpecker-server.service
│   ├── scrob.service
│   ├── goatcounter-*.service
│   └── caddy.service
├── config&amp;#x2F;             # Service configurations
│   ├── Caddyfile
│   ├── gitea&amp;#x2F;app.ini
│   ├── woodpecker&amp;#x2F;server.env
│   └── scrob&amp;#x2F;scrob.env
└── bin&amp;#x2F;                # Deployment scripts
    ├── bootstrap       # Initial setup
    ├── deploy          # Update and restart
    ├── update          # Check and apply updates
    └── backup          # Backup databases
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Configuration is declarative. Change a config file, commit, push, pull
on the VPS, run &lt;code&gt;.&#x2F;bin&#x2F;deploy&lt;&#x2F;code&gt;, done.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bootstrap-script&quot;&gt;Bootstrap Script&lt;&#x2F;h2&gt;
&lt;p&gt;The bootstrap script sets up everything from scratch:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Installs packages (Caddy, PostgreSQL)&lt;&#x2F;li&gt;
&lt;li&gt;Downloads service binaries (Gitea, GoatCounter, Woodpecker)&lt;&#x2F;li&gt;
&lt;li&gt;Creates system users (&lt;code&gt;gitea&lt;&#x2F;code&gt;, &lt;code&gt;goatcounter&lt;&#x2F;code&gt;, &lt;code&gt;scrob&lt;&#x2F;code&gt;, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;Initializes PostgreSQL databases&lt;&#x2F;li&gt;
&lt;li&gt;Generates secure passwords and tokens&lt;&#x2F;li&gt;
&lt;li&gt;Copies config files from templates&lt;&#x2F;li&gt;
&lt;li&gt;Installs systemd service files&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Run once on a fresh VPS:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;sudo .&amp;#x2F;bin&amp;#x2F;bootstrap
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It generates a &lt;code&gt;.env&lt;&#x2F;code&gt; file with all secrets and database passwords. This
file is git-ignored and stays on the server.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;deploy-script&quot;&gt;Deploy Script&lt;&#x2F;h2&gt;
&lt;p&gt;The deploy script handles updates and restarts:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Updates configuration files (substitutes passwords from &lt;code&gt;.env&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Copies service files to &lt;code&gt;&#x2F;etc&#x2F;systemd&#x2F;system&#x2F;&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Reloads systemd&lt;&#x2F;li&gt;
&lt;li&gt;Creates databases if they don&#x27;t exist&lt;&#x2F;li&gt;
&lt;li&gt;Enables and restarts all services&lt;&#x2F;li&gt;
&lt;li&gt;Checks service status&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;After changing config locally:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;git add config&amp;#x2F;Caddyfile
git commit -m &amp;quot;Update reverse proxy config&amp;quot;
git push

# On VPS
cd ~&amp;#x2F;dev&amp;#x2F;burrow-systemd
git pull
sudo .&amp;#x2F;bin&amp;#x2F;deploy
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The deploy script is idempotent. Run it as many times as you want.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;service-updates&quot;&gt;Service Updates&lt;&#x2F;h2&gt;
&lt;p&gt;Updates are automated via the update script. It checks GitHub releases
for all services and downloads new binaries when available:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Check what needs updating
sudo .&amp;#x2F;bin&amp;#x2F;update check

# Apply all updates
sudo .&amp;#x2F;bin&amp;#x2F;update apply

# Update specific service
sudo .&amp;#x2F;bin&amp;#x2F;update apply gitea
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The script compares installed versions against the latest GitHub
releases. If an update is available, it downloads the new binary, stops
the service, replaces the binary, and restarts the service.&lt;&#x2F;p&gt;
&lt;p&gt;Scrob migrations run automatically on startup via &lt;code&gt;sqlx::migrate!()&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Simple and explicit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;database-strategy&quot;&gt;Database Strategy&lt;&#x2F;h2&gt;
&lt;p&gt;PostgreSQL is shared across Gitea, Woodpecker, and Scrob. Each service
gets its own database and user with limited permissions.&lt;&#x2F;p&gt;
&lt;p&gt;GoatCounter uses SQLite because each instance is independent. Three
separate SQLite databases, three separate systemd units, three separate
ports.&lt;&#x2F;p&gt;
&lt;p&gt;No database clustering, no replication, no complexity. Daily backups via
cron are enough for my use case.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-works&quot;&gt;What Works&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Single command deployment (&lt;code&gt;.&#x2F;bin&#x2F;deploy&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Automated binary updates (&lt;code&gt;.&#x2F;bin&#x2F;update&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Centralized logging via journalctl&lt;&#x2F;li&gt;
&lt;li&gt;Automatic HTTPS via Caddy + Let&#x27;s Encrypt&lt;&#x2F;li&gt;
&lt;li&gt;Service isolation via systemd users&lt;&#x2F;li&gt;
&lt;li&gt;Declarative config in git&lt;&#x2F;li&gt;
&lt;li&gt;Low memory footprint (no container runtime)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-doesn-t&quot;&gt;What Doesn&#x27;t&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Can&#x27;t easily move to a different host (not portable like containers)&lt;&#x2F;li&gt;
&lt;li&gt;Service isolation is weaker than containers&lt;&#x2F;li&gt;
&lt;li&gt;Harder to test locally (no docker-compose equivalent)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;security-model&quot;&gt;Security Model&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Each service runs as a dedicated system user&lt;&#x2F;li&gt;
&lt;li&gt;PostgreSQL only listens on localhost&lt;&#x2F;li&gt;
&lt;li&gt;TLS handled by Caddy (reverse proxy)&lt;&#x2F;li&gt;
&lt;li&gt;Secrets stored in &lt;code&gt;.env&lt;&#x2F;code&gt; (git-ignored, mode 600)&lt;&#x2F;li&gt;
&lt;li&gt;No rate limiting (future work)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;All services bind to localhost and are only accessible via Caddy&#x27;s
reverse proxy. The VPS firewall blocks everything except 80, 443, and
SSH.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;reflections&quot;&gt;Reflections&lt;&#x2F;h2&gt;
&lt;p&gt;The best part is the simplicity. No Docker networking, no volume mounts,
no image building. Just systemd units, config files, and bash scripts.&lt;&#x2F;p&gt;
&lt;p&gt;When something breaks, &lt;code&gt;journalctl -u &amp;lt;service&amp;gt;&lt;&#x2F;code&gt; shows exactly what went
wrong. No container layers to debug.&lt;&#x2F;p&gt;
&lt;p&gt;The tradeoff is portability. This setup is tied to the specific VPS and
filesystem layout. Migrating to a new server means running bootstrap
again and restoring database backups.&lt;&#x2F;p&gt;
&lt;p&gt;For my use case, that&#x27;s fine. I&#x27;m not running a multi-tenant SaaS. I&#x27;m
hosting services for myself.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s enough.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;burrow-systemd&quot;&gt;burrow-systemd&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;jakegoldsborough.com&#x2F;blog&#x2F;2026&#x2F;building-scrob-self-hosted-scrobbling&#x2F;&quot;&gt;Scrob blog post&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;about.gitea.com&#x2F;&quot;&gt;Gitea&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;woodpecker-ci.org&#x2F;&quot;&gt;Woodpecker CI&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.goatcounter.com&#x2F;&quot;&gt;GoatCounter&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;caddyserver.com&#x2F;&quot;&gt;Caddy&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Building Scrob: A Self-Hosted Music Scrobbling Server</title>
          <pubDate>Fri, 02 Jan 2026 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2026/building-scrob-self-hosted-scrobbling/</link>
          <guid>https://jakegoldsborough.com/blog/2026/building-scrob-self-hosted-scrobbling/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2026/building-scrob-self-hosted-scrobbling/">&lt;p&gt;I wanted to track what music I listen to without sending that data to
Last.fm. Part privacy preference, part self-hosting habit.&lt;&#x2F;p&gt;
&lt;p&gt;So I set up &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;scrob&quot;&gt;scrob&lt;&#x2F;a&gt; - a self-hosted
scrobbling server that tracks my listening history and makes it
shareable.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;2026&#x2F;scrob-ui&#x2F;1.png&quot; alt=&quot;Scrob UI - Latest Scrobbles&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;2026&#x2F;scrob-ui&#x2F;2.png&quot; alt=&quot;Scrob UI - All Stats&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;&#x2F;h2&gt;
&lt;p&gt;Scrob is a REST API server with a separate static UI:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Music Player (shelltrax, etc.)
    |
    v
[POST &amp;#x2F;scrob] with Bearer token
    |
    v
Scrob API (Axum + Rust)
    |
    v
PostgreSQL (scrobbles + users)
    |
    v
Scrob UI (static Svelte site)
    |
    v
Public profile: ui.scrob.example.com&amp;#x2F;username
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The server authenticates clients via Bearer tokens, stores scrobbles in
Postgres, and serves stats via REST endpoints. The UI is a static site
that fetches data from the API.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;public-profiles&quot;&gt;Public Profiles&lt;&#x2F;h2&gt;
&lt;p&gt;The interesting part was adding public user profiles. Like Last.fm, I
wanted people to be able to share their music taste via a URL:
&lt;code&gt;ui.scrob.jakegoldsborough.com&#x2F;username&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This required:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;New public API endpoints (&lt;code&gt;&#x2F;users&#x2F;:username&#x2F;recent&lt;&#x2F;code&gt;,
&lt;code&gt;&#x2F;users&#x2F;:username&#x2F;top&#x2F;artists&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;users&#x2F;:username&#x2F;top&#x2F;tracks&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Privacy controls (users are public by default, can opt-in to private)&lt;&#x2F;li&gt;
&lt;li&gt;URL-based routing in the Svelte app&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The privacy model is simple: a boolean &lt;code&gt;is_private&lt;&#x2F;code&gt; column in the users
table. Public endpoints check the flag and return 403 if private. Users
can toggle it via &lt;code&gt;&#x2F;settings&#x2F;privacy&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;security-model&quot;&gt;Security Model&lt;&#x2F;h2&gt;
&lt;p&gt;Scrob uses token-based authentication:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Login with username&#x2F;password to get a Bearer token&lt;&#x2F;li&gt;
&lt;li&gt;All scrobble submissions require &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Tokens are stored in the database with revocation support&lt;&#x2F;li&gt;
&lt;li&gt;Passwords are hashed with bcrypt&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;TLS is expected to be handled by a reverse proxy (Caddy, nginx, etc.).
The server itself runs over HTTP.&lt;&#x2F;p&gt;
&lt;p&gt;Rate limiting is not currently implemented. Future work.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Backend&lt;&#x2F;strong&gt;: Rust + Axum + PostgreSQL + sqlx&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Frontend&lt;&#x2F;strong&gt;: Svelte 5 + Vite + TypeScript&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Client&lt;&#x2F;strong&gt;: &lt;a href=&quot;https:&#x2F;&#x2F;jakegoldsborough.com&#x2F;blog&#x2F;2025&#x2F;introducing-shelltrax&#x2F;&quot;&gt;Shelltrax&lt;&#x2F;a&gt; (my terminal music player)&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Infrastructure&lt;&#x2F;strong&gt;: Systemd services on a VPS, no Docker&lt;&#x2F;p&gt;
&lt;p&gt;Scrob is straightforward. It&#x27;s an API server that receives scrobble
data, stores it in Postgres, and serves it back via REST endpoints. The
UI is a static site that shows your listening history.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it&quot;&gt;Try It&lt;&#x2F;h2&gt;
&lt;p&gt;If you want to run your own scrob instance:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Prerequisites: Rust 1.82+, PostgreSQL 12+, reverse proxy for TLS&lt;&#x2F;li&gt;
&lt;li&gt;Clone repo and build: &lt;code&gt;cargo build --release&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Set &lt;code&gt;DATABASE_URL&lt;&#x2F;code&gt; environment variable&lt;&#x2F;li&gt;
&lt;li&gt;Migrations run automatically on first startup via &lt;code&gt;sqlx::migrate!()&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Create a user with &lt;code&gt;.&#x2F;scripts&#x2F;bootstrap.sh&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Start UI (separate repo: scrob-ui) or use API directly&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;See the &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;scrob&quot;&gt;README&lt;&#x2F;a&gt; for full details.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;sqlx-and-offline-query-caching&quot;&gt;sqlx and Offline Query Caching&lt;&#x2F;h2&gt;
&lt;p&gt;Scrob uses &lt;code&gt;sqlx&lt;&#x2F;code&gt; for compile-time checked SQL queries. This is great for type safety but annoying for CI: you need a database to compile.&lt;&#x2F;p&gt;
&lt;p&gt;The solution is &lt;code&gt;cargo sqlx prepare&lt;&#x2F;code&gt; which generates a &lt;code&gt;.sqlx&#x2F;&lt;&#x2F;code&gt; cache of
query metadata. Then CI can build with &lt;code&gt;SQLX_OFFLINE=true&lt;&#x2F;code&gt; without
needing Postgres.&lt;&#x2F;p&gt;
&lt;p&gt;The problem: running &lt;code&gt;cargo sqlx prepare&lt;&#x2F;code&gt; manually is tedious. You need
to spin up Postgres, run migrations, prepare the cache, tear it down.&lt;&#x2F;p&gt;
&lt;p&gt;I automated it with a Makefile:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;makefile&quot; class=&quot;language-makefile &quot;&gt;&lt;code class=&quot;language-makefile&quot; data-lang=&quot;makefile&quot;&gt;prepare:
  @docker run postgres:16-alpine (port 5433)
  @Wait for postgres
  @cargo sqlx migrate run
  @cargo sqlx prepare
  @docker rm postgres
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now &lt;code&gt;make prepare&lt;&#x2F;code&gt; handles everything. One command, query cache updated.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;dev-tooling&quot;&gt;Dev Tooling&lt;&#x2F;h3&gt;
&lt;p&gt;The prepare target must be rerun whenever you:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Add or modify SQL queries in the codebase&lt;&#x2F;li&gt;
&lt;li&gt;Create new migrations&lt;&#x2F;li&gt;
&lt;li&gt;Change database schema&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Without updating &lt;code&gt;.sqlx&#x2F;&lt;&#x2F;code&gt;, CI builds fail with query verification
errors. The cache is committed to git, so offline builds work without a
live database connection.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;deployment&quot;&gt;Deployment&lt;&#x2F;h2&gt;
&lt;p&gt;The deployment setup is declarative:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Migrations live in the scrob repo&lt;&#x2F;li&gt;
&lt;li&gt;On startup, scrob automatically detects and runs new migrations via
&lt;code&gt;sqlx::migrate!()&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;The deploy script checks GitHub releases for new versions and only
downloads if there&#x27;s an update&lt;&#x2F;li&gt;
&lt;li&gt;Systemd restarts the service after updating the binary&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;No manual migration management. No separate migration runner. Single
source of truth.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-works&quot;&gt;What Works&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Scrobbling from Shelltrax to the API&lt;&#x2F;li&gt;
&lt;li&gt;Public profile pages with recent scrobbles and top stats&lt;&#x2F;li&gt;
&lt;li&gt;Privacy toggle (public by default, opt-in private)&lt;&#x2F;li&gt;
&lt;li&gt;Automated migrations on startup&lt;&#x2F;li&gt;
&lt;li&gt;Version-aware deployment script&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;what-doesn-t&quot;&gt;What Doesn&#x27;t&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;No album stats yet (just artists and tracks)&lt;&#x2F;li&gt;
&lt;li&gt;No client&#x2F;player tracking (can&#x27;t tell if you scrobbled from Shelltrax
vs another client)&lt;&#x2F;li&gt;
&lt;li&gt;Limited metadata (no album art, genre tags, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;UI is functional but basic&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;reflections&quot;&gt;Reflections&lt;&#x2F;h2&gt;
&lt;p&gt;The best part of this project was the deployment automation. The &lt;code&gt;make prepare&lt;&#x2F;code&gt; command and the version-checking deploy script make updates
trivial. Push a tag, wait for CI, run &lt;code&gt;sudo .&#x2F;bin&#x2F;deploy&lt;&#x2F;code&gt;, done.&lt;&#x2F;p&gt;
&lt;p&gt;Scrob isn&#x27;t trying to compete with Last.fm&#x27;s features... yet. It&#x27;s a simple,
self-hosted alternative that does one thing: tracks what you listen to
and makes it shareable.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s enough for now.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;links&quot;&gt;Links&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;scrob&quot;&gt;scrob (backend)&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;scrob-ui&quot;&gt;scrob-ui (frontend)&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;ui.scrob.jakegoldsborough.com&#x2F;users&#x2F;ducks&quot;&gt;My public profile&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>2025 - A Review</title>
          <pubDate>Wed, 31 Dec 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/yearly-summary/</link>
          <guid>https://jakegoldsborough.com/blog/2025/yearly-summary/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/yearly-summary/">&lt;h1 id=&quot;2025-yearly-recap&quot;&gt;2025 Yearly Recap&lt;&#x2F;h1&gt;
&lt;h2 id=&quot;a-year-of-building-shipping-and-showing-the-work&quot;&gt;A Year of Building, Shipping, and Showing the Work&lt;&#x2F;h2&gt;
&lt;p&gt;2025 was the year things stopped being ideas in progress and started
becoming systems with gravity. Across code, writing, travel, and
creative work, the through-line was consistency: small, composable
pieces that add up to something durable.&lt;&#x2F;p&gt;
&lt;p&gt;The pace was intentional. Ship in public. Measure lightly. Let
curiosity decide what compounds.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;open-source-and-engineering&quot;&gt;Open Source and Engineering&lt;&#x2F;h2&gt;
&lt;p&gt;This was a deeply technical year, but not driven by framework churn.
It was about foundations.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Continued deep investment in Rust, especially for CLI and TUI tooling&lt;&#x2F;li&gt;
&lt;li&gt;Multiple self-authored tools reached real usability:
&lt;ul&gt;
&lt;li&gt;Static resume generation&lt;&#x2F;li&gt;
&lt;li&gt;Budgeting and finance tooling&lt;&#x2F;li&gt;
&lt;li&gt;Audio and scrobbling infrastructure&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;Strong preference for:
&lt;ul&gt;
&lt;li&gt;Plain data formats&lt;&#x2F;li&gt;
&lt;li&gt;Deterministic builds&lt;&#x2F;li&gt;
&lt;li&gt;Git as a source of truth&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;Clear move away from &quot;just ship JSON&quot; toward intentional data modeling&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Alongside that:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Declarative systems (NixOS, Docker Compose, reproducible infra)&lt;&#x2F;li&gt;
&lt;li&gt;CLI-first design, with UI as a layer rather than a dependency&lt;&#x2F;li&gt;
&lt;li&gt;Energy-aware and sustainability-minded technical choices&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;A recurring pattern this year: build tools you actually want to use,
then refine them until they feel obvious.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;writing-blogging-and-metrics&quot;&gt;Writing, Blogging, and Metrics&lt;&#x2F;h2&gt;
&lt;p&gt;In 2025, the blog stopped feeling like a side project and started acting
like a signal.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Consistent long-form technical writing&lt;&#x2F;li&gt;
&lt;li&gt;Multi-part series and deep dives&lt;&#x2F;li&gt;
&lt;li&gt;Willingness to publish unfinished thinking and iterate publicly&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The results were quiet but meaningful:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Organic traffic from multiple search engines&lt;&#x2F;li&gt;
&lt;li&gt;Readers arriving via GitHub, chat tools, and RSS&lt;&#x2F;li&gt;
&lt;li&gt;Early signs of compounding interest rather than spikes&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Metrics stayed in their place: feedback, not validation.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;travel-and-perspective-shifts&quot;&gt;Travel and Perspective Shifts&lt;&#x2F;h2&gt;
&lt;p&gt;Travel this year was less about movement and more about recalibration.&lt;&#x2F;p&gt;
&lt;p&gt;Time spent in Europe, especially Central Europe, reinforced:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A preference for walkable cities&lt;&#x2F;li&gt;
&lt;li&gt;Public transit that works&lt;&#x2F;li&gt;
&lt;li&gt;Slower, more deliberate daily rhythms&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;It also sharpened longer-term thinking around relocation, digital-nomad
feasibility, and how much infrastructure is actually needed to live
well.&lt;&#x2F;p&gt;
&lt;p&gt;This wasn&#x27;t escapism. It was alignment.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;disc-golf-outdoors-and-sustainability&quot;&gt;Disc Golf, Outdoors, and Sustainability&lt;&#x2F;h2&gt;
&lt;p&gt;Disc golf remained a grounding constant.&lt;&#x2F;p&gt;
&lt;p&gt;Not just as a hobby, but as:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A recurring design constraint (maps, data, APIs)&lt;&#x2F;li&gt;
&lt;li&gt;A sustainability touchstone&lt;&#x2F;li&gt;
&lt;li&gt;A reminder that systems should work outside, not just on screens&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The focus subtly shifted from collecting and optimizing toward
understanding spaces:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Courses as designed environments&lt;&#x2F;li&gt;
&lt;li&gt;Flow, access, and maintenance&lt;&#x2F;li&gt;
&lt;li&gt;How technology can support, not dominate, physical experiences&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;creative-work-beyond-code&quot;&gt;Creative Work Beyond Code&lt;&#x2F;h2&gt;
&lt;p&gt;2025 also made room for making things with your hands.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Pottery experiments&lt;&#x2F;li&gt;
&lt;li&gt;Tufting and material exploration&lt;&#x2F;li&gt;
&lt;li&gt;Visual identity and logo work&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These fed back into engineering work by reinforcing:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Process over output&lt;&#x2F;li&gt;
&lt;li&gt;Material constraints&lt;&#x2F;li&gt;
&lt;li&gt;Iteration without metrics&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;That cross-pollination showed up everywhere.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;career-and-direction&quot;&gt;Career and Direction&lt;&#x2F;h2&gt;
&lt;p&gt;Professionally, this was a year of alignment.&lt;&#x2F;p&gt;
&lt;p&gt;The focus moved further toward:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Open-source adjacent work&lt;&#x2F;li&gt;
&lt;li&gt;Tooling that empowers communities&lt;&#x2F;li&gt;
&lt;li&gt;Infrastructure that disappears when it works&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Rather than chasing titles or hype, the emphasis stayed on:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Taste&lt;&#x2F;li&gt;
&lt;li&gt;Maintainability&lt;&#x2F;li&gt;
&lt;li&gt;Systems that still make sense six months later&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The result was confidence rooted in practice, not positioning.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;h2 id=&quot;the-shape-of-things-going-forward&quot;&gt;The Shape of Things Going Forward&lt;&#x2F;h2&gt;
&lt;p&gt;If 2024 was about exploration, 2025 was about integration.&lt;&#x2F;p&gt;
&lt;p&gt;There is now:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A coherent technical stack&lt;&#x2F;li&gt;
&lt;li&gt;A public body of work&lt;&#x2F;li&gt;
&lt;li&gt;Clear values around sustainability, simplicity, and ownership&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Most importantly, the year ends with momentum that feels calm rather
than frantic.&lt;&#x2F;p&gt;
&lt;p&gt;That is the kind that lasts.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building LLM-TUI: Never Lose Context Again</title>
          <pubDate>Tue, 16 Dec 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-llm-tui-terminal-ai-chat/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-llm-tui-terminal-ai-chat/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-llm-tui-terminal-ai-chat/">&lt;p&gt;I am loving CLI agentic LLM apps except for one thing... and that&#x27;s losing context.&lt;&#x2F;p&gt;
&lt;p&gt;You&#x27;re working on a feature. You&#x27;ve shown the AI five files. You&#x27;ve explained
your architecture. You&#x27;re making progress. Then Claude crashes. Or you switch
to a different project.&lt;&#x2F;p&gt;
&lt;p&gt;When you come back tomorrow, you start over. Re-explain everything. Re-share
the files. Re-establish context.&lt;&#x2F;p&gt;
&lt;p&gt;I was taking daily notes but I got tired of that. So I built llm-tui.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s a terminal interface for AI chat, but that&#x27;s not the main feature. The
main feature is it remembers. File context persists across sessions. Tool
results get cached. You can close it, come back a week later, and pick up
exactly where you left off.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What It Does&lt;&#x2F;h2&gt;
&lt;p&gt;You launch it, pick a model, and chat. But the devil&#x27;s in the details.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Multi-Provider Support&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Switch between Ollama (local models on your
machine), Claude API (Anthropic&#x27;s hosted models), and AWS Bedrock (Claude via
AWS). One interface, multiple backends.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Tool System&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;When using Claude or Bedrock, the AI can Read files, Write
files, Edit existing code, search with Glob and Grep, and run Bash commands.
All sandboxed to your home directory. Every tool execution requires
confirmation (y&#x2F;n&#x2F;q).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;File Context Persistence&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Files read during a session get cached. When you
reopen that session later, those files are already loaded. The AI remembers
what it was looking at.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Session Management&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Create named sessions, organize them by project, rename
and delete them. SQLite storage means everything persists and loads instantly.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Vim Keybindings&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Modal editing. Normal mode for navigation, insert mode for
typing, command mode for session management. j&#x2F;k to scroll, i to insert, Esc to
escape. Feels natural.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Token Tracking&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Real-time display of token usage (Tokens: 1250&#x2F;200000).
When conversations get long, automatic context compaction kicks in at 75%
capacity. Old messages get summarized, recent ones stay intact.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-i-built-it&quot;&gt;Why I Built It&lt;&#x2F;h2&gt;
&lt;p&gt;Context loss kills productivity. You&#x27;re building a feature, the AI understands
your codebase, and then you lose it all. Browser tabs close. Sessions expire.
You switch projects and forget to save the conversation.&lt;&#x2F;p&gt;
&lt;p&gt;Starting over means minutes of setup every time. Copy file contents. Explain
your architecture again. Re-establish what the AI already knew.&lt;&#x2F;p&gt;
&lt;p&gt;I needed persistence. Sessions that survive restarts. File context that doesn&#x27;t
disappear. A tool that picks up where you left off without re-explaining
everything.&lt;&#x2F;p&gt;
&lt;p&gt;And I wanted control over which model handles the task. Local models for quick
iteration. Claude when I need reasoning. Bedrock for AWS work. One interface,
no lost context when switching.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-tool-system&quot;&gt;The Tool System&lt;&#x2F;h2&gt;
&lt;p&gt;Context persistence only works if the AI can actually interact with your files.
Browser-based chat requires copying code back and forth. Every file you want
the AI to see means another copy-paste. Every change it suggests means manually
applying edits.&lt;&#x2F;p&gt;
&lt;p&gt;The tool system fixes this. When using Claude or Bedrock, the AI can Read
files, Write files, Edit code, search with Glob and Grep, and run Bash
commands. All sandboxed to your home directory with explicit confirmation.&lt;&#x2F;p&gt;
&lt;p&gt;When the AI wants to read a file, it calls the Read tool. You see: &lt;code&gt;Read &#x2F;home&#x2F;user&#x2F;project&#x2F;foo.rs? (y&#x2F;n&#x2F;q)&lt;&#x2F;code&gt;. Same for writes and edits. You approve or
reject each action.&lt;&#x2F;p&gt;
&lt;p&gt;The key part: tool results get cached per session. If the AI reads
&lt;code&gt;config.toml&lt;&#x2F;code&gt;, that result stays in the session history. Next time you open
that session, it already knows what was in that file. No re-reading. No
re-explaining. The context persists.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-future-is-weird&quot;&gt;The Future Is Weird&lt;&#x2F;h2&gt;
&lt;p&gt;As you can probably tell, I love and prefer open source software. Unfortunately,
Claude does not fall into that category so I could not look at it for inspiration.
So, I tried a wild idea. I simply asked Claude how it&#x27;s tools worked and what the API
response looked like. And you know what, it worked! I had Claude help me basically
rebuild it&#x27;s tools by internal reflection. The future is a trip, man.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;session-management&quot;&gt;Session Management&lt;&#x2F;h2&gt;
&lt;p&gt;This is where context persistence actually lives. Each session is a separate
context with its own history, files, and conversation state. Work on multiple
projects without mixing contexts. Switch between them without losing anything.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;:new my-feature                # New session with custom name
:project discourse-yaks        # Set current project
:rename better-name            # Rename current session
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Sessions are stored in SQLite at &lt;code&gt;~&#x2F;.local&#x2F;share&#x2F;llm-tui&#x2F;sessions.db&lt;&#x2F;code&gt;. Close
the app, come back tomorrow, and every session is exactly as you left it. The
AI still has all the files loaded. The conversation picks up mid-thought.&lt;&#x2F;p&gt;
&lt;p&gt;You can even load context from other sessions with &lt;code&gt;:load session-name&lt;&#x2F;code&gt;. Pull
in file context from a different project without manually re-sharing
everything.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;provider-management&quot;&gt;Provider Management&lt;&#x2F;h2&gt;
&lt;p&gt;Switch providers with a command:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;:provider ollama    # Local models
:provider claude    # Claude API (requires ANTHROPIC_API_KEY)
:provider bedrock   # AWS Bedrock (requires AWS credentials)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The models screen (press &lt;code&gt;3&lt;&#x2F;code&gt;) shows all available models across all providers.
Installed Ollama models marked with &lt;code&gt;[installed]&lt;&#x2F;code&gt;. Current active model marked
with &lt;code&gt;[current]&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You can download Ollama models directly from the TUI. Navigate to a
non-installed model, hit Enter, and it pulls from the Ollama library. One
keypress.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;automatic-context-compaction&quot;&gt;Automatic Context Compaction&lt;&#x2F;h2&gt;
&lt;p&gt;Long conversations hit context window limits. Most chat interfaces handle this
by truncating old messages. You lose the early context that established your
architecture decisions and project structure.&lt;&#x2F;p&gt;
&lt;p&gt;llm-tui summarizes old messages instead of dropping them. At 75% capacity, it
sends old messages to the LLM for summarization. The summary replaces the
original messages, keeping under 500 tokens. Recent messages (default: 10)
always stay uncompacted.&lt;&#x2F;p&gt;
&lt;p&gt;You keep the context. The AI still knows what happened at the start of the
conversation. You just use fewer tokens to maintain it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;&#x2F;h2&gt;
&lt;p&gt;Written in Rust. The UI is built with ratatui (terminal UI library). SQLite for
storage via rusqlite and crossterm for terminal handling.&lt;&#x2F;p&gt;
&lt;p&gt;Three API clients: reqwest for Ollama&#x27;s HTTP API, anthropic-sdk-rust for
Claude, and AWS SDK for Bedrock.&lt;&#x2F;p&gt;
&lt;p&gt;Tool execution uses ripgrep (grep crate) for fast content search, glob for
pattern matching, and walkdir for file traversal.&lt;&#x2F;p&gt;
&lt;p&gt;Built my own vim-navigator-rs library for the modal keybindings. Normal, insert,
and command modes with proper vim-style navigation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;configuration&quot;&gt;Configuration&lt;&#x2F;h2&gt;
&lt;p&gt;Config file at &lt;code&gt;~&#x2F;.config&#x2F;llm-tui&#x2F;config.toml&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;autosave_mode = &amp;quot;onsend&amp;quot;              # disabled&amp;#x2F;onsend&amp;#x2F;timer
ollama_model = &amp;quot;llama2&amp;quot;
ollama_context_window = 4096
claude_model = &amp;quot;claude-3-5-sonnet-20241022&amp;quot;
claude_context_window = 200000
bedrock_model = &amp;quot;us.anthropic.claude-sonnet-4-20250514-v1:0&amp;quot;
bedrock_context_window = 200000
autocompact_threshold = 0.75          # Compact at 75% capacity
autocompact_keep_recent = 10          # Keep last 10 messages uncompacted
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Autosave modes: disabled (manual &lt;code&gt;:w&lt;&#x2F;code&gt; only), onsend (save immediately when
sending messages), or timer (save every N seconds).&lt;&#x2F;p&gt;
&lt;p&gt;Auto-start Ollama if not running (configurable). Set default provider. Pick
your context window sizes.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;roadmap&quot;&gt;Roadmap&lt;&#x2F;h2&gt;
&lt;p&gt;Still building. Next up:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;OpenAI API integration&lt;&#x2F;li&gt;
&lt;li&gt;Setup wizard for API keys&lt;&#x2F;li&gt;
&lt;li&gt;Daily notes integration (load from my claude-notes directory)&lt;&#x2F;li&gt;
&lt;li&gt;Search functionality across sessions&lt;&#x2F;li&gt;
&lt;li&gt;Session export&lt;&#x2F;li&gt;
&lt;li&gt;Code block syntax highlighting&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;&#x2F;h2&gt;
&lt;p&gt;Browser-based chat interfaces are fine for casual use. But when you&#x27;re deep in
development work, context loss becomes the bottleneck. Re-explaining
architecture. Re-sharing files. Starting conversations from scratch.&lt;&#x2F;p&gt;
&lt;p&gt;llm-tui fixes that. Sessions persist. File context survives restarts. You pick
up exactly where you left off. The AI remembers what it knew yesterday.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>JOBL and SRG: Building Resume Tools I Actually Want to Use</title>
          <pubDate>Sun, 14 Dec 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/jobl-srg-resume-tools/</link>
          <guid>https://jakegoldsborough.com/blog/2025/jobl-srg-resume-tools/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/jobl-srg-resume-tools/">&lt;p&gt;After getting laid off from my last job, I told myself I would keep my resume
updated and my options open. Then we had layoffs at Discourse. Nothing like
watching coworkers lose their jobs to remind you that stability is an illusion
and preparation matters.&lt;&#x2F;p&gt;
&lt;p&gt;I have been in a spec and tool building mood lately anyway. Something about the
uncertainty makes me want to build things I can control. JOBL and SRG fit that
mood perfectly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h2&gt;
&lt;p&gt;I needed to update my resume. The last version was a LibreOffice doc. It was a
pain in the ass. The formatting was fragile. One change and the whole thing
shifted. Exporting to PDF was a gamble. And I could not version control it
properly because it was a binary blob.&lt;&#x2F;p&gt;
&lt;p&gt;What I wanted: plain text I can edit in vim, version control in git, and generate
HTML and PDF programmatically.&lt;&#x2F;p&gt;
&lt;p&gt;So I built it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;jobl-a-toml-resume-format&quot;&gt;JOBL: A TOML Resume Format&lt;&#x2F;h2&gt;
&lt;p&gt;JOBL is a strict TOML-based resume format. The file extension is &lt;code&gt;.jobl&lt;&#x2F;code&gt; and
the structure is simple:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;toml&quot; class=&quot;language-toml &quot;&gt;&lt;code class=&quot;language-toml&quot; data-lang=&quot;toml&quot;&gt;[person]
name = &amp;quot;Jake Goldsborough&amp;quot;
headline = &amp;quot;Infrastructure Engineer&amp;quot;
email = &amp;quot;jake@example.com&amp;quot;
location = &amp;quot;Portland, OR&amp;quot;
summary = &amp;quot;Backend systems and deployment automation.&amp;quot;

[skills]
Languages = [&amp;quot;Ruby&amp;quot;, &amp;quot;Rust&amp;quot;, &amp;quot;JavaScript&amp;quot;]
Infrastructure = [&amp;quot;Docker&amp;quot;, &amp;quot;Kubernetes&amp;quot;, &amp;quot;NixOS&amp;quot;]

[[experience]]
title = &amp;quot;Software Engineer&amp;quot;
company = &amp;quot;Discourse&amp;quot;
start = &amp;quot;2025-06&amp;quot;
end = &amp;quot;current&amp;quot;
summary = &amp;quot;Infrastructure team, backend systems, deployment tooling.&amp;quot;
highlights = [
  &amp;quot;Built postgres-manager HTTP service replacing MessageBus pub&amp;#x2F;sub&amp;quot;,
  &amp;quot;Reduced duplicate operations and improved operational visibility&amp;quot;
]

[[projects]]
name = &amp;quot;Transit Tracker&amp;quot;
url = &amp;quot;https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;ducks&amp;#x2F;discourse-transit-tracker&amp;quot;
summary = &amp;quot;Live departure board plugin for Discourse showing real-time transit arrivals&amp;quot;

[[education]]
degree = &amp;quot;BS Computer Science&amp;quot;
institution = &amp;quot;State University&amp;quot;
start = &amp;quot;2016&amp;quot;
end = &amp;quot;2020&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;TOML is human readable. You can edit it in any text editor. Comments are
supported. Syntax errors fail fast at parse time. Version control works
perfectly because it is plain text.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-toml-instead-of-json-or-yaml&quot;&gt;Why TOML Instead of JSON or YAML&lt;&#x2F;h2&gt;
&lt;p&gt;JSON is for machines. Writing nested structures by hand is miserable. No
comments. Trailing commas break everything. It is data interchange format, not
a human authoring format.&lt;&#x2F;p&gt;
&lt;p&gt;YAML has too many footguns. Indentation matters. Multiple ways to express the
same thing. The Norway problem (NO gets parsed as boolean false). Anchors and
references add complexity I do not need.&lt;&#x2F;p&gt;
&lt;p&gt;TOML is boring in the best way. One obvious way to express each type. Strict
validation. Native Rust support via serde. No surprises.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-parser-strict-validation&quot;&gt;The Parser: Strict Validation&lt;&#x2F;h2&gt;
&lt;p&gt;JOBL uses a two phase validation approach:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Parse TOML into generic Value to catch unknown keys&lt;&#x2F;li&gt;
&lt;li&gt;Deserialize into typed structs for field validation&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;This catches typos immediately. If you write &lt;code&gt;titel&lt;&#x2F;code&gt; instead of &lt;code&gt;title&lt;&#x2F;code&gt;, you
get an error with the exact path: &lt;code&gt;experience[0].titel: unknown field&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The parser collects multiple errors instead of failing fast. When you have five
typos, you see all five at once instead of fixing them one at a time.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub fn parse_file(path: &amp;amp;Path) -&amp;gt; ValidationResult&amp;lt;JoblDocument&amp;gt;
pub fn parse_str(input: &amp;amp;str) -&amp;gt; ValidationResult&amp;lt;JoblDocument&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Simple API. File or string in, validated document or errors out.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;no-version-field&quot;&gt;No Version Field&lt;&#x2F;h2&gt;
&lt;p&gt;I originally included a &lt;code&gt;jobl_version&lt;&#x2F;code&gt; field in documents. Then I removed it.&lt;&#x2F;p&gt;
&lt;p&gt;Docker recently dropped version fields from their configs. The library version
determines what spec you support. Simpler for users. They do not maintain a
version field. The parser handles compatibility.&lt;&#x2F;p&gt;
&lt;p&gt;You can always add versioning later if you need it. Start simple.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;srg-static-resume-generator&quot;&gt;SRG: Static Resume Generator&lt;&#x2F;h2&gt;
&lt;p&gt;JOBL handles data. SRG handles rendering.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;srg --input resume.jobl --out dist --template minimal
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Reads your JOBL file, generates &lt;code&gt;dist&#x2F;index.html&lt;&#x2F;code&gt; and &lt;code&gt;dist&#x2F;resume.pdf&lt;&#x2F;code&gt;. Single
binary. No dependencies except Chrome for PDF generation.&lt;&#x2F;p&gt;
&lt;p&gt;The HTML is self contained with embedded CSS. Email it. Put it on a static
host. It just works.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pdf-generation-headless-chrome&quot;&gt;PDF Generation: Headless Chrome&lt;&#x2F;h2&gt;
&lt;p&gt;I was skeptical about using headless Chrome for PDF generation. It seemed
heavy and finicky. I&#x27;ve tried an HTML -&amp;gt; PDF method before with less than ideal
results. But it works incredibly well.&lt;&#x2F;p&gt;
&lt;p&gt;The HTML you write is the PDF you get. Print media queries work exactly as
expected. Local fonts load synchronously. No layout rewriting needed.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;let pdf_data = tab.print_to_pdf(Some(PrintToPdfOptions {
  landscape: Some(false),
  display_header_footer: Some(false),  &amp;#x2F;&amp;#x2F; No browser artifacts
  print_background: Some(true),        &amp;#x2F;&amp;#x2F; Keep CSS backgrounds
  scale: Some(1.0),
  paper_width: Some(8.5),
  paper_height: Some(11.0),
  &amp;#x2F;&amp;#x2F; ... margins, etc
}))
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Alternative was native Rust PDF libraries like printpdf. That means manually
positioning every element. Reimplementing layout logic. Maintaining two
rendering paths (HTML and PDF).&lt;&#x2F;p&gt;
&lt;p&gt;Headless Chrome means one rendering path. CSS does the work. The PDF matches
the HTML perfectly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layout-system-structure-without-html&quot;&gt;Layout System: Structure Without HTML&lt;&#x2F;h2&gt;
&lt;p&gt;Users should not need to write HTML to customize their resume. But they should
be able to control structure and formatting.&lt;&#x2F;p&gt;
&lt;p&gt;The layout system is a simple DSL:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;person
  name
  headline
  email

experience
  title
  company
  start &amp;quot; - &amp;quot; end
  highlights

education
  degree
  institution
  start &amp;quot; - &amp;quot; end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Section names at indent level 0. Field names at level 2. Order matters.&lt;&#x2F;p&gt;
&lt;p&gt;Quoted strings are literals. Unquoted words are field names. Mix them:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;person
  name &amp;quot;at&amp;quot; email
  &amp;quot;Location:&amp;quot; location

experience
  start &amp;quot; - &amp;quot; end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Renders as:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Jake Goldsborough at jake@example.com
Location: Portland, OR

2025-06 - current
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The layout file controls what shows and in what order. The template (minimal,
professional, etc) controls CSS styling. Separation of concerns.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-build-this&quot;&gt;Why Build This&lt;&#x2F;h2&gt;
&lt;p&gt;Practical answer: I needed to update my resume and existing tools did not fit
how I work.&lt;&#x2F;p&gt;
&lt;p&gt;Real answer: building tools gives me a sense of control when everything else
feels uncertain. Layoffs happen. Companies change. Markets shift. But code I
write is mine. Specs I design are mine. Tools I build solve problems exactly
how I want them solved.&lt;&#x2F;p&gt;
&lt;p&gt;This is not about resume formats. This is about responding to uncertainty by
building something you own completely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;current-status&quot;&gt;Current Status&lt;&#x2F;h2&gt;
&lt;p&gt;Both projects are functional and tested:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;JOBL: 7 unit tests, strict validation, clean error messages&lt;&#x2F;li&gt;
&lt;li&gt;SRG: layout system with quoted strings, PDF generation working, 15 total
tests&lt;&#x2F;li&gt;
&lt;li&gt;Combined: about 1800 lines of Rust, all passing tests&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Documentation sites are up at &lt;a href=&quot;https:&#x2F;&#x2F;jobl.dev&quot;&gt;https:&#x2F;&#x2F;jobl.dev&lt;&#x2F;a&gt; (plain HTML,
no build step, Gruvbox theme with dark&#x2F;light toggle).&lt;&#x2F;p&gt;
&lt;p&gt;Source is on GitHub if you want to look:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;jobl&quot;&gt;github.com&#x2F;ducks&#x2F;jobl&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;srg&quot;&gt;github.com&#x2F;ducks&#x2F;srg&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Skin Shedding, Not Bikeshedding</title>
          <pubDate>Sun, 23 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/skin-shedding-not-bikeshedding/</link>
          <guid>https://jakegoldsborough.com/blog/2025/skin-shedding-not-bikeshedding/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/skin-shedding-not-bikeshedding/">&lt;p&gt;Snakes shed their skin. They outgrow it, slough it off, and keep moving.
They&#x27;re still the same snake, just with a fresh layer, ready for whatever
comes next.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ve been thinking about that lately.&lt;&#x2F;p&gt;
&lt;p&gt;This whole reflection started because I caught myself in yet another argument
where nobody was building anything.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-bikeshed-problem&quot;&gt;The Bikeshed Problem&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;s this concept in software development called bikeshedding. The term
comes from an example about a fictional committee reviewing plans for a
nuclear power plant. They breeze through the complex reactor design but spend
hours debating the color of the bike shed. Why? Because everyone understands
bike sheds. Everyone has opinions about paint.&lt;&#x2F;p&gt;
&lt;p&gt;In code, bikeshedding looks like endless debates about tab width, framework
choices, naming conventions. Arguments about the &quot;right way&quot; to do things
that could be resolved by just trying something and seeing if it works.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ve spent too much time in those debates.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-weight-of-being-right&quot;&gt;The Weight of Being Right&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s what I&#x27;ve learned: arguing about code is exhausting. Not the
technical discussions about trade-offs or the collaborative problem-solving.
I mean the arguments. The &quot;this is the only correct approach&quot; discussions.
The debates where everyone digs in and nobody builds anything.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ve been part of teams where every PR becomes a referendum on architectural
philosophy. Where suggesting a different approach gets met with resistance
before anyone&#x27;s even tried it. Where the discussion thread is longer than
the code changes.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s draining. And worse, it&#x27;s a trap. Because when you&#x27;re arguing about
whether to use Tool A or Tool B, you&#x27;re not actually learning which one
works better for your specific use case. You&#x27;re just... arguing.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-freedom-of-not-knowing&quot;&gt;The Freedom of Not Knowing&lt;&#x2F;h2&gt;
&lt;p&gt;The truth is, half the time we don&#x27;t know what will work until we try it.
We think we do. We have intuitions, experience, opinions. But software is
weird. Context matters. What worked great on one project might be wrong for
another.&lt;&#x2F;p&gt;
&lt;p&gt;So instead, I just try things.&lt;&#x2F;p&gt;
&lt;p&gt;Not recklessly. Not without thought. But when faced with two reasonable
approaches, instead of spending hours debating the merits, I&#x27;ll pick one and
build it. See how it feels. See where it breaks. See what I learn.&lt;&#x2F;p&gt;
&lt;p&gt;And you know what? Sometimes the &quot;wrong&quot; choice teaches me more than the
&quot;right&quot; one would have.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;shedding-skin&quot;&gt;Shedding Skin&lt;&#x2F;h2&gt;
&lt;p&gt;This is where the snake metaphor comes back. Shedding skin isn&#x27;t about
becoming a different snake. It&#x27;s about not being constrained by what fit
before.&lt;&#x2F;p&gt;
&lt;p&gt;For the most part, I&#x27;m the same developer I was six months ago. Same skills,
same values, same approach to problem-solving. But I&#x27;m not carrying around the
weight of old arguments anymore. I&#x27;m not defending approaches I used on past
projects just because I invested ego in them.&lt;&#x2F;p&gt;
&lt;p&gt;I can use SQLite on one project and Postgres on another. I can use React
here and Svelte there. I can experiment with different state management
approaches and see what actually works instead of what I think should work.&lt;&#x2F;p&gt;
&lt;p&gt;The skin shedding isn&#x27;t about the technology. It&#x27;s about letting go of
needing to be right before I&#x27;ve tried anything.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-this-looks-like&quot;&gt;What This Looks Like&lt;&#x2F;h2&gt;
&lt;p&gt;Practically, this means:&lt;&#x2F;p&gt;
&lt;p&gt;Build small experiments. Don&#x27;t debate for hours about whether approach A or B
is better. Spend 30 minutes trying A and see how it feels. Then try B. Now you
have actual data instead of theoretical arguments.&lt;&#x2F;p&gt;
&lt;p&gt;Ship things that might not be perfect. Perfect is the enemy of learning. Ship
something that works, see how it breaks in the real world, iterate. You&#x27;ll
learn more in a week of production use than a month of pre-planning.&lt;&#x2F;p&gt;
&lt;p&gt;Change your mind without drama. &quot;We&#x27;re using X&quot; doesn&#x27;t have to be a permanent
commitment. If it&#x27;s not working, you can try Y. This isn&#x27;t failure, it&#x27;s
iteration.&lt;&#x2F;p&gt;
&lt;p&gt;Judge by outcomes, not aesthetics. Code that&#x27;s a bit messy but works reliably
is better than code that&#x27;s architecturally pure but brittle. The goal is to
solve problems, not win style points.&lt;&#x2F;p&gt;
&lt;p&gt;All of this beats spending three hours arguing about the hypothetical failure
modes of something nobody has tested.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-meta-lesson&quot;&gt;The Meta Lesson&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s the thing: this post is about code, but it&#x27;s not really about code.
It&#x27;s about how we approach problems when we don&#x27;t know the answer yet.&lt;&#x2F;p&gt;
&lt;p&gt;Do we lock ourselves into positions before we have evidence? Do we defend
our intuitions as if they&#x27;re facts? Do we mistake confidence for correctness?&lt;&#x2F;p&gt;
&lt;p&gt;Or do we stay curious? Do we try things? Do we update our beliefs when we
learn new information?&lt;&#x2F;p&gt;
&lt;p&gt;The snake sheds its skin because it has to. It&#x27;s grown too big for the old
one. We can do the same. We can outgrow old arguments, old certainties, old
ways of working that don&#x27;t serve us anymore.&lt;&#x2F;p&gt;
&lt;p&gt;We&#x27;re still the same developers. We&#x27;re just not trapped by what we thought
we knew before we tried it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it-and-see&quot;&gt;Try It and See&lt;&#x2F;h2&gt;
&lt;p&gt;Last week, instead of debating the limitations of someone else&#x27;s critique, I
just built the tool I wished we had.&lt;&#x2F;p&gt;
&lt;p&gt;Next time you&#x27;re in a debate about the &quot;right&quot; way to do something, ask
yourself: would it be faster to just try it?&lt;&#x2F;p&gt;
&lt;p&gt;Not everything, of course. Some decisions are expensive to reverse. But a
lot of what we argue about? It&#x27;s just bike sheds. We could build both sheds,
paint them different colors, and actually see which one we like better.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s skin shedding. Letting go of the need to be right about things you
haven&#x27;t tried yet. Staying light, staying curious, staying open to learning
from the next thing you build.&lt;&#x2F;p&gt;
&lt;p&gt;The old skin doesn&#x27;t fit anymore. Time to shed it and keep moving.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Date-Ver: A Formal Spec for Date-Based Versioning</title>
          <pubDate>Sat, 15 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/introducing-date-ver-date-based-versioning/</link>
          <guid>https://jakegoldsborough.com/blog/2025/introducing-date-ver-date-based-versioning/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/introducing-date-ver-date-based-versioning/">&lt;p&gt;Yesterday I published &lt;a href=&quot;https:&#x2F;&#x2F;rubygems.org&#x2F;gems&#x2F;yaml-janitor&quot;&gt;yaml-janitor&lt;&#x2F;a&gt;
using date-based version numbers like &lt;code&gt;20251115&lt;&#x2F;code&gt;. The version number is the
release date. Automatic, meaningful, no decisions needed.&lt;&#x2F;p&gt;
&lt;p&gt;Then I realized there&#x27;s no formal specification for this. Semantic versioning
has &lt;a href=&quot;https:&#x2F;&#x2F;semver.org&#x2F;&quot;&gt;semver.org&lt;&#x2F;a&gt; with strict rules and RFC 2119 language.
Calendar versioning has &lt;a href=&quot;https:&#x2F;&#x2F;calver.org&#x2F;&quot;&gt;calver.org&lt;&#x2F;a&gt;, but that&#x27;s just a
pattern guide, not a spec.&lt;&#x2F;p&gt;
&lt;p&gt;So I built &lt;a href=&quot;https:&#x2F;&#x2F;date-ver.com&#x2F;&quot;&gt;date-ver&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-is&quot;&gt;What It Is&lt;&#x2F;h2&gt;
&lt;p&gt;date-ver is a formal spec for date-based version numbers. Instead of &lt;code&gt;1.2.3&lt;&#x2F;code&gt;
or &lt;code&gt;2.0.0&lt;&#x2F;code&gt;, you use &lt;code&gt;20251116&lt;&#x2F;code&gt;, &lt;code&gt;202511&lt;&#x2F;code&gt;, or &lt;code&gt;2025&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The idea is simple: your version number is when you released, not what
changed. Works great for applications, utilities, and distributions where
users care about freshness over compatibility.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-it-exists&quot;&gt;Why It Exists&lt;&#x2F;h2&gt;
&lt;p&gt;Projects have been using date-based versions forever. Ubuntu does &lt;code&gt;24.04&lt;&#x2F;code&gt; and
&lt;code&gt;24.10&lt;&#x2F;code&gt;. yaml-janitor uses &lt;code&gt;20251115&lt;&#x2F;code&gt;. But without a formal spec, everyone
makes up their own rules.&lt;&#x2F;p&gt;
&lt;p&gt;Questions that needed answers:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Can you mix formats? (Yes: &lt;code&gt;2024&lt;&#x2F;code&gt; → &lt;code&gt;202505&lt;&#x2F;code&gt; → &lt;code&gt;20250601&lt;&#x2F;code&gt; is valid)&lt;&#x2F;li&gt;
&lt;li&gt;Multiple releases per day? (Sequence numbers: &lt;code&gt;20251116.1&lt;&#x2F;code&gt;, &lt;code&gt;20251116.2&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Pre-releases? (Use your separator: &lt;code&gt;20251116.alpha.1&lt;&#x2F;code&gt; or &lt;code&gt;2025-11-15-rc-2&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Separators allowed? (Yes: &lt;code&gt;20251116&lt;&#x2F;code&gt; = &lt;code&gt;2025.11.16&lt;&#x2F;code&gt; = &lt;code&gt;2025-11-16&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;date-ver answers all of these with proper RFC 2119 language.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-separator-decision&quot;&gt;The Separator Decision&lt;&#x2F;h2&gt;
&lt;p&gt;Ubuntu uses dots (&lt;code&gt;24.04&lt;&#x2F;code&gt;). yaml-janitor uses nothing (&lt;code&gt;20251115&lt;&#x2F;code&gt;). Some
projects use hyphens or slashes. Rather than force everyone to change, date-ver
allows any single-character separator as long as you&#x27;re consistent throughout
the entire version string similar to regex.&lt;&#x2F;p&gt;
&lt;p&gt;Valid:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;20251116&lt;&#x2F;code&gt; (no separator)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;2025.11.16&lt;&#x2F;code&gt; (dots throughout)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;2025-11-16&lt;&#x2F;code&gt; (hyphens throughout)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;2025&#x2F;11&#x2F;16&lt;&#x2F;code&gt; (slashes throughout)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;20251116.alpha.1&lt;&#x2F;code&gt; (dots for everything)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;2025-11-15-rc-2&lt;&#x2F;code&gt; (hyphens for everything)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Invalid:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;2025.11-16&lt;&#x2F;code&gt; (mixed separators in date)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;20251116-alpha.1&lt;&#x2F;code&gt; (mixing none with hyphen and dot)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Pick one separator and stick to it. All separator styles are equivalent. To
compare versions, strip separators and compare digits. Simple.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;pick-your-precision&quot;&gt;Pick Your Precision&lt;&#x2F;h2&gt;
&lt;p&gt;Projects choose their granularity:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Year: &lt;code&gt;2025&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Year-month: &lt;code&gt;202511&lt;&#x2F;code&gt; or &lt;code&gt;2025.11&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Year-month-day: &lt;code&gt;20251116&lt;&#x2F;code&gt; or &lt;code&gt;2025.11.16&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;More specific = higher precedence: &lt;code&gt;2025 &amp;lt; 202501 &amp;lt; 20250101&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Start with yearly versions and switch to monthly or daily whenever. No
breaking changes, just more precision when you need it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;when-to-use-it&quot;&gt;When to Use It&lt;&#x2F;h2&gt;
&lt;p&gt;Good fit:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Applications with regular releases&lt;&#x2F;li&gt;
&lt;li&gt;Operating systems and distributions&lt;&#x2F;li&gt;
&lt;li&gt;Tools where freshness matters&lt;&#x2F;li&gt;
&lt;li&gt;Internal utilities&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Bad fit:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Libraries where compatibility matters&lt;&#x2F;li&gt;
&lt;li&gt;APIs with semver expectations&lt;&#x2F;li&gt;
&lt;li&gt;Projects with strict dependency contracts&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Need to signal breaking changes? Use semver. Need to signal release timing?
Use date-ver.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;building-it&quot;&gt;Building It&lt;&#x2F;h2&gt;
&lt;p&gt;Wrote the spec following semver&#x27;s structure:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Summary&lt;&#x2F;li&gt;
&lt;li&gt;Introduction&lt;&#x2F;li&gt;
&lt;li&gt;Numbered rules with RFC 2119 language&lt;&#x2F;li&gt;
&lt;li&gt;FAQ&lt;&#x2F;li&gt;
&lt;li&gt;Examples&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Built a website with GitHub Pages, Gruvbox colors, and automatic dark mode.
The spec itself uses date-ver &lt;code&gt;20251115&lt;&#x2F;code&gt; (it originally said &lt;code&gt;1.0.0&lt;&#x2F;code&gt; which was
pretty ironic).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;that-s-it&quot;&gt;That&#x27;s It&lt;&#x2F;h2&gt;
&lt;p&gt;date-ver exists. The spec is done, the domain is live, and the website is up
at &lt;a href=&quot;https:&#x2F;&#x2F;date-ver.com&#x2F;&quot;&gt;date-ver.com&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Ubuntu is officially valid date-ver. So is yaml-janitor. Any project using
date-based versions can point to the spec now.&lt;&#x2F;p&gt;
&lt;p&gt;Maybe it catches on. Maybe it doesn&#x27;t. Either way, there&#x27;s a formal answer
when someone asks &quot;why are your versions dates?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The version is the release date. That&#x27;s date-ver.&lt;&#x2F;p&gt;
&lt;hr &#x2F;&gt;
&lt;p&gt;Full spec: &lt;a href=&quot;https:&#x2F;&#x2F;date-ver.com&#x2F;&quot;&gt;date-ver.com&lt;&#x2F;a&gt;
Source: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;date-ver&quot;&gt;GitHub&lt;&#x2F;a&gt; (CC BY 4.0)&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building yaml-janitor: Solving the Comment Preservation Problem</title>
          <pubDate>Fri, 14 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/yaml-janitor-comment-preservation/</link>
          <guid>https://jakegoldsborough.com/blog/2025/yaml-janitor-comment-preservation/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/yaml-janitor-comment-preservation/">&lt;p&gt;For the past five years, there&#x27;s been an intermittent problem at work:
sometimes, when editing YAML configuration files with automated tools, comments
get destroyed. The comments contain critical context like why certain values
exist or what not to change, so losing them causes real problems when trying to
maintain infrastructure configuration.&lt;&#x2F;p&gt;
&lt;p&gt;A coworker tried to solve this in 2021 by building tooling around
psych-pure, a pure Ruby YAML parser that can preserve comments. But
psych-pure wasn&#x27;t stable enough at the time, so the project stalled.&lt;&#x2F;p&gt;
&lt;p&gt;Fast forward to 2025, and I happened to be looking through devops topics and
stumbled upon this one. This seemed like the perfect opportunity to revisit the
comment preservation problem.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-initial-validation&quot;&gt;The Initial Validation&lt;&#x2F;h2&gt;
&lt;p&gt;I started by writing a simple validation script using psych-pure to check
if our production YAML files could survive a round-trip: load the file,
dump it back out, and verify the semantics stayed the same.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;require &amp;#x27;psych&amp;#x2F;pure&amp;#x27;

yaml_content = File.read(&amp;#x27;config.yml&amp;#x27;)
loaded = Psych::Pure.load(yaml_content, comments: true)
dumped = Psych::Pure.dump(loaded)

# Verify semantics match
original_data = Psych::Pure.load(yaml_content)
dumped_data = Psych::Pure.load(dumped)

if original_data == dumped_data
  puts &amp;quot;✅ Semantics preserved&amp;quot;
else
  puts &amp;quot;❌ MISMATCH&amp;quot;
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I ran this against 1,736 production YAML files across two repositories.
The good news: no corrupt YAML! Most files validated successfully, with
only a handful failing due to using patterns that are technically invalid
per the YAML spec (though the C implementation of Psych accepts them
anyway).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;building-yaml-janitor&quot;&gt;Building yaml-janitor&lt;&#x2F;h2&gt;
&lt;p&gt;With validation working, I built a proper linter around psych-pure. The
key challenge was that while psych-pure preserves comments when loading,
its built-in dump function doesn&#x27;t give enough control over formatting.
I needed custom formatting rules like consistent indentation and compact
array notation.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s what yaml-janitor fixes:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;# Before: mixed indentation, explicit array format
config:
    # Database settings
    database:
      host: localhost
        port: 5432
    features:
      -
        name: caching
        enabled: true

# After: consistent indentation, compact arrays
config:
  # Database settings
  database:
    host: localhost
    port: 5432
  features:
    - name: caching
      enabled: true
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The solution was to build a custom emitter that walks the loaded data
structure and outputs formatted YAML while preserving all comments:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class Emitter
  def initialize(node, config)
    @node = node
    @config = config
    @output = []
  end

  def emit
    emit_comments(get_comments(@node, :leading), 0)
    emit_document(@node)
    @output.join(&amp;quot;\n&amp;quot;) + &amp;quot;\n&amp;quot;
  end

  def emit_mapping(hash, indent)
    entries = hash.respond_to?(:psych_keys) ?
      hash.psych_keys.map { |pk| [pk.key_node, pk.value_node] } :
      hash.to_a

    entries.each_with_index do |(key, value), index|
      emit_comments(get_comments(key, :leading), indent)
      key_str = scalar_to_string(key)

      case value
      when Hash, Array
        @output &amp;lt;&amp;lt; &amp;quot;#{&amp;#x27; &amp;#x27; * indent}#{key_str}:&amp;quot;
        emit_node(value, indent + indentation)
      else
        value_str = scalar_to_string(value)
        @output &amp;lt;&amp;lt; &amp;quot;#{&amp;#x27; &amp;#x27; * indent}#{key_str}: #{value_str}&amp;quot;
      end

      emit_comments(get_comments(key, :trailing), indent)
    end
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This gave me complete control over formatting while preserving every
comment. The emitter handles:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Consistent indentation (2 or 4 spaces)&lt;&#x2F;li&gt;
&lt;li&gt;Compact array format (dash on same line as first key)&lt;&#x2F;li&gt;
&lt;li&gt;Comment placement (leading and trailing)&lt;&#x2F;li&gt;
&lt;li&gt;Blank lines between top-level sections&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;production-testing&quot;&gt;Production Testing&lt;&#x2F;h2&gt;
&lt;p&gt;I tested yaml-janitor on a production file with mixed 2-space and 4-space
indentation:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# Backup original
cp pipeline.yaml &amp;#x2F;tmp&amp;#x2F;pipeline_backup.yaml

# Fix with yaml-janitor
yaml-janitor --fix --indentation 2 pipeline.yaml

# Verify semantics preserved
ruby -ryaml -e &amp;quot;
  orig = YAML.load_file(&amp;#x27;&amp;#x2F;tmp&amp;#x2F;pipeline_backup.yaml&amp;#x27;)
  fixed = YAML.load_file(&amp;#x27;pipeline.yaml&amp;#x27;)
  puts orig == fixed ? &amp;#x27;✅ Semantics preserved&amp;#x27; : &amp;#x27;❌ MISMATCH&amp;#x27;
&amp;quot;
# Output: ✅ Semantics preserved
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The fix worked perfectly. Consistent indentation, all comments preserved,
and semantics verified to be identical.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;configuration-system&quot;&gt;Configuration System&lt;&#x2F;h2&gt;
&lt;p&gt;I added a configuration file system so teams can standardize their YAML
formatting:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;# .yaml-janitor.yml
indentation: 2
line_width: 80
sequence_indent: false

rules:
  consistent_indentation:
    enabled: true
  multiline_certificate:
    enabled: true
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The CLI supports both config files and command-line overrides:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;yaml-janitor --config .yaml-janitor.yml files&amp;#x2F;*.yml
yaml-janitor --fix --indentation 2 config.yml
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;publishing-to-rubygems&quot;&gt;Publishing to RubyGems&lt;&#x2F;h2&gt;
&lt;p&gt;Once the linter was working, I published it to RubyGems so anyone could
use it. I set up automated publishing with GitHub Actions and RubyGems
trusted publishing (no API keys needed):&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;name: Publish to RubyGems
on:
  push:
    tags:
      - &amp;#x27;v*&amp;#x27;
jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions&amp;#x2F;checkout@v4
      - uses: ruby&amp;#x2F;setup-ruby@v1
        with:
          ruby-version: &amp;#x27;3.3&amp;#x27;
          bundler-cache: true
      - uses: rubygems&amp;#x2F;release-gem@v1
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The release process is now fully automated:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Update version number (date-based: YYYYMMDD)&lt;&#x2F;li&gt;
&lt;li&gt;Run &lt;code&gt;bundle install&lt;&#x2F;code&gt; to update lockfile&lt;&#x2F;li&gt;
&lt;li&gt;Commit and push to main&lt;&#x2F;li&gt;
&lt;li&gt;Create and push tag: &lt;code&gt;git tag v20251113 &amp;amp;&amp;amp; git push origin v20251113&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;GitHub Actions automatically publishes to RubyGems&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;&#x2F;h2&gt;
&lt;p&gt;yaml-janitor is now published and available on RubyGems:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;gem install yaml-janitor
yaml-janitor --help
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Production validation results:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Scanned 1,736 YAML files&lt;&#x2F;li&gt;
&lt;li&gt;Found 0 corrupt files&lt;&#x2F;li&gt;
&lt;li&gt;Fixed widespread formatting inconsistencies&lt;&#x2F;li&gt;
&lt;li&gt;Preserved all comments during fixes&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;People are already using it to lint their YAML configurations.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-five-year-solution&quot;&gt;The Five-Year Solution&lt;&#x2F;h2&gt;
&lt;p&gt;The problem my coworker identified in 2021 is finally solved. Teams can
now safely automate YAML editing without losing critical context in
comments. The infrastructure was already there, it just needed psych-pure
to stabilize and someone to finish building the tooling.&lt;&#x2F;p&gt;
&lt;p&gt;yaml-janitor is open source and available on
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;yaml-janitor&quot;&gt;GitHub&lt;&#x2F;a&gt; and
&lt;a href=&quot;https:&#x2F;&#x2F;rubygems.org&#x2F;gems&#x2F;yaml-janitor&quot;&gt;RubyGems&lt;&#x2F;a&gt;. If you&#x27;ve ever had
to choose between manual YAML editing and losing your comments, check
it out.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Like a Pig in Shit: Why I Love AI &quot;Slop&quot;</title>
          <pubDate>Sat, 08 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/like-a-pig-in-shit-ai-slop/</link>
          <guid>https://jakegoldsborough.com/blog/2025/like-a-pig-in-shit-ai-slop/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/like-a-pig-in-shit-ai-slop/">&lt;p&gt;I&#x27;ve shipped more projects in the last three months than I did in the previous
year. Are they all perfect? No. Are they all polished production-grade code?
Hell no. Do I care? Not even a little bit.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m having the time of my life building whatever idea pops into my head, and AI
is the reason I can actually finish things instead of just thinking about them.&lt;&#x2F;p&gt;
&lt;p&gt;And look, shipping isn&#x27;t everything. Sometimes the best code never sees the
light of day. Sometimes you build something just to learn, or just because it
sounds fun. But there&#x27;s something deeply satisfying about making things that
actually work - even if they&#x27;re just for you.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-ve-been-building&quot;&gt;What I&#x27;ve Been Building&lt;&#x2F;h2&gt;
&lt;p&gt;In the last few months alone:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;vimdeck.nvim&quot;&gt;vimdeck.nvim&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt;: Markdown presentations in Neovim using Treesitter&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;mdpreview.nvim&quot;&gt;mdpreview.nvim&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt;: Live markdown preview in a split window&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-yaks&quot;&gt;discourse-yaks&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt;: Virtual currency system for forum posts&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-transit-tracker&quot;&gt;discourse-transit-tracker&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt;: Turn forums into live departure boards&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-invite-stats&quot;&gt;discourse-invite-stats&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt;: Invite reporting and analytics plugin&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;shelltrax&lt;&#x2F;a&gt;&lt;&#x2F;strong&gt;: TUI music player in Rust (my daily driver now)&lt;&#x2F;li&gt;
&lt;li&gt;Plus a handful of smaller tools and fixes&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Some of these are polished. Some are &quot;good enough for me.&quot; All of them work.
All of them solve real problems I had. And I built most of them in a day or
two.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-slop-criticism&quot;&gt;The &quot;Slop&quot; Criticism&lt;&#x2F;h2&gt;
&lt;p&gt;There&#x27;s this narrative that AI-generated code is &quot;slop.&quot; Low quality, generic,
copy-pasted garbage that&#x27;s polluting the internet. And sure, that exists. That
existed before AI when people would blindly copy from Stack Overflow. But
the criticism misses something important:&lt;&#x2F;p&gt;
&lt;p&gt;When you&#x27;re building for yourself, &quot;slop&quot; doesn&#x27;t matter.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m not pushing half-baked code to production systems. I&#x27;m not claiming my
afternoon projects are enterprise-ready. I&#x27;m building tools that solve my
problems, learning by doing, and having fun.&lt;&#x2F;p&gt;
&lt;p&gt;If the markdown preview plugin has rough edges, who cares? It works for me. If
the presentation tool doesn&#x27;t handle every edge case, so what? It renders my
slides.&lt;&#x2F;p&gt;
&lt;p&gt;The &quot;slop&quot; criticism only matters when you&#x27;re shipping garbage and calling it
gold. When you&#x27;re building for yourself? Ship it, iterate, move on.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-actually-happening&quot;&gt;What&#x27;s Actually Happening&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s what working with AI looks like for me:&lt;&#x2F;p&gt;
&lt;p&gt;I have an idea. &quot;I want to do presentations in Neovim.&quot; Instead of spending a
week researching markdown parsers and figlet integration and buffer management,
I describe what I want and start building.&lt;&#x2F;p&gt;
&lt;p&gt;The AI suggests approaches. Some work. Some don&#x27;t. We iterate. I learn what
Treesitter can do. I understand how Neovim&#x27;s highlight system works. I see
patterns I wouldn&#x27;t have thought of.&lt;&#x2F;p&gt;
&lt;p&gt;A few hours later, I have a working plugin.&lt;&#x2F;p&gt;
&lt;p&gt;Is it perfect? No. Did I learn a ton? Yes. Can I actually use it? Absolutely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-joy-of-building&quot;&gt;The Joy of Building&lt;&#x2F;h2&gt;
&lt;p&gt;What the critics miss is how much fun this is.&lt;&#x2F;p&gt;
&lt;p&gt;I used to have a backlog of ideas that never got built. &quot;Someday I&#x27;ll make
that.&quot; &quot;That would be cool but it&#x27;s too much work.&quot; &quot;I don&#x27;t have time to learn
X framework.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Now? I just build it.&lt;&#x2F;p&gt;
&lt;p&gt;Want to visualize invite relationships as an ASCII tree? Build it. Want live
transit departures in a forum? Build it. Want to fix a bug in a YAML parser?
Build it and submit the fix upstream.&lt;&#x2F;p&gt;
&lt;p&gt;The barrier between &quot;I wish this existed&quot; and &quot;I built this&quot; has collapsed. And
that&#x27;s incredible.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;but-is-it-actually-good-code&quot;&gt;But Is It Actually Good Code?&lt;&#x2F;h2&gt;
&lt;p&gt;Sometimes yes, sometimes no. And that&#x27;s fine.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kddnewton&#x2F;psych-pure&#x2F;pull&#x2F;30&quot;&gt;Ruby psych-pure bug fix&lt;&#x2F;a&gt; I
submitted? That&#x27;s good code. Full test coverage, proper edge case handling,
clean implementation. It&#x27;s going into a production library.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-transit-tracker&quot;&gt;transit tracker&lt;&#x2F;a&gt;? It
works, handles 500k+ MTA stop times, and has solid patterns for data
downloading and parsing. Could the UI be more polished? Sure. But it does what
I need and I learned a ton building it.&lt;&#x2F;p&gt;
&lt;p&gt;The key is knowing the difference. When it matters, I put in the work. When
it&#x27;s just for me, I ship it and move on.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-real-productivity-hack&quot;&gt;The Real Productivity Hack&lt;&#x2F;h2&gt;
&lt;p&gt;Working with AI isn&#x27;t about replacing thinking. It&#x27;s about spending your brain
cycles on the interesting problems instead of the boring setup work.&lt;&#x2F;p&gt;
&lt;p&gt;I don&#x27;t want to spend three hours setting up test infrastructure. I want to
spend three hours solving the actual problem. AI handles the grunt work. I
handle the decisions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;you-can-vibe-and-still-be-productive&quot;&gt;You Can Vibe and Still Be Productive&lt;&#x2F;h2&gt;
&lt;p&gt;Here&#x27;s the thing: you can have fun, move fast, and still produce quality work.
It&#x27;s not either&#x2F;or.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m &quot;vibing out&quot; projects. Building whatever sounds interesting. Shipping fast.
And yet:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The code works&lt;&#x2F;li&gt;
&lt;li&gt;The tests pass&lt;&#x2F;li&gt;
&lt;li&gt;The bugs get fixed&lt;&#x2F;li&gt;
&lt;li&gt;The upstream contributions get merged&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;You can be a pig in shit and still do good work. The slop is the medium, not
the output.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-gatekeeping-problem&quot;&gt;The Gatekeeping Problem&lt;&#x2F;h2&gt;
&lt;p&gt;A lot of the &quot;AI slop&quot; criticism is just gatekeeping in disguise.&lt;&#x2F;p&gt;
&lt;p&gt;&quot;Real programmers don&#x27;t use AI.&quot; &quot;You&#x27;re not actually learning.&quot; &quot;This is
ruining software quality.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Meanwhile, I&#x27;m shipping. Learning by building. Contributing to open source.
Having fun.&lt;&#x2F;p&gt;
&lt;p&gt;The gatekeepers can argue about the &quot;right way&quot; to write code. I&#x27;m too busy
building things.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-actually-matters&quot;&gt;What Actually Matters&lt;&#x2F;h2&gt;
&lt;p&gt;At the end of the day, here&#x27;s what matters:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Does it work?&lt;&#x2F;li&gt;
&lt;li&gt;Did you learn something?&lt;&#x2F;li&gt;
&lt;li&gt;Are you having fun?&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If the answer to those three questions is yes, then it doesn&#x27;t matter if
someone on Twitter thinks your code is &quot;slop.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;You&#x27;re building. They&#x27;re complaining. You win.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;like-a-pig-in-shit&quot;&gt;Like a Pig in Shit&lt;&#x2F;h2&gt;
&lt;p&gt;So yeah, I&#x27;m rolling around in the AI slop. Building projects at a pace I never
could before. Shipping tools that solve real problems. Learning by doing
instead of just reading about it. Old me would think I was full of shit if I
tried explaining this.&lt;&#x2F;p&gt;
&lt;p&gt;Call it slop if you want. I&#x27;m too busy having the time of my life to care.&lt;&#x2F;p&gt;
&lt;p&gt;The pig pen is the place for me.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>vimdeck.nvim: Markdown Presentations in Neovim</title>
          <pubDate>Fri, 07 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/vimdeck-nvim-markdown-presentations/</link>
          <guid>https://jakegoldsborough.com/blog/2025/vimdeck-nvim-markdown-presentations/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/vimdeck-nvim-markdown-presentations/">&lt;p&gt;I live in the terminal and Neovim. We have been discussing lightning talks at work,
so I wanted a way to present using both. The original
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;tybenz&#x2F;vimdeck&quot;&gt;vimdeck&lt;&#x2F;a&gt; used to work perfectly, but it&#x27;s a
Ruby script that doesn&#x27;t seem to work with Neovim.&lt;&#x2F;p&gt;
&lt;p&gt;So I rewrote it from scratch using Treesitter and Lua (and Claude).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-does&quot;&gt;What It Does&lt;&#x2F;h2&gt;
&lt;p&gt;You write your presentation in markdown. Separate slides with horizontal
rules (&lt;code&gt;---&lt;&#x2F;code&gt;). Run &lt;code&gt;:Vimdeck&lt;&#x2F;code&gt; and you get a fullscreen presentation with:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;ASCII art headers (h1 and h2 use figlet)&lt;&#x2F;li&gt;
&lt;li&gt;Syntax highlighted code blocks&lt;&#x2F;li&gt;
&lt;li&gt;Clean list rendering with bullets&lt;&#x2F;li&gt;
&lt;li&gt;Blockquote formatting&lt;&#x2F;li&gt;
&lt;li&gt;Keyboard navigation (Space&#x2F;PageDown, Backspace&#x2F;PageUp, q to quit)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;neovim-deck-1.png&quot; alt=&quot;vimdeck presentation showing ASCII art header&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;neovim-deck-prose.png&quot; alt=&quot;neovimdeck prose slide&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-rewrite-it&quot;&gt;Why Rewrite It?&lt;&#x2F;h2&gt;
&lt;p&gt;The original vimdeck was solid for Vim, but it was not working for me in
Neovim. Adding to that, it felt like it could be done in a better, more native
way. The current Vimdeck was:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;External Ruby script (dependency management)&lt;&#x2F;li&gt;
&lt;li&gt;Generated temp files for each slide&lt;&#x2F;li&gt;
&lt;li&gt;Custom shell wrapper for keybindings&lt;&#x2F;li&gt;
&lt;li&gt;redcarpet gem for markdown parsing&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Neovim has Treesitter built-in. Lua is native. Why shell out to Ruby and
manage temp files when you can parse and render everything directly?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-approach&quot;&gt;The Approach&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Parser&lt;&#x2F;strong&gt; (&lt;code&gt;lua&#x2F;vimdeck&#x2F;parser.lua&lt;&#x2F;code&gt;): Uses Treesitter to parse markdown
buffers. Queries for headings, code blocks, lists, quotes, paragraphs. Splits
content into slides on horizontal rules (thematic breaks).&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;lua&quot; class=&quot;language-lua &quot;&gt;&lt;code class=&quot;language-lua&quot; data-lang=&quot;lua&quot;&gt;local query = vim.treesitter.query.parse(&amp;#x27;markdown&amp;#x27;, [[
  (thematic_break) @separator
  (atx_heading) @heading
  (fenced_code_block) @code
  (list_item) @list_item
  (block_quote) @quote
  (paragraph) @paragraph
]])
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;For each captured node, extract the text and metadata (heading level, code
language, etc.). Group elements into slides based on separators.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Renderer&lt;&#x2F;strong&gt; (&lt;code&gt;lua&#x2F;vimdeck&#x2F;renderer.lua&lt;&#x2F;code&gt;): Takes parsed slides and renders
them to buffer lines with highlight instructions.&lt;&#x2F;p&gt;
&lt;p&gt;Headers get piped through figlet for ASCII art (h1 uses standard font, h2
uses small). Lists get bullet points. Blockquotes get vertical bars. Code
blocks get syntax highlighting via Treesitter string parsers.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Navigation&lt;&#x2F;strong&gt; (&lt;code&gt;lua&#x2F;vimdeck&#x2F;navigation.lua&lt;&#x2F;code&gt;): Opens slides in a new tab,
sets up keybindings, manages state. Updates the buffer content and applies
highlights when switching slides.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;technical-bits&quot;&gt;Technical Bits&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Treesitter Integration&lt;&#x2F;strong&gt;: The markdown parser exposes node types like
&lt;code&gt;atx_heading&lt;&#x2F;code&gt;, &lt;code&gt;fenced_code_block&lt;&#x2F;code&gt;, &lt;code&gt;list_item&lt;&#x2F;code&gt;. Each node has position info
(start&#x2F;end row&#x2F;col) and can extract text with
&lt;code&gt;vim.treesitter.get_node_text()&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Getting clean text required helper functions. List items include the markdown
markers (&lt;code&gt;- item&lt;&#x2F;code&gt;), so we traverse child nodes to skip &lt;code&gt;list_marker&lt;&#x2F;code&gt; nodes
and extract just the content. Same for blockquotes (strip &lt;code&gt;&amp;gt;&lt;&#x2F;code&gt; markers) and
code blocks (strip fence markers).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Heading Levels&lt;&#x2F;strong&gt;: Treesitter gives you &lt;code&gt;atx_heading&lt;&#x2F;code&gt; nodes. To determine
level, check for child nodes like &lt;code&gt;atx_h1_marker&lt;&#x2F;code&gt;, &lt;code&gt;atx_h2_marker&lt;&#x2F;code&gt;, etc.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;lua&quot; class=&quot;language-lua &quot;&gt;&lt;code class=&quot;language-lua&quot; data-lang=&quot;lua&quot;&gt;function M.get_heading_level(heading_node)
  for child in heading_node:iter_children() do
    if child:type() == &amp;#x27;atx_h1_marker&amp;#x27; then return 1 end
    if child:type() == &amp;#x27;atx_h2_marker&amp;#x27; then return 2 end
    -- ...
  end
  return 1
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Duplicate Elements&lt;&#x2F;strong&gt;: Initial implementation captured both &lt;code&gt;list_item&lt;&#x2F;code&gt;
nodes and &lt;code&gt;paragraph&lt;&#x2F;code&gt; nodes inside them. Rendering each element separately
meant lists showed up twice. Fix: check node ancestry and skip paragraphs
that are children of list items or blockquotes.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Newline Flattening&lt;&#x2F;strong&gt;: &lt;code&gt;vim.api.nvim_buf_set_lines()&lt;&#x2F;code&gt; requires each array
element to be a single line. Figlet output and some text processing can embed
&lt;code&gt;\n&lt;&#x2F;code&gt; characters. Solution: scan lines before setting buffer content and split
any with embedded newlines.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-supports&quot;&gt;What It Supports&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;All heading levels&lt;&#x2F;strong&gt; (h1-h6). h1 and h2 get ASCII art via figlet if
available. h3-h6 render as plain text with style options such as box, dashed, or
underline.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Code blocks&lt;&#x2F;strong&gt; with language-specific syntax highlighting. The renderer
creates a Treesitter string parser for the code content and applies highlight
queries.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Lists&lt;&#x2F;strong&gt; render with bullet points (&lt;code&gt;*&lt;&#x2F;code&gt;). &lt;strong&gt;Blockquotes&lt;&#x2F;strong&gt; render with
vertical bars (&lt;code&gt;┃&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;neovim-deck-heading.png&quot; alt=&quot;A neovim deck slide showing heading styling, lists, and blockquotes&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;per-presentation-configuration&quot;&gt;Per-Presentation Configuration&lt;&#x2F;h2&gt;
&lt;p&gt;YAML frontmatter lets you configure individual presentations without
changing global settings. Add frontmatter at the start of your markdown:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;markdown&quot; class=&quot;language-markdown &quot;&gt;&lt;code class=&quot;language-markdown&quot; data-lang=&quot;markdown&quot;&gt;---
wrap: 80
center_horizontal: true
margin: 3
use_figlet: false
header_style: &amp;quot;underline&amp;quot;
---

# First Slide
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;wrap&lt;&#x2F;code&gt; option automatically wraps long paragraphs to the specified
width. Perfect for prose-heavy slides where you don&#x27;t want to manually
hard-wrap text. Write flowing paragraphs in your markdown source and let
vimdeck handle the layout.&lt;&#x2F;p&gt;
&lt;p&gt;Header styles provide alternatives to figlet ASCII art. Options include
&lt;code&gt;underline&lt;&#x2F;code&gt; (single&#x2F;double lines), &lt;code&gt;box&lt;&#x2F;code&gt; (Unicode box drawing), &lt;code&gt;double&lt;&#x2F;code&gt;
(double-line boxes), and &lt;code&gt;dashed&lt;&#x2F;code&gt; (dotted underlines). All use Unicode
characters for clean terminal rendering.&lt;&#x2F;p&gt;
&lt;p&gt;Frontmatter parsing uses a simple YAML parser (20 lines of Lua) that
handles basic key-value pairs. Supports booleans, numbers, and strings.
The frontmatter config merges with global config, so you only override what
you need.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;installation&quot;&gt;Installation&lt;&#x2F;h2&gt;
&lt;p&gt;Using lazy.nvim:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;lua&quot; class=&quot;language-lua &quot;&gt;&lt;code class=&quot;language-lua&quot; data-lang=&quot;lua&quot;&gt;{
  &amp;#x27;ducks&amp;#x2F;vimdeck.nvim&amp;#x27;,
  cmd = { &amp;#x27;Vimdeck&amp;#x27;, &amp;#x27;VimdeckFile&amp;#x27; },
  opts = {
    use_figlet = true,
    center_slides = true,
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Requires figlet for ASCII art headers:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;# macOS
brew install figlet

# Arch Linux
sudo pacman -S figlet

# NixOS
pkgs.figlet
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And markdown Treesitter parsers:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;vim&quot; class=&quot;language-vim &quot;&gt;&lt;code class=&quot;language-vim&quot; data-lang=&quot;vim&quot;&gt;:TSInstall markdown markdown_inline
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;usage&quot;&gt;Usage&lt;&#x2F;h2&gt;
&lt;p&gt;Write your presentation:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;markdown&quot; class=&quot;language-markdown &quot;&gt;&lt;code class=&quot;language-markdown&quot; data-lang=&quot;markdown&quot;&gt;# First Slide

Content here

---

## Second Slide

More content

---

### Code Example

```lua
function hello()
  print(&amp;quot;Hello!&amp;quot;)
end
```
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Open in Neovim and run &lt;code&gt;:Vimdeck&lt;&#x2F;code&gt;. Navigate with Space&#x2F;PageDown for next
slide, Backspace&#x2F;PageUp for previous, q to quit.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;differences-from-original&quot;&gt;Differences From Original&lt;&#x2F;h2&gt;
&lt;p&gt;The original vimdeck was a Ruby script. This is a native Neovim plugin.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;No external dependencies (besides figlet for ASCII art)&lt;&#x2F;li&gt;
&lt;li&gt;No temp files&lt;&#x2F;li&gt;
&lt;li&gt;Treesitter parsing instead of redcarpet&lt;&#x2F;li&gt;
&lt;li&gt;Dynamic rendering instead of static file generation&lt;&#x2F;li&gt;
&lt;li&gt;Works with Neovim&#x27;s highlight system directly&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;things-to-remember&quot;&gt;Things To Remember&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Treesitter is powerful&lt;&#x2F;strong&gt;: The markdown parser handles all the edge cases
(nested lists, multi-line quotes, code fence detection). Using the query
system is cleaner than regex parsing.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Node traversal matters&lt;&#x2F;strong&gt;: Getting clean text from Treesitter nodes requires
understanding the tree structure. List items have marker nodes, code blocks
have fence nodes, quotes have paragraph children. Walking the tree to extract
just content nodes is necessary.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Lua quirks&lt;&#x2F;strong&gt;: Coming from Ruby, easy to forget &lt;code&gt;not&lt;&#x2F;code&gt; instead of &lt;code&gt;!&lt;&#x2F;code&gt;, &lt;code&gt;~=&lt;&#x2F;code&gt;
instead of &lt;code&gt;!=&lt;&#x2F;code&gt;, &lt;code&gt;..&lt;&#x2F;code&gt; for string concatenation, 1-indexed tables.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Code&lt;&#x2F;strong&gt;: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;vimdeck.nvim&quot;&gt;github.com&#x2F;ducks&#x2F;vimdeck.nvim&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Invite Tree to Invite Stats - How A Simple Tree Flourished Into A Full Moderation Plugin</title>
          <pubDate>Wed, 05 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/discourse-invite-stats-evolution/</link>
          <guid>https://jakegoldsborough.com/blog/2025/discourse-invite-stats-evolution/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/discourse-invite-stats-evolution/">&lt;p&gt;Recently, I wrote about building a Discourse plugin that visualizes invite
relationships as an ASCII tree. Clean, simple, Lobsters-inspired. I thought
I was done.&lt;&#x2F;p&gt;
&lt;p&gt;Then I kept thinking about it.&lt;&#x2F;p&gt;
&lt;p&gt;Back in 2017, someone on Meta asked about &lt;a href=&quot;https:&#x2F;&#x2F;meta.discourse.org&#x2F;t&#x2F;tracking-referrals-encouraging-users-to-invite-others&#x2F;75040&quot;&gt;tracking referrals and
invites&lt;&#x2F;a&gt;.
People wanted to know who was inviting quality members and who was creating
moderation work. The Gamification plugin tracks invite counts, but that&#x27;s
just quantity. What about quality?&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re running an invite-only community, you care about accountability.
Who&#x27;s inviting spam accounts? Who&#x27;s inviting people who get immediately
suspended? Which inviters have consistently good judgment?&lt;&#x2F;p&gt;
&lt;p&gt;The tree visualization was neat, but it wasn&#x27;t answering those questions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-changed&quot;&gt;What Changed&lt;&#x2F;h2&gt;
&lt;p&gt;I added moderation metrics to every user in the tree. Now you see:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Suspended users (🚫)&lt;&#x2F;li&gt;
&lt;li&gt;Silenced users (🔇)&lt;&#x2F;li&gt;
&lt;li&gt;Users with agreed flags (⚠️3)&lt;&#x2F;li&gt;
&lt;li&gt;Quality scores for people who invited others&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Then I built an accountability report that sits above the tree. It shows
inviters who meet certain criteria:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Invited 3 or more problematic users&lt;&#x2F;li&gt;
&lt;li&gt;Have less than 70% invite success rate with 5+ invites&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The tree is still there, but it&#x27;s no longer the main feature. It&#x27;s
supporting evidence for the accountability data.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-rename&quot;&gt;The Rename&lt;&#x2F;h2&gt;
&lt;p&gt;At this point, the plugin wasn&#x27;t really about the tree anymore. It was
about invite statistics and quality tracking. The tree was just one way to
display the data.&lt;&#x2F;p&gt;
&lt;p&gt;So I renamed it: &lt;code&gt;discourse-invite-tree&lt;&#x2F;code&gt; became &lt;code&gt;discourse-invite-stats&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Renaming a plugin mid-development is annoying. You have to update:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Repository name&lt;&#x2F;li&gt;
&lt;li&gt;Plugin metadata&lt;&#x2F;li&gt;
&lt;li&gt;All Ruby class names&lt;&#x2F;li&gt;
&lt;li&gt;All JavaScript component names&lt;&#x2F;li&gt;
&lt;li&gt;All CSS classes&lt;&#x2F;li&gt;
&lt;li&gt;All translation keys&lt;&#x2F;li&gt;
&lt;li&gt;All site setting names&lt;&#x2F;li&gt;
&lt;li&gt;The symlink in your Discourse plugins directory&lt;&#x2F;li&gt;
&lt;li&gt;The remote URL in git&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But the name needs to match what the thing actually does. A plugin called
&quot;invite tree&quot; that&#x27;s primarily focused on moderation accountability is
misleading.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;making-it-configurable&quot;&gt;Making It Configurable&lt;&#x2F;h2&gt;
&lt;p&gt;The hardcoded thresholds (3 bad invites, 70% quality score, 5 minimum
invites) worked for my mental model, but different communities have
different standards.&lt;&#x2F;p&gt;
&lt;p&gt;So I added site settings:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;invite_stats_problematic_threshold&lt;&#x2F;code&gt;: How many bad invites trigger a flag&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;invite_stats_quality_threshold&lt;&#x2F;code&gt;: What percentage is acceptable&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;invite_stats_min_invites_for_quality&lt;&#x2F;code&gt;: Minimum invites before scoring applies&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;invite_stats_flags_threshold&lt;&#x2F;code&gt;: How many agreed flags mark someone as problematic&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I converted the template to a proper Glimmer component class so I could
inject &lt;code&gt;siteSettings&lt;&#x2F;code&gt; and compute the threshold description dynamically. Now
when an admin changes the thresholds in settings, the UI updates to match.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;invite-stats-settings.png&quot; alt=&quot;Admin settings panel showing configurable thresholds&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;No more hardcoded magic numbers. If your community runs strict moderation
and wants to flag anyone with 2 bad invites, you can do that. If you&#x27;re
more lenient and only care about people with 5+ bad invites, that works
too.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;things-to-remember&quot;&gt;Things to Remember&lt;&#x2F;h2&gt;
&lt;p&gt;Scope creep isn&#x27;t always bad. I started with &quot;show me who invited whom&quot; and
ended up with &quot;help me identify problematic inviters.&quot; The tree
visualization is still there, but it&#x27;s not the point anymore.&lt;&#x2F;p&gt;
&lt;p&gt;Sometimes you need to keep iterating. The first version was fine. The
second version is actually useful.&lt;&#x2F;p&gt;
&lt;p&gt;Check it out: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-invite-stats&quot;&gt;discourse-invite-stats on
GitHub&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Visualizing Discourse Invite Trees</title>
          <pubDate>Mon, 03 Nov 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/discourse-invite-tree/</link>
          <guid>https://jakegoldsborough.com/blog/2025/discourse-invite-tree/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/discourse-invite-tree/">&lt;p&gt;I built a small Discourse plugin that shows who invited whom in your
community. It displays the invite relationships as an ASCII tree, similar to
how &lt;a href=&quot;https:&#x2F;&#x2F;lobste.rs&#x2F;users&quot;&gt;Lobsters&lt;&#x2F;a&gt; shows their user invites.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;discourse-invite-tree.png&quot; alt=&quot;Invite tree showing users with ASCII connecting lines&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The tree shows usernames, join dates, and how many people each user has
invited. Users without an inviter (founding members or self-signups) appear at
the root level, with their invite chains nested below.&lt;&#x2F;p&gt;
&lt;p&gt;The plugin works great with Discourse&#x27;s native invite-only mode. If you enable
invite-only registration, the tree visualization lets you see exactly how your
community has grown through invites over time.&lt;&#x2F;p&gt;
&lt;p&gt;Back in 2017, someone on Meta asked about &lt;a href=&quot;https:&#x2F;&#x2F;meta.discourse.org&#x2F;t&#x2F;tracking-referrals-encouraging-users-to-invite-others&#x2F;75040&quot;&gt;tracking referrals and
invites&lt;&#x2F;a&gt;.
People were using Data Explorer with custom SQL queries to see this data. This
plugin makes it visual and accessible at &lt;code&gt;&#x2F;invite-tree&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The implementation is straightforward: a recursive SQL query builds the tree
from Discourse&#x27;s native invite tables, and a Glimmer component renders it with
monospace font and tree characters. The whole thing is about 600 lines.&lt;&#x2F;p&gt;
&lt;p&gt;I kept it minimal and hackable. No complex features, just a clean tree that
adapts to your theme. If you want to customize how it looks or what it shows,
the code is easy to modify.&lt;&#x2F;p&gt;
&lt;p&gt;Check it out: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-invite-tree&quot;&gt;discourse-invite-tree on
GitHub&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Shelltrax Part 2: Footer, Tests, and CI</title>
          <pubDate>Wed, 29 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/shelltrax-part-2-footer-tests-ci/</link>
          <guid>https://jakegoldsborough.com/blog/2025/shelltrax-part-2-footer-tests-ci/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/shelltrax-part-2-footer-tests-ci/">&lt;p&gt;In &lt;a href=&quot;https:&#x2F;&#x2F;jakegoldsborough.com&#x2F;blog&#x2F;2025&#x2F;introducing-shelltrax&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt;, I built the core of shelltrax&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;a TUI music player in Rust. It had library navigation, file browsing,
audio playback, and a footer with song info and status. One big problem though,
the footer would stop updating after the first song and I really wanted to fix
that.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This post covers three improvements: implementing a proper footer with
playback progress, adding unit tests for the tricky bits, and setting up CI
to keep code quality high.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;running-with-a-limp&quot;&gt;Running With A Limp&lt;&#x2F;h2&gt;
&lt;p&gt;The footer existed from early on (progress bar, time display, track info), but
it had a critical bug: when autoplay advanced to the next track, the progress
bar wouldn&#x27;t reset. It would either keep counting from where the previous song left
off, showing wrong times and eventually overflowing past 100% or it would just
reset back to 0 and not progress. Whatever it did, it didn&#x27;t work.&lt;&#x2F;p&gt;
&lt;p&gt;The bug was in &lt;code&gt;play_next_track()&lt;&#x2F;code&gt;. It would set &lt;code&gt;playback_start&lt;&#x2F;code&gt; and
&lt;code&gt;current_track&lt;&#x2F;code&gt;, but it wouldn&#x27;t reset &lt;code&gt;paused_duration&lt;&#x2F;code&gt; or &lt;code&gt;paused_at&lt;&#x2F;code&gt;. If
you paused the first song for 30 seconds, that 30 seconds would carry over to
every subsequent song, throwing off the footer display completely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;consolidation-begin-playback&quot;&gt;Consolidation: begin_playback()&lt;&#x2F;h2&gt;
&lt;p&gt;The solution was extracting the timer reset logic into a dedicated method:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub fn begin_playback(&amp;amp;mut self, track: &amp;amp;LibraryTrack) {
    self.current_track = Some(track.clone());
    self.playback_start = Some(Instant::now());
    self.paused_duration = Duration::ZERO;
    self.paused_at = None;
    self.playback_duration = track.duration.unwrap_or(0);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now &lt;code&gt;play_next_track()&lt;&#x2F;code&gt; calls &lt;code&gt;begin_playback()&lt;&#x2F;code&gt; instead of manually setting
fields. This ensures all timing state resets properly when advancing to the
next song, whether manually or via autoplay.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;implementation-tracking-time-correctly&quot;&gt;Implementation: Tracking Time Correctly&lt;&#x2F;h2&gt;
&lt;p&gt;The app needs to track multiple timing values:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;pub struct App {
    pub playback_start: Option&amp;lt;Instant&amp;gt;,
    pub playback_duration: u64,
    pub paused_at: Option&amp;lt;Instant&amp;gt;,
    pub paused_duration: Duration,
    &amp;#x2F;&amp;#x2F; ... other fields
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When a song starts, we record &lt;code&gt;playback_start&lt;&#x2F;code&gt;. When the user pauses, we
record &lt;code&gt;paused_at&lt;&#x2F;code&gt;. When they unpause, we add the pause duration to
&lt;code&gt;paused_duration&lt;&#x2F;code&gt; and clear &lt;code&gt;paused_at&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The footer calculation looks like this:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;let elapsed = if let Some(paused_at) = app.paused_at {
    paused_at.duration_since(start)
} else {
    now.duration_since(start)
};

let adjusted = elapsed.saturating_sub(app.paused_duration);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If currently paused, elapsed time is frozen at the pause moment. Otherwise,
it&#x27;s the time since playback started. Then we subtract all the accumulated
pause time to get the actual playback position.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;code&gt;saturating_sub&lt;&#x2F;code&gt; is important. Without it, if &lt;code&gt;paused_duration&lt;&#x2F;code&gt; somehow
exceeded &lt;code&gt;elapsed&lt;&#x2F;code&gt; (race condition, clock skew, whatever), you&#x27;d get an
underflow panic. &lt;code&gt;saturating_sub&lt;&#x2F;code&gt; clamps to zero instead.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layout-three-lines-of-footer&quot;&gt;Layout: Three Lines of Footer&lt;&#x2F;h2&gt;
&lt;p&gt;The footer uses a vertical layout with three lines:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;let layout = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
        Constraint::Length(1),  &amp;#x2F;&amp;#x2F; Progress bar
        Constraint::Length(1),  &amp;#x2F;&amp;#x2F; Time display
        Constraint::Length(1),  &amp;#x2F;&amp;#x2F; Track info
    ])
    .split(inner);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Line 1 is a &lt;code&gt;Gauge&lt;&#x2F;code&gt; widget showing the ratio of elapsed to total time. Line 2
shows &lt;code&gt;MM:SS &#x2F; MM:SS&lt;&#x2F;code&gt;. Line 3 shows &lt;code&gt;Artist - Title - Album&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The progress bar ratio:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;let ratio = if total.as_secs_f64() &amp;gt; 0.0 {
    adjusted.as_secs_f64() &amp;#x2F; total.as_secs_f64()
} else {
    0.0
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Clamp it to &lt;code&gt;1.0&lt;&#x2F;code&gt; max so the gauge doesn&#x27;t overflow if the elapsed time
somehow exceeds the track duration (can happen with malformed metadata).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;testing-what-actually-needs-tests&quot;&gt;Testing: What Actually Needs Tests?&lt;&#x2F;h2&gt;
&lt;p&gt;I&#x27;m not a fan of testing UI rendering code. It&#x27;s tedious, fragile, and
doesn&#x27;t catch the bugs that matter. What I do test is the state management
logic that the UI depends on.&lt;&#x2F;p&gt;
&lt;p&gt;For shelltrax, the critical logic is:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Playback state transitions (playing, paused, stopped)&lt;&#x2F;li&gt;
&lt;li&gt;Time tracking during pause&#x2F;unpause cycles&lt;&#x2F;li&gt;
&lt;li&gt;Library state management (artist&#x2F;album hierarchy)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I added two test modules: one in &lt;code&gt;app.rs&lt;&#x2F;code&gt; for playback logic, one in
&lt;code&gt;library.rs&lt;&#x2F;code&gt; for library state.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;testing-playback-state&quot;&gt;Testing Playback State&lt;&#x2F;h3&gt;
&lt;p&gt;Four tests in &lt;code&gt;app.rs&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;test_begin_playback_resets_timers:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[test]
fn test_begin_playback_resets_timers() {
    let mut app = App::new();

    app.playback_start = Some(Instant::now());
    app.paused_duration = Duration::from_secs(10);
    app.paused_at = Some(Instant::now());

    let track = create_test_track(&amp;quot;test&amp;quot;, 180);
    app.begin_playback(&amp;amp;track);

    assert!(app.playback_start.is_some());
    assert_eq!(app.paused_duration, Duration::ZERO);
    assert!(app.paused_at.is_none());
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When starting a new track, all the timing state should reset. If it didn&#x27;t,
the footer would show stale pause data from the previous song.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;test_toggle_pause_accumulates_paused_duration:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[test]
fn test_toggle_pause_accumulates_paused_duration() {
    let mut app = App::new();

    let start = Instant::now();
    app.paused_at = Some(start);
    app.paused_duration = Duration::from_secs(5);

    app.toggle_pause();  &amp;#x2F;&amp;#x2F; Unpause

    std::thread::sleep(Duration::from_millis(100));

    app.toggle_pause();  &amp;#x2F;&amp;#x2F; Pause again

    assert!(app.paused_at.is_none());
    assert!(app.paused_duration &amp;gt; Duration::from_secs(5));
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This test verifies that pausing multiple times accumulates the total paused
duration. The &lt;code&gt;sleep&lt;&#x2F;code&gt; is gross but necessary to test time-based logic without
mocking the clock (which would require dependency injection, which is
overkill for a hobby project).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;testing-library-state&quot;&gt;Testing Library State&lt;&#x2F;h3&gt;
&lt;p&gt;Six tests in &lt;code&gt;library.rs&lt;&#x2F;code&gt; covering the artist&#x2F;album&#x2F;track hierarchy:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;test_add_tracks_creates_structure:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[test]
fn test_add_tracks_creates_structure() {
    let mut lib = LibraryState::new();

    let tracks = vec![
        create_test_track(&amp;quot;Artist A&amp;quot;, &amp;quot;Album 1&amp;quot;, &amp;quot;Track 1&amp;quot;, 1),
        create_test_track(&amp;quot;Artist A&amp;quot;, &amp;quot;Album 1&amp;quot;, &amp;quot;Track 2&amp;quot;, 2),
        create_test_track(&amp;quot;Artist B&amp;quot;, &amp;quot;Album 2&amp;quot;, &amp;quot;Track 3&amp;quot;, 1),
    ];

    lib.add_tracks(tracks);

    assert_eq!(lib.artists.len(), 2);
    assert_eq!(lib.artists[0].name, &amp;quot;Artist A&amp;quot;);
    assert_eq!(lib.artists[0].albums.len(), 1);
    assert_eq!(lib.artists[0].albums[0].tracks.len(), 2);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This validates the library builds the correct tree structure when adding
tracks. If the grouping logic broke, you&#x27;d end up with duplicate artists or
albums in the wrong places.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;test_visible_tracks_for_album:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;rust&quot; class=&quot;language-rust &quot;&gt;&lt;code class=&quot;language-rust&quot; data-lang=&quot;rust&quot;&gt;#[test]
fn test_visible_tracks_for_album() {
    let mut lib = LibraryState::new();

    lib.add_tracks(vec![
        create_test_track(&amp;quot;Artist&amp;quot;, &amp;quot;Album 1&amp;quot;, &amp;quot;Track 1&amp;quot;, 1),
        create_test_track(&amp;quot;Artist&amp;quot;, &amp;quot;Album 1&amp;quot;, &amp;quot;Track 2&amp;quot;, 2),
        create_test_track(&amp;quot;Artist&amp;quot;, &amp;quot;Album 2&amp;quot;, &amp;quot;Track 3&amp;quot;, 1),
    ]);

    lib.selection = Some(LibrarySelection::Album {
        artist_index: 0,
        album_index: 0,
    });
    let tracks = lib.visible_tracks();

    assert_eq!(tracks.len(), 2);
    assert_eq!(tracks[0].title, &amp;quot;Track 1&amp;quot;);
    assert_eq!(tracks[1].title, &amp;quot;Track 2&amp;quot;);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;visible_tracks&lt;&#x2F;code&gt; method returns different results depending on whether
an artist or an album is selected. This test ensures album selection filters
correctly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;ci-keeping-code-quality-high&quot;&gt;CI: Keeping Code Quality High&lt;&#x2F;h2&gt;
&lt;p&gt;GitHub Actions makes CI trivial for Rust projects. The workflow file:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;yaml&quot; class=&quot;language-yaml &quot;&gt;&lt;code class=&quot;language-yaml&quot; data-lang=&quot;yaml&quot;&gt;name: test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions&amp;#x2F;checkout@v4

    - name: install rust
      uses: dtolnay&amp;#x2F;rust-toolchain@stable
      with:
        components: clippy

    - name: cache dependencies
      uses: actions&amp;#x2F;cache@v4
      with:
        path: |
          ~&amp;#x2F;.cargo&amp;#x2F;bin&amp;#x2F;
          ~&amp;#x2F;.cargo&amp;#x2F;registry&amp;#x2F;index&amp;#x2F;
          ~&amp;#x2F;.cargo&amp;#x2F;registry&amp;#x2F;cache&amp;#x2F;
          ~&amp;#x2F;.cargo&amp;#x2F;git&amp;#x2F;db&amp;#x2F;
          target&amp;#x2F;
        key: ${{ runner.os }}-cargo-${{ hashFiles(&amp;#x27;**&amp;#x2F;Cargo.lock&amp;#x27;) }}

    - name: install system dependencies
      run: |
        sudo apt-get update
        sudo apt-get install -y libasound2-dev

    - name: run tests
      run: cargo test --verbose

    - name: run clippy
      run: cargo clippy -- -D warnings
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The important bits:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Dependency caching:&lt;&#x2F;strong&gt; Without caching, every CI run would download and
compile all dependencies from scratch. With caching, subsequent runs reuse
compiled dependencies, dropping build time from several minutes to under 30
seconds.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;System dependencies:&lt;&#x2F;strong&gt; The audio libraries (cpal, rodio) need ALSA headers
to compile. &lt;code&gt;libasound2-dev&lt;&#x2F;code&gt; provides those on Ubuntu.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Clippy with &lt;code&gt;-D warnings&lt;&#x2F;code&gt;:&lt;&#x2F;strong&gt; This flag treats all warnings as errors. It&#x27;s
strict, but it keeps code quality high. If clippy suggests a fix, you either
apply it or add an explicit &lt;code&gt;#[allow(...)]&lt;&#x2F;code&gt; annotation explaining why you&#x27;re
ignoring it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;results&quot;&gt;Results&lt;&#x2F;h2&gt;
&lt;p&gt;The footer works. Tests pass. CI keeps the codebase clean. Shelltrax now
feels like a real music player instead of a tech demo.&lt;&#x2F;p&gt;
&lt;p&gt;Running &lt;code&gt;cargo test&lt;&#x2F;code&gt; shows 10 passing tests:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;running 10 tests
test app::tests::test_begin_playback_resets_timers ... ok
test app::tests::test_toggle_pause_accumulates_paused_duration ... ok
test app::tests::test_toggle_pause_cycles_state ... ok
test app::tests::test_toggle_pause_sets_paused_at ... ok
test library::tests::test_add_tracks_creates_structure ... ok
test library::tests::test_toggle_expanded ... ok
test library::tests::test_track_by_path_finds_track ... ok
test library::tests::test_track_by_path_returns_none_for_missing ... ok
test library::tests::test_visible_tracks_for_album ... ok
test library::tests::test_visible_tracks_for_artist ... ok

test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;And &lt;code&gt;cargo clippy&lt;&#x2F;code&gt; stays green with zero warnings.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next?&lt;&#x2F;h2&gt;
&lt;p&gt;The core functionality is solid, but there are still features I want:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;config system&lt;&#x2F;li&gt;
&lt;li&gt;seeking&lt;&#x2F;li&gt;
&lt;li&gt;better metadata handling&lt;&#x2F;li&gt;
&lt;li&gt;vi keybindings&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But for now, shelltrax does what I needed it to do: play music in the
terminal with a proper UI that shows what&#x27;s happening.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Code&lt;&#x2F;strong&gt;: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;github.com&#x2F;ducks&#x2F;shelltrax&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Replacing MessageBus Pub&#x2F;Sub with REST APIs: Fixing Production Outages</title>
          <pubDate>Mon, 27 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/replacing-messagebus-with-http-apis/</link>
          <guid>https://jakegoldsborough.com/blog/2025/replacing-messagebus-with-http-apis/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/replacing-messagebus-with-http-apis/">&lt;p&gt;We&#x27;re converting one of our infrastructure services from MessageBus pub&#x2F;sub to
a synchronous REST API. This isn&#x27;t a protocol change or upgrade - both
architectures use HTTP. This is about replacing fire-and-forget asynchronous
messaging with request&#x2F;response patterns that provide immediate feedback.&lt;&#x2F;p&gt;
&lt;p&gt;The control plane manages hundreds of Discourse forums across multiple data
centers. It needs to orchestrate PostgreSQL user and database creation across
these distributed clusters.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h2&gt;
&lt;p&gt;Our &lt;code&gt;postgres-manager&lt;&#x2F;code&gt; service handles PostgreSQL user and database lifecycle
for Discourse hosted sites. It was built using &lt;code&gt;ServiceSkeleton&lt;&#x2F;code&gt;, a Ruby
framework for message-driven services, subscribing to MessageBus channels for
commands.&lt;&#x2F;p&gt;
&lt;p&gt;MessageBus uses HTTP long-polling for pub&#x2F;sub messaging. Publishers send
messages to channels, subscribers open long-lived HTTP connections and wait for
messages to arrive. It works well for many use cases, but we&#x27;ve hit two
critical problems:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;No feedback mechanism. When the control plane published a message like
&quot;create database for site X&quot;, it had no way to know if the operation succeeded
or failed. The message was sent and forgotten.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Message replay on restarts. MessageBus keeps a backlog of messages. When
postgres-manager restarts, it replays unacknowledged messages, causing duplicate
operations. We&#x27;ve had multiple production outages from databases being recreated
or users being decommissioned twice.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The message replay issue is the immediate trigger, but the lack of feedback is
the underlying architectural problem. We need synchronous request&#x2F;response
patterns with immediate success&#x2F;failure feedback.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-solution-rest-api-with-request-response&quot;&gt;The Solution: REST API with Request&#x2F;Response&lt;&#x2F;h2&gt;
&lt;p&gt;We&#x27;re converting postgres-manager to a Sinatra HTTP service that responds
synchronously to requests. Instead of subscribing to MessageBus channels, it
will expose REST endpoints:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST &#x2F;databases&lt;&#x2F;code&gt; - Create database and user&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;DELETE &#x2F;databases&#x2F;:username&lt;&#x2F;code&gt; - Decommission database&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;POST &#x2F;users&lt;&#x2F;code&gt; - Create user (triggers sync)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;DELETE &#x2F;users&#x2F;:username&lt;&#x2F;code&gt; - Decommission user&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;POST &#x2F;sync&lt;&#x2F;code&gt; - Sync all databases&#x2F;users from control plane&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Each endpoint will return an immediate response with proper HTTP status codes:
200 for success, 401 for auth failures, 400 for bad parameters, 500 for server
errors.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;implementation&quot;&gt;Implementation&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;before-serviceskeleton-with-messagebus&quot;&gt;Before: ServiceSkeleton with MessageBus&lt;&#x2F;h3&gt;
&lt;p&gt;The old implementation used ServiceSkeleton to subscribe to MessageBus:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;message_bus = MessageBus::HTTPClient.new(
  config.control_plane_base_url,
  headers: { &amp;quot;Discourse-Access-Token&amp;quot; =&amp;gt; config.control_plane_token }
)

message_bus.subscribe(config.message_bus_channel) do |message|
  case message[&amp;quot;type&amp;quot;]
  when &amp;quot;create_user&amp;quot;, &amp;quot;create_db&amp;quot;, &amp;quot;sync&amp;quot;
    sync
  when &amp;quot;decommission_db&amp;quot;
    decommission_db(message[&amp;quot;dbname&amp;quot;])
  when &amp;quot;decommission_user&amp;quot;
    decommission_user(message[&amp;quot;username&amp;quot;])
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The control plane would publish messages:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;PostgresManagerPublisher.create_db(owner, cluster_name)
# Fire and forget - no response
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;after-sinatra-http-service&quot;&gt;After: Sinatra HTTP Service&lt;&#x2F;h3&gt;
&lt;p&gt;The new implementation uses Sinatra with proper HTTP patterns:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;before do
  return if [&amp;quot;&amp;#x2F;health&amp;quot;, &amp;quot;&amp;#x2F;metrics&amp;quot;].include?(request.path_info)

  auth_header = request.env[&amp;quot;HTTP_AUTHORIZATION&amp;quot;]
  if !auth_header || !auth_header.start_with?(&amp;quot;Bearer &amp;quot;)
    halt 401, json(error: &amp;quot;Unauthorized&amp;quot;, code: &amp;quot;AUTH_REQUIRED&amp;quot;)
  end

  token = auth_header.sub(&amp;quot;Bearer &amp;quot;, &amp;quot;&amp;quot;)
  if token != config[:api_key]
    halt 401, json(error: &amp;quot;Unauthorized&amp;quot;, code: &amp;quot;INVALID_TOKEN&amp;quot;)
  end
end

post &amp;quot;&amp;#x2F;databases&amp;quot; do
  body = parse_json_body
  cluster = body[&amp;quot;cluster&amp;quot;]
  username = body[&amp;quot;username&amp;quot;]

  if cluster != config[:container_name]
    halt 400, json(
      error: &amp;quot;Cluster mismatch&amp;quot;,
      code: &amp;quot;CLUSTER_MISMATCH&amp;quot;,
      expected: config[:container_name],
      received: cluster
    )
  end

  sync

  json(
    status: &amp;quot;success&amp;quot;,
    message: &amp;quot;Database sync completed&amp;quot;,
    cluster: cluster
  )
end

delete &amp;quot;&amp;#x2F;databases&amp;#x2F;:username&amp;quot; do
  username = params[&amp;quot;username&amp;quot;]
  decommission_db(username)

  json(
    status: &amp;quot;success&amp;quot;,
    message: &amp;quot;Database decommissioned&amp;quot;,
    username: username
  )
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The control plane will make HTTP requests and get immediate responses:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;PostgresManagerHttpClient.create_db(owner, cluster_name)
# Returns { &amp;quot;status&amp;quot; =&amp;gt; &amp;quot;success&amp;quot;, &amp;quot;message&amp;quot; =&amp;gt; &amp;quot;...&amp;quot; }
# Or raises PostgresManagerError on failure
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;http-client-with-proper-error-handling&quot;&gt;HTTP Client with Proper Error Handling&lt;&#x2F;h2&gt;
&lt;p&gt;The new HTTP client provides clear error handling:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class PostgresManagerHttpClient
  class PostgresManagerError &amp;lt; StandardError; end
  class PostgresManagerTimeout &amp;lt; PostgresManagerError; end
  class PostgresManagerAuthError &amp;lt; PostgresManagerError; end

  def self.create_db(owner, cluster_name)
    call_postgres_manager(
      cluster_name: cluster_name,
      method: :post,
      path: &amp;quot;&amp;#x2F;databases&amp;quot;,
      body: { cluster: cluster_name, username: owner, action: &amp;quot;create&amp;quot; }
    )
  end

  private

  def self.call_postgres_manager(cluster_name:, method:, path:, body: nil)
    api_key = ENV[&amp;quot;POSTGRES_MANAGER_API_KEY&amp;quot;]
    raise &amp;quot;POSTGRES_MANAGER_API_KEY not configured&amp;quot; if !api_key

    base_url = ENV[&amp;quot;POSTGRES_MANAGER_BASE_URL&amp;quot;] ||
               &amp;quot;http:&amp;#x2F;&amp;#x2F;postgres-manager-#{cluster_name}:9105&amp;quot;

    uri = URI(&amp;quot;#{base_url}#{path}&amp;quot;)

    http = Net::HTTP.new(uri.host, uri.port)
    http.open_timeout = 5
    http.read_timeout = 30

    request = case method
    when :post then Net::HTTP::Post.new(uri)
    when :delete then Net::HTTP::Delete.new(uri)
    end

    request[&amp;quot;Authorization&amp;quot;] = &amp;quot;Bearer #{api_key}&amp;quot;
    request[&amp;quot;Content-Type&amp;quot;] = &amp;quot;application&amp;#x2F;json&amp;quot;
    request.body = body.to_json if body

    begin
      response = http.request(request)

      case response.code.to_i
      when 200..299
        return nil if response.body.to_s.empty?
        JSON.parse(response.body)
      when 401
        raise PostgresManagerAuthError,
              &amp;quot;Authentication failed: #{response.body}&amp;quot;
      when 400..499
        raise PostgresManagerError,
              &amp;quot;Client error (#{response.code}): #{response.body}&amp;quot;
      when 500..599
        raise PostgresManagerError,
              &amp;quot;Server error (#{response.code}): #{response.body}&amp;quot;
      end
    rescue Net::OpenTimeout, Net::ReadTimeout
      raise PostgresManagerTimeout,
            &amp;quot;Request timed out for #{uri}&amp;quot;
    rescue Errno::ECONNREFUSED
      raise PostgresManagerError,
            &amp;quot;Connection refused to #{uri}&amp;quot;
    end
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;testing-strategy-unit-and-integration-tests&quot;&gt;Testing Strategy: Unit and Integration Tests&lt;&#x2F;h2&gt;
&lt;p&gt;The HTTP service has a test suite with 54 tests covering both unit and
integration testing:&lt;&#x2F;p&gt;
&lt;h3 id=&quot;unit-tests-16-tests-no-postgresql-required&quot;&gt;Unit Tests (16 tests, no PostgreSQL required)&lt;&#x2F;h3&gt;
&lt;p&gt;Unit tests use WebMock to stub external dependencies:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;it &amp;quot;makes POST request to &amp;#x2F;databases with correct parameters&amp;quot; do
  stub_request(:post, &amp;quot;#{base_url}&amp;#x2F;databases&amp;quot;)
    .with(
      body: { cluster: &amp;quot;flex001&amp;quot;, username: &amp;quot;example&amp;quot;,
              action: &amp;quot;create&amp;quot; }.to_json,
      headers: { &amp;quot;Authorization&amp;quot; =&amp;gt; &amp;quot;Bearer test-api-key&amp;quot; }
    )
    .to_return(
      status: 200,
      body: { status: &amp;quot;success&amp;quot;, message: &amp;quot;Database sync completed&amp;quot; }.to_json
    )

  result = PostgresManagerHttpClient.create_db(&amp;quot;example&amp;quot;, &amp;quot;flex001&amp;quot;)
  expect(result[&amp;quot;status&amp;quot;]).to eq(&amp;quot;success&amp;quot;)
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;These tests run fast (under 1 second) and validate HTTP client logic, metrics
tracking, and configuration handling without requiring PostgreSQL.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;integration-tests-38-tests-with-real-postgresql&quot;&gt;Integration Tests (38 tests, with real PostgreSQL)&lt;&#x2F;h3&gt;
&lt;p&gt;Integration tests use Docker to automatically start&#x2F;stop PostgreSQL and test
real database operations:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;it &amp;#x27;creates database and user&amp;#x27; do
  multisite_config = {
    &amp;quot;example&amp;quot; =&amp;gt; {
      &amp;quot;username&amp;quot; =&amp;gt; &amp;quot;example&amp;quot;,
      &amp;quot;password&amp;quot; =&amp;gt; &amp;quot;test123&amp;quot;,
      &amp;quot;database&amp;quot; =&amp;gt; &amp;quot;example_discourse&amp;quot;
    }
  }

  stub_request(:get, &amp;quot;http:&amp;#x2F;&amp;#x2F;mothership.test&amp;#x2F;api&amp;#x2F;multisite_config?container_name=test-cluster&amp;quot;)
    .to_return(status: 200, body: JSON.dump(multisite_config))

  result = sync.perform

  # Verify in actual PostgreSQL
  sync.database.with_db(&amp;#x27;postgres&amp;#x27;) do |db|
    user_result = db.exec_params(&amp;#x27;SELECT usename FROM pg_user WHERE usename = $1&amp;#x27;, [&amp;#x27;example&amp;#x27;])
    expect(user_result.ntuples).to eq(1)

    db_result = db.exec_params(&amp;#x27;SELECT datname FROM pg_database WHERE datname = $1&amp;#x27;, [&amp;#x27;example_discourse&amp;#x27;])
    expect(db_result.ntuples).to eq(1)
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;idempotency-tests&quot;&gt;Idempotency Tests&lt;&#x2F;h3&gt;
&lt;p&gt;Critical tests verify the service handles message replay scenarios that caused
production outages:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;it &amp;#x27;handles rapid duplicate creates (race condition simulation)&amp;#x27; do
  threads = 2.times.map do
    Thread.new do
      begin
        database.create(test_db, test_user)
      rescue PG::DuplicateDatabase
        # Expected - one thread wins, other gets duplicate error
      end
    end
  end
  threads.each(&amp;amp;:join)

  # Verify only one database created
  database.with_db(&amp;#x27;postgres&amp;#x27;) do |db|
    result = db.exec_params(&amp;#x27;SELECT datname FROM pg_database WHERE datname = $1&amp;#x27;, [test_db])
    expect(result.ntuples).to eq(1)
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;These tests validate that calling create operations multiple times (as happens
during message replay) doesn&#x27;t cause crashes or duplicate resources.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;migration-strategy&quot;&gt;Migration Strategy&lt;&#x2F;h2&gt;
&lt;p&gt;We can&#x27;t switch all clusters at once. The migration strategy:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Deploy HTTP version alongside MessageBus - Run both in separate
containers&lt;&#x2F;li&gt;
&lt;li&gt;Test HTTP version manually - Verify endpoints work, check metrics&lt;&#x2F;li&gt;
&lt;li&gt;Switch control plane to HTTP - Deploy control plane changes, monitor logs&lt;&#x2F;li&gt;
&lt;li&gt;Clean up MessageBus - Stop old containers, remove environment variables&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Both services can run simultaneously during migration, and rollback is just
reverting the control plane code and restarting services.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;expected-results&quot;&gt;Expected Results&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;No more duplicate operations from message replay&lt;&#x2F;li&gt;
&lt;li&gt;Immediate visibility into success&#x2F;failure&lt;&#x2F;li&gt;
&lt;li&gt;Proper error handling with retries&lt;&#x2F;li&gt;
&lt;li&gt;Easy to test with curl or standard HTTP tools&lt;&#x2F;li&gt;
&lt;li&gt;Can be load balanced through standard proxies&lt;&#x2F;li&gt;
&lt;li&gt;Standard HTTP monitoring and metrics&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;outcomes&quot;&gt;Outcomes&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Fire-and-forget messaging has hidden costs. The lack of feedback makes
debugging production issues extremely difficult. We don&#x27;t know if operations
fail until customers complain.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Message replay is a feature until it&#x27;s a bug. MessageBus&#x27;s backlog replay is
useful for reliable message delivery, but causes havoc when operations aren&#x27;t
idempotent.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Different HTTP patterns, same protocol. Both MessageBus (HTTP long-polling)
and our REST API use HTTP for transport, but serve fundamentally different
communication patterns. MessageBus uses HTTP to implement pub&#x2F;sub messaging
(asynchronous, one-to-many), while REST implements request&#x2F;response
(synchronous, one-to-one). The choice isn&#x27;t HTTP vs something else - it&#x27;s
choosing the right messaging pattern for your use case.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Testing gets easier with simpler patterns. The old MessageBus setup requires
a full environment. The HTTP version can be tested with simple request&#x2F;response
stubs.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h2&gt;
&lt;p&gt;This migration will take our infrastructure service from fire-and-forget
messaging to synchronous request&#x2F;response patterns. The expected result: no more
duplicate operations, immediate feedback on success&#x2F;failure, and much simpler
debugging. Sometimes the solution isn&#x27;t choosing new technology - it&#x27;s choosing
the right pattern for your use case.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;current-status&quot;&gt;Current Status&lt;&#x2F;h2&gt;
&lt;p&gt;The HTTP service implementation is complete and tested:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Modular architecture with 6 separated modules (Config, Database, User,
Client, Sync, Metrics)&lt;&#x2F;li&gt;
&lt;li&gt;Test suite (16 unit tests, 38 integration, all passing)&lt;&#x2F;li&gt;
&lt;li&gt;RuboCop clean (zero offenses)&lt;&#x2F;li&gt;
&lt;li&gt;Security fixes (parameterized queries, retry limits)&lt;&#x2F;li&gt;
&lt;li&gt;GitHub Actions CI configured and passing&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The service is production-ready and awaiting deployment to test clusters. Once
validated with real PostgreSQL operations and monitored for stability, we&#x27;ll
proceed with gradual rollout to production infrastructure.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building The Guest Spot: Part 2 - Two Refactors</title>
          <pubDate>Sun, 26 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-the-guest-spot-part-2/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-the-guest-spot-part-2/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-the-guest-spot-part-2/">&lt;p&gt;In &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-the-guest-spot-part-1&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt;, I built an Instagram-style showcase using a custom &lt;code&gt;GuestSpotPost&lt;&#x2F;code&gt; model. It worked. But it also meant maintaining a parallel data structure instead of leveraging what Discourse already provides.&lt;&#x2F;p&gt;
&lt;p&gt;This post covers two major refactors: first from custom models to Discourse Topics, then from custom feed infrastructure to plugin outlets. Each time, less code gave me more features.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-refactor&quot;&gt;Why Refactor?&lt;&#x2F;h2&gt;
&lt;p&gt;The custom model approach had problems:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Duplicate logic: Comments, moderation, spam protection already exist for Topics&lt;&#x2F;li&gt;
&lt;li&gt;Extra maintenance: Every time Discourse updates, potential breakage&lt;&#x2F;li&gt;
&lt;li&gt;Missing features: No native file upload, no revision history, no trust levels&lt;&#x2F;li&gt;
&lt;li&gt;More code: Separate serializers, separate permissions, separate everything&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Discourse Topics already have everything we need:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;First post holds the caption and images&lt;&#x2F;li&gt;
&lt;li&gt;Title can be auto-generated&lt;&#x2F;li&gt;
&lt;li&gt;Categories handle public vs private&lt;&#x2F;li&gt;
&lt;li&gt;Pinning is built-in&lt;&#x2F;li&gt;
&lt;li&gt;Commenting works out of the box&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The showcase is just a custom view of Topics in the &quot;Public Feed&quot; category.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-refactor&quot;&gt;The Refactor&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;backend-changes&quot;&gt;Backend Changes&lt;&#x2F;h3&gt;
&lt;p&gt;Deleted the entire &lt;code&gt;GuestSpotPost&lt;&#x2F;code&gt; model and migrations. Instead, the controller now works with Topics:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# app&amp;#x2F;controllers&amp;#x2F;guest_spot&amp;#x2F;posts_controller.rb
def index
  category_id = CategoryHelper.public_feed_category_id

  topics = Topic
    .where(category_id: category_id)
    .where(deleted_at: nil)
    .includes(:user, posts: :uploads)
    .order(created_at: :desc)
    .limit(50)

  pinned_topics = Topic
    .where(category_id: category_id)
    .where(deleted_at: nil)
    .where.not(pinned_at: nil)
    .includes(:user, posts: :uploads)
    .order(created_at: :desc)

  render json: {
    posts: serialize_data(topics, GuestSpotPostSerializer),
    pinned: serialize_data(pinned_topics, GuestSpotPostSerializer)
  }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The serializer extracts what we need from Topics:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# app&amp;#x2F;serializers&amp;#x2F;guest_spot_post_serializer.rb
class GuestSpotPostSerializer &amp;lt; ApplicationSerializer
  attributes :id, :user_id, :username, :caption, :image_urls, :created_at, :pinned

  def caption
    object.first_post&amp;amp;.raw || &amp;quot;&amp;quot;
  end

  def image_urls
    object.first_post.uploads.map { |upload| UrlHelper.absolute(upload.url) }
  end

  def pinned
    object.pinned_at.present?
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Caption is the post content. Images come from uploads. Pinned status is the native &lt;code&gt;pinned_at&lt;&#x2F;code&gt; field.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;creating-posts&quot;&gt;Creating Posts&lt;&#x2F;h3&gt;
&lt;p&gt;Instead of &lt;code&gt;GuestSpotPost.create&lt;&#x2F;code&gt;, we use &lt;code&gt;TopicCreator&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def create
  category_id = CategoryHelper.public_feed_category_id
  title = &amp;quot;@#{current_user.username} - #{Time.now.to_i}&amp;quot;

  topic_creator = TopicCreator.new(
    current_user,
    Guardian.new(current_user),
    category: category_id,
    title: title,
    raw: params[:caption] || &amp;quot;&amp;quot;
  )

  topic = topic_creator.create
  render_serialized(topic, GuestSpotPostSerializer)
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Auto-generated titles keep them unique. The caption goes in the first post&#x27;s raw content. Image uploads attach automatically through Discourse&#x27;s existing upload system.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;frontend-changes&quot;&gt;Frontend Changes&lt;&#x2F;h3&gt;
&lt;p&gt;Frontend barely changed. The serializer provides the same JSON structure, so components worked as-is. The only update was changing &lt;code&gt;@post.id&lt;&#x2F;code&gt; to reference topic IDs instead of custom model IDs.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-this-got-us&quot;&gt;What This Got Us&lt;&#x2F;h3&gt;
&lt;p&gt;The refactor from custom models to Topics gave us:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Comments work natively (no custom implementation)&lt;&#x2F;li&gt;
&lt;li&gt;Moderation tools work (flags, hiding, deleting)&lt;&#x2F;li&gt;
&lt;li&gt;File uploads work (native uploader)&lt;&#x2F;li&gt;
&lt;li&gt;Revision history works (edit tracking)&lt;&#x2F;li&gt;
&lt;li&gt;Trust levels work (spam protection)&lt;&#x2F;li&gt;
&lt;li&gt;All for free, by using what Discourse already provides&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But I still had a custom feed with its own routes, controllers, and serializers. That was the next problem to solve.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;going-native-deleting-the-custom-feed&quot;&gt;Going Native: Deleting the Custom Feed&lt;&#x2F;h2&gt;
&lt;p&gt;In Part 1, I built a custom &lt;code&gt;GuestSpotPost&lt;&#x2F;code&gt; model. Then I realized that was
overkill and refactored to use Discourse Topics. But I kept the custom feed
with its own routes, controllers, and serializers.&lt;&#x2F;p&gt;
&lt;p&gt;After implementing that custom feed, I realized there was an uncomfortable
disconnect. Users would browse an Instagram-style feed, click a post, and
suddenly land in Discourse&#x27;s standard topic view. The context switch was jarring.&lt;&#x2F;p&gt;
&lt;p&gt;The question became: why maintain a custom feed at all? If the custom model
was unnecessary, maybe the custom feed was too.&lt;&#x2F;p&gt;
&lt;p&gt;Discourse already has everything we need:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Category pages show topic lists&lt;&#x2F;li&gt;
&lt;li&gt;Category permissions control who can post and who can view&lt;&#x2F;li&gt;
&lt;li&gt;Native pinning highlights featured content&lt;&#x2F;li&gt;
&lt;li&gt;Theme system allows extensive visual customization&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Instead of maintaining a parallel feed system, we could just make the Public
Feed category look great using plugin outlets.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-deletion&quot;&gt;The Deletion&lt;&#x2F;h3&gt;
&lt;p&gt;I deleted the entire custom feed infrastructure:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Removed (1,078 lines)&lt;&#x2F;strong&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Custom feed routes and controllers (&lt;code&gt;guest_spot&#x2F;feed_controller.rb&lt;&#x2F;code&gt;,
&lt;code&gt;guest_spot&#x2F;posts_controller.rb&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Custom post serializer (&lt;code&gt;guest_spot_post_serializer.rb&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;All custom Ember components (&lt;code&gt;guest-spot-feed.gjs&lt;&#x2F;code&gt;,
&lt;code&gt;guest-spot-post-card.gjs&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Custom route definitions and templates&lt;&#x2F;li&gt;
&lt;li&gt;Multiple initializers for hiding UI elements&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Added (264 lines)&lt;&#x2F;strong&gt;:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Single plugin outlet connector:
&lt;code&gt;assets&#x2F;javascripts&#x2F;discourse&#x2F;connectors&#x2F;topic-list-item&#x2F;guest-spot-item.gjs&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;CSS Grid layout for responsive cards&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Net result: 814 fewer lines of code.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;plugin-outlets-the-right-pattern&quot;&gt;Plugin Outlets: The Right Pattern&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse provides plugin outlets - extension points where plugins can inject
custom HTML. There are two types:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Regular outlets&lt;&#x2F;strong&gt; inject content but Discourse still renders the default
elements. &lt;strong&gt;Wrapper outlets&lt;&#x2F;strong&gt; completely replace the template.&lt;&#x2F;p&gt;
&lt;p&gt;I used the &lt;code&gt;topic-list-item&lt;&#x2F;code&gt; wrapper outlet to completely replace how topics
display in the Public Feed category:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;import Component from &amp;quot;@glimmer&amp;#x2F;component&amp;quot;;
import avatar from &amp;quot;discourse&amp;#x2F;helpers&amp;#x2F;avatar&amp;quot;;
import replaceEmoji from &amp;quot;discourse&amp;#x2F;helpers&amp;#x2F;replace-emoji&amp;quot;;
import formatDate from &amp;quot;discourse&amp;#x2F;helpers&amp;#x2F;format-date&amp;quot;;

export default class GuestSpotItem extends Component {
  get isPublicFeed() {
    return this.args.outletArgs?.topic?.category?.slug === &amp;quot;public-feed&amp;quot;;
  }

  get truncatedExcerpt() {
    const excerpt = this.args.outletArgs?.topic?.excerpt || &amp;quot;&amp;quot;;
    if (excerpt.length &amp;lt;= 50) {
      return excerpt;
    }
    return excerpt.substring(0, 50) + &amp;quot;...&amp;quot;;
  }

  &amp;lt;template&amp;gt;
    {{#if this.isPublicFeed}}
      &amp;lt;td class=&amp;quot;topic-list-data guest-spot-card&amp;quot;&amp;gt;
        &amp;lt;div class=&amp;quot;guest-spot-author&amp;quot;&amp;gt;
          &amp;lt;a href=&amp;quot;&amp;#x2F;u&amp;#x2F;{{this.args.outletArgs.topic.creator.username}}&amp;quot;&amp;gt;
            {{avatar this.args.outletArgs.topic.creator imageSize=&amp;quot;medium&amp;quot;}}
            &amp;lt;span class=&amp;quot;username&amp;quot;&amp;gt;
              {{this.args.outletArgs.topic.creator.username}}
            &amp;lt;&amp;#x2F;span&amp;gt;
          &amp;lt;&amp;#x2F;a&amp;gt;
        &amp;lt;&amp;#x2F;div&amp;gt;

        &amp;lt;div class=&amp;quot;guest-spot-image&amp;quot;&amp;gt;
          &amp;lt;a href={{this.args.outletArgs.topic.url}}&amp;gt;
            &amp;lt;img src={{this.args.outletArgs.topic.image_url}} alt=&amp;quot;&amp;quot; &amp;#x2F;&amp;gt;
          &amp;lt;&amp;#x2F;a&amp;gt;
        &amp;lt;&amp;#x2F;div&amp;gt;

        &amp;lt;div class=&amp;quot;guest-spot-excerpt&amp;quot;&amp;gt;
          {{replaceEmoji this.truncatedExcerpt}}
        &amp;lt;&amp;#x2F;div&amp;gt;

        &amp;lt;div class=&amp;quot;guest-spot-metadata&amp;quot;&amp;gt;
          &amp;lt;div class=&amp;quot;meta-item&amp;quot;&amp;gt;
            Views: {{this.args.outletArgs.topic.views}}
          &amp;lt;&amp;#x2F;div&amp;gt;
          &amp;lt;div class=&amp;quot;meta-item&amp;quot;&amp;gt;
            Replies: {{this.args.outletArgs.topic.posts_count}}
          &amp;lt;&amp;#x2F;div&amp;gt;
          &amp;lt;div class=&amp;quot;meta-item&amp;quot;&amp;gt;
            Posted: {{formatDate this.args.outletArgs.topic.createdAt
            leaveAgo=true}}
          &amp;lt;&amp;#x2F;div&amp;gt;
        &amp;lt;&amp;#x2F;div&amp;gt;
      &amp;lt;&amp;#x2F;td&amp;gt;
    {{else}}
      {{@default}}
    {{&amp;#x2F;if}}
  &amp;lt;&amp;#x2F;template&amp;gt;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The component checks if we&#x27;re in the public-feed category. If yes, render the
custom card layout. If no, render the default (&lt;code&gt;{{@default}}&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;css-grid-for-responsive-layout&quot;&gt;CSS Grid for Responsive Layout&lt;&#x2F;h3&gt;
&lt;p&gt;Instead of JavaScript handling the layout, CSS Grid does all the work:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;scss&quot; class=&quot;language-scss &quot;&gt;&lt;code class=&quot;language-scss&quot; data-lang=&quot;scss&quot;&gt;.category-public-feed {
  .topic-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 1.5rem;

    @media (max-width: 480px) {
      grid-template-columns: 1fr;
    }

    tr.topic-list-item {
      display: flex;
      flex-direction: column;
      border-radius: 8px;
      overflow: hidden;
      background: var(--primary-very-low);
      transition: transform 0.2s;

      &amp;amp;:hover {
        transform: translateY(-2px);
      }
    }
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On mobile, it collapses to a single column. On tablet and desktop, it flows
naturally based on available space.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;guest-spot-mobile-3.png&quot; alt=&quot;Mobile view of The Guest Spot showing a nature image from Tattoo Mike&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;guest-spot-feed-1.png&quot; alt=&quot;Desktop view of The Guest Spot showing a grid of tattoo pictures&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;discovery-process&quot;&gt;Discovery Process&lt;&#x2F;h3&gt;
&lt;p&gt;Finding the right plugin outlet took some research. I used &lt;code&gt;rg&lt;&#x2F;code&gt; to search
Discourse core for available outlets:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;rg &amp;quot;PluginOutlet&amp;quot; app&amp;#x2F;assets&amp;#x2F;javascripts&amp;#x2F;discourse&amp;#x2F;app&amp;#x2F;components&amp;#x2F; \
  | grep topic-list
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Found several candidates:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;topic-list-before-link&lt;&#x2F;code&gt; - Injects before the title link (still renders
default content)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;topic-list-after-title&lt;&#x2F;code&gt; - Injects after the title (still renders default
content)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;topic-list-item&lt;&#x2F;code&gt; - Wrapper outlet (replaces entire template)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The wrapper outlet was key. Regular outlets would have shown both my custom
card and the default topic row, creating duplicate content.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-works-now&quot;&gt;What Works Now&lt;&#x2F;h3&gt;
&lt;p&gt;The native approach gives us:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Comments work out of the box (no custom implementation needed)&lt;&#x2F;li&gt;
&lt;li&gt;All Discourse features work (moderation, flags, bookmarks, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;814 fewer lines of code to maintain&lt;&#x2F;li&gt;
&lt;li&gt;Better mobile support (Grid automatically adapts)&lt;&#x2F;li&gt;
&lt;li&gt;No custom API (no serializers, controllers, or routes)&lt;&#x2F;li&gt;
&lt;li&gt;Pin&#x2F;unpin still works (using Discourse&#x27;s native pinning)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The showcase is now just &quot;a really nicely styled Discourse category&quot; instead
of &quot;a custom app built on top of Discourse.&quot;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;vibe-coding-pros-and-cons&quot;&gt;Vibe Coding: Pros and Cons&lt;&#x2F;h2&gt;
&lt;p&gt;This project is a perfect example of &quot;vibe coding&quot; - building something by
feel, iterating quickly, and learning what works through trial and error.&lt;&#x2F;p&gt;
&lt;p&gt;I started with a custom model because that felt right. Then I realized Topics
already did everything I needed. I built a custom feed because I wanted full
control. Then I realized plugin outlets gave me that control without the
maintenance burden.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The downside&lt;&#x2F;strong&gt;: I went too fast and tried too much. Each iteration meant
throwing away code. The custom model, the custom feed infrastructure - all
that work ended up deleted. If I&#x27;d researched Discourse patterns first, I
could have gone straight to the plugin outlet approach.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The upside&lt;&#x2F;strong&gt;: I learned way more by doing it wrong first. I understand why
wrapper outlets exist, because I felt the pain of duplicate content with
regular outlets. I understand why Discourse&#x27;s native features are powerful,
because I tried to rebuild them and saw how much work that is.&lt;&#x2F;p&gt;
&lt;p&gt;Fast iteration meant I could course-correct. I wasn&#x27;t six months into building
a custom ORM before realizing Topics existed. I was a few days in, so
refactoring didn&#x27;t hurt. The velocity of vibe coding let me try ideas, see
them fail, and pivot quickly.&lt;&#x2F;p&gt;
&lt;p&gt;The final result is simple and maintainable. It took three iterations to get
there, but each iteration taught me something. Now I know how to not do things,
which is just as valuable as knowing how to do them.&lt;&#x2F;p&gt;
&lt;p&gt;The code is on GitHub: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-guest-spot&quot;&gt;discourse-guest-spot&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building The Guest Spot: Part 1 - My First Community</title>
          <pubDate>Sat, 25 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-the-guest-spot-part-1/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-the-guest-spot-part-1/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-the-guest-spot-part-1/">&lt;p&gt;I&#x27;m not exactly a social butterfly. I can enjoy small groups of like minded people
talking about our interests but small talk really drains me. I&#x27;m much happier
with a computer, book, or just sitting out in the woods.&lt;&#x2F;p&gt;
&lt;p&gt;Since joining Discourse a few months ago, I&#x27;ve been learning the platform by
building weird experiments. A transit departure board. A virtual currency
system. Tools that push Discourse in directions it wasn&#x27;t designed for. But
they&#x27;ve all been technical exercises. Solo projects. Learning by doing, but
doing alone.&lt;&#x2F;p&gt;
&lt;p&gt;The Guest Spot is different. This is my first time helping build something for
an actual community of people. Real artists who will (hopefully) use this
thing. Not just me poking at APIs and seeing what breaks.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s exciting. And a little terrifying.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-the-idea-evolved&quot;&gt;How The Idea Evolved&lt;&#x2F;h2&gt;
&lt;p&gt;The original concept was simple: a private forum for tattoo artists. A place
where people in the industry could talk shop, share techniques, discuss
challenges. No clients. No tourists. Just professionals.&lt;&#x2F;p&gt;
&lt;p&gt;But then I started thinking about how artists actually share their work.
Instagram. Almost every tattoo artist is on Instagram. It&#x27;s where they build
their portfolio, attract clients, establish their style. But they don&#x27;t own any
of it. The algorithm decides who sees their posts. Instagram decides what gets
buried or promoted. If Instagram changes the rules or shuts down tomorrow, years
of work disappears.&lt;&#x2F;p&gt;
&lt;p&gt;What if artists could have both? A professional forum for discussion and a
public showcase for their work. Same platform, same data, but they own it all.
No algorithm deciding what gets seen. No corporate platform that could pull the
rug out.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s when the concept clicked: build the Instagram-style feed as the front
door, with the private forum as the main room. Artists get a beautiful public
portfolio plus a real community space. All on infrastructure they control.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-the-guest-spot&quot;&gt;What Is The Guest Spot?&lt;&#x2F;h2&gt;
&lt;p&gt;The result: an Instagram-style public showcase for tattoo artists, powered by
Discourse.&lt;&#x2F;p&gt;
&lt;p&gt;Artists can post photos of their work in a clean, visual feed. Pinned posts
appear in a horizontal carousel at the top. Recent work flows down below in a
responsive grid. Click any image and you get a full detail page with all the
context.&lt;&#x2F;p&gt;
&lt;p&gt;And if you&#x27;ve been vetted, behind the public feed is a private, invite-only
Discourse forum. The showcase is the front door. The forum is where the real
community happens. Artists can discuss techniques, share experiences, ask
questions. It&#x27;s gated access because tattoo culture values quality and trust.&lt;&#x2F;p&gt;
&lt;p&gt;Think of it as Instagram&#x27;s visual presentation meets forum depth, with Discourse
handling both sides.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-discourse-makes-sense-here&quot;&gt;Why Discourse Makes Sense Here&lt;&#x2F;h2&gt;
&lt;p&gt;Discourse isn&#x27;t Instagram. It&#x27;s not designed to be a photo showcase. But it has
everything we need to build one:&lt;&#x2F;p&gt;
&lt;p&gt;Custom fields store image URLs and captions. Categories and tags organize
content. The permission system handles public showcase vs private forum.&lt;&#x2F;p&gt;
&lt;p&gt;Most importantly, Discourse is built for communities. The forum infrastructure
is already there. We&#x27;re just adding a pretty front door.&lt;&#x2F;p&gt;
&lt;p&gt;And unlike Instagram, artists own everything. The database, the images, the
content. They can export it all. No algorithm. No corporate overlord. Just their
work, on their terms.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-ve-built-so-far&quot;&gt;What I&#x27;ve Built So Far&lt;&#x2F;h2&gt;
&lt;p&gt;Backend is straightforward Rails stuff:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GuestSpotPost&lt;&#x2F;code&gt; model with image URLs (stored as arrays), captions, and
pinned status&lt;&#x2F;li&gt;
&lt;li&gt;Migrations for the database schema&lt;&#x2F;li&gt;
&lt;li&gt;Controllers that handle CRUD operations and serve JSON to the frontend&lt;&#x2F;li&gt;
&lt;li&gt;Serializers that package post data with user info&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Frontend is where it gets interesting. Modern Discourse uses Glimmer components,
which are Ember&#x27;s latest component format. Template and logic live in the same
&lt;code&gt;.gjs&lt;&#x2F;code&gt; file:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;export default class GuestSpotPostCard extends Component {
  get firstImage() {
    return this.args.post.image_urls?.[0];
  }

  &amp;lt;template&amp;gt;
    &amp;lt;LinkTo @route=&amp;quot;guest-spot-post&amp;quot; @model={{@post.id}} class=&amp;quot;guest-spot-post-card&amp;quot;&amp;gt;
      {{#if this.firstImage}}
        &amp;lt;img src={{this.firstImage}} alt={{@post.caption}} class=&amp;quot;post-image&amp;quot; &amp;#x2F;&amp;gt;
      {{&amp;#x2F;if}

      &amp;lt;div class=&amp;quot;post-meta&amp;quot;&amp;gt;
        &amp;lt;div class=&amp;quot;post-author&amp;quot;&amp;gt;
          {{avatar @post.user imageSize=&amp;quot;small&amp;quot;}}
          &amp;lt;span class=&amp;quot;username&amp;quot;&amp;gt;{{@post.user.username}}&amp;lt;&amp;#x2F;span&amp;gt;
        &amp;lt;&amp;#x2F;div&amp;gt;

        {{#if @post.caption}}
          &amp;lt;p class=&amp;quot;post-caption&amp;quot;&amp;gt;{{@post.caption}}&amp;lt;&amp;#x2F;p&amp;gt;
        {{&amp;#x2F;if}}

        {{#if @post.pinned}}
          &amp;lt;span class=&amp;quot;pinned-badge&amp;quot;&amp;gt;Featured&amp;lt;&amp;#x2F;span&amp;gt;
        {{&amp;#x2F;if}}
      &amp;lt;&amp;#x2F;div&amp;gt;
    &amp;lt;&amp;#x2F;LinkTo&amp;gt;
  &amp;lt;&amp;#x2F;template&amp;gt;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The card is a link. Click it, you go to the full post page. The whole thing is
wrapped in a &lt;code&gt;LinkTo&lt;&#x2F;code&gt; component so the entire card is interactive.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-feed-page&quot;&gt;The Feed Page&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;guest-spot-mobile-1.png&quot; alt=&quot;A mobile friendly layout of feed with tattoo posts from artists&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Two sections: pinned posts in a horizontal carousel, recent posts in a
responsive grid.&lt;&#x2F;p&gt;
&lt;p&gt;The carousel uses CSS flexbox with &lt;code&gt;scroll-snap-type: x mandatory&lt;&#x2F;code&gt; so it feels
smooth on mobile. On phones, each card takes up 80% of the viewport width. On
tablets, 250px. On desktop, 300px. Pure CSS, no JavaScript.&lt;&#x2F;p&gt;
&lt;p&gt;The grid uses CSS Grid with &lt;code&gt;auto-fill&lt;&#x2F;code&gt; and &lt;code&gt;minmax()&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;scss&quot; class=&quot;language-scss &quot;&gt;&lt;code class=&quot;language-scss&quot; data-lang=&quot;scss&quot;&gt;.posts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1.5rem;

  @media (max-width: 768px) {
    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
    gap: 1rem;
  }

  @media (max-width: 480px) {
    grid-template-columns: 1fr;
    gap: 0.75rem;
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On phones, we force a single column so cards stay large enough to see the artwork clearly. On tablets and desktops, it flows naturally based on available space.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-post-page&quot;&gt;The Post Page&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;guest-spot-mobile-2.png&quot; alt=&quot;A single page post of a tattoo showing cool birds&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Click any card and you get the full post view. It&#x27;s designed to let the artwork breathe while keeping all the important context visible.&lt;&#x2F;p&gt;
&lt;p&gt;The layout is simple:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Back link at the top (returns to feed)&lt;&#x2F;li&gt;
&lt;li&gt;Full-size image(s) with &lt;code&gt;object-fit: contain&lt;&#x2F;code&gt; so nothing gets cropped&lt;&#x2F;li&gt;
&lt;li&gt;Artist info (avatar + username)&lt;&#x2F;li&gt;
&lt;li&gt;Full caption with preserved line breaks&lt;&#x2F;li&gt;
&lt;li&gt;Pinned badge if applicable&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Images are wrapped in a card design with rounded corners and subtle shadow. On mobile, padding reduces and max image height adjusts so everything fits nicely on smaller screens. The whole page uses the same responsive breakpoints as the feed (768px for tablets, 480px for phones).&lt;&#x2F;p&gt;
&lt;p&gt;All contained in a centered column with max-width of 900px. Clean, focused, lets the work be the star.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;hiding-the-sidebar&quot;&gt;Hiding the Sidebar&lt;&#x2F;h2&gt;
&lt;p&gt;Guest Spot has its own route at &lt;code&gt;&#x2F;guest-spot&lt;&#x2F;code&gt;. But Discourse has a persistent
sidebar. For a public showcase, that doesn&#x27;t make sense. Artists posting work
shouldn&#x27;t see forum navigation.&lt;&#x2F;p&gt;
&lt;p&gt;First attempt: route lifecycle hooks.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;export default class GuestSpotFeedRoute extends DiscourseRoute {
  activate() {
    super.activate(...arguments);
    const appController = this.controllerFor(&amp;quot;application&amp;quot;);
    appController.set(&amp;quot;showSidebar&amp;quot;, false);
    document.body.classList.add(&amp;quot;guest-spot-page&amp;quot;);
  }

  deactivate() {
    const appController = this.controllerFor(&amp;quot;application&amp;quot;);
    appController.set(&amp;quot;showSidebar&amp;quot;, true);
    document.body.classList.remove(&amp;quot;guest-spot-page&amp;quot;);
    super.deactivate(...arguments);
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This hides the sidebar. But the hamburger button (&lt;code&gt;.header-sidebar-toggle&lt;&#x2F;code&gt;)
stays visible. Users can toggle it back. Not ideal.&lt;&#x2F;p&gt;
&lt;p&gt;CSS didn&#x27;t work. Setting controller properties didn&#x27;t work. So I got pragmatic:
DOM manipulation via an initializer.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;export default {
  name: &amp;quot;hide-sidebar-toggle-on-guest-spot&amp;quot;,

  initialize() {
    withPluginApi(&amp;quot;0.8&amp;quot;, (api) =&amp;gt; {
      api.onPageChange((url) =&amp;gt; {
        const toggle = document.querySelector(&amp;quot;.header-sidebar-toggle&amp;quot;);
        if (toggle) {
          if (url.startsWith(&amp;quot;&amp;#x2F;guest-spot&amp;quot;)) {
            toggle.style.display = &amp;quot;none&amp;quot;;
          } else {
            toggle.style.display = &amp;quot;&amp;quot;;
          }
        }
      });
    });
  },
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Is it hacky? Yes. Does it work? Also yes. Sometimes pragmatic beats perfect.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;mobile-responsive&quot;&gt;Mobile Responsive&lt;&#x2F;h2&gt;
&lt;p&gt;Mobile-first isn&#x27;t just a buzzword here. It&#x27;s critical. Tattoo artists live on their phones. They&#x27;re checking Instagram between clients, browsing artist portfolios while waiting for appointments, posting fresh work right after finishing a piece. If this doesn&#x27;t work perfectly on a phone, it doesn&#x27;t work at all.&lt;&#x2F;p&gt;
&lt;p&gt;The entire layout is mobile-first. Every component has media queries for tablet and phone breakpoints.&lt;&#x2F;p&gt;
&lt;p&gt;Carousel cards shrink to 80% viewport width on phones so you can see part of the next card (scroll hint). Grid forces a single column on small screens so cards don&#x27;t get comically tiny. Post detail pages reduce padding and image heights so everything fits nicely without excessive scrolling.&lt;&#x2F;p&gt;
&lt;p&gt;All CSS. No JavaScript calculating widths or handling resize events. The browser does the work, which means it&#x27;s fast and doesn&#x27;t drain battery with constant calculations.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;This is Part 1. We&#x27;ve got the public showcase working. But there&#x27;s more to
build:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Post creation UI (artists need to upload images and write captions)&lt;&#x2F;li&gt;
&lt;li&gt;Admin controls (pin&#x2F;unpin posts from the feed)&lt;&#x2F;li&gt;
&lt;li&gt;User profile pages (view all posts by a specific artist)&lt;&#x2F;li&gt;
&lt;li&gt;Comments system integration (if we want interaction on posts)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;But most importantly: actually launching this for a real community. Getting
feedback from artists. Seeing if this makes sense to people who aren&#x27;t me.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the scary part. Building code is easy. Building for people? That&#x27;s where
it gets real.&lt;&#x2F;p&gt;
&lt;p&gt;Part 2 will cover the creation flow and admin tools. Stay tuned.&lt;&#x2F;p&gt;
&lt;p&gt;If you want to see the code, it&#x27;s on GitHub:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-guest-spot&quot;&gt;discourse-guest-spot&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>How I&#x27;m Using Claude Code for Daily Development Work</title>
          <pubDate>Fri, 24 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/how-i-am-using-claude-code/</link>
          <guid>https://jakegoldsborough.com/blog/2025/how-i-am-using-claude-code/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/how-i-am-using-claude-code/">&lt;p&gt;I&#x27;ve been using Claude Code for a few months now as my primary development assistant. Not as a replacement for thinking, but as a way to move faster on the boring stuff while keeping the interesting problems for myself.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-s-actually-good-at&quot;&gt;What It&#x27;s Actually Good At&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;the-grunt-work&quot;&gt;The Grunt Work&lt;&#x2F;h3&gt;
&lt;p&gt;Testing infrastructure is where Claude shines. Today, I needed to test SMTP error logging improvements. Instead of manually setting up &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;Supermathie&#x2F;mail-relay-simulator&quot;&gt;mail-relay-simulator&lt;&#x2F;a&gt;, configuring different failure scenarios, and writing test cases, I described what I needed and got a complete Ruby script that:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Manages docker-compose lifecycle&lt;&#x2F;li&gt;
&lt;li&gt;Switches between 4 test scenarios (auth failures, wrong credentials, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;Writes proper config files with correct syntax&lt;&#x2F;li&gt;
&lt;li&gt;Handles cleanup&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The script works. It&#x27;s maintainable. It saved me 2-3 hours of tedious setup work.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;research-and-discovery&quot;&gt;Research and Discovery&lt;&#x2F;h3&gt;
&lt;p&gt;&quot;Find all the places in this codebase where we do X&quot; - this is tedious grep work that Claude excels at. It can search, cross-reference, and explain patterns without me needing to open 20 files.&lt;&#x2F;p&gt;
&lt;p&gt;Example: When investigating AWS SDK credential chain bugs, I had Claude trace through the entire credential provider chain, find where it was failing, and document the root cause. That kind of code archaeology is perfect for AI assistance.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;documentation&quot;&gt;Documentation&lt;&#x2F;h3&gt;
&lt;p&gt;Writing test results documentation, commit messages, technical proposals - Claude can take my rough notes and produce clean markdown that I&#x27;d never bother writing myself. The writeup exists because the AI made it low-effort, not because I suddenly got better at documentation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-it-could-improve&quot;&gt;Where It Could Improve&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;understanding-intent&quot;&gt;Understanding Intent&lt;&#x2F;h3&gt;
&lt;p&gt;If I&#x27;m not specific about what I want, Claude will give me something that technically works but misses the point. Example: I asked for markdown rendering in Neovim. Got three different failed attempts at split-window rendering before I just said &quot;fuck it, just hard-wrap at 80 characters.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;The AI doesn&#x27;t know when to stop. It will keep trying increasingly complex solutions when the answer is &quot;do less.&quot;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;production-debugging&quot;&gt;Production Debugging&lt;&#x2F;h3&gt;
&lt;p&gt;When real production issues happen, Claude&#x27;s knowledge cutoff and lack of access to live systems means it&#x27;s guessing. I can describe symptoms and it&#x27;ll suggest things to check, but it&#x27;s not replacing actual debugging experience.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;knowing-what-it-doesn-t-know&quot;&gt;Knowing What It Doesn&#x27;t Know&lt;&#x2F;h3&gt;
&lt;p&gt;The worst thing Claude does is hallucinate with confidence. It&#x27;ll make up photographer names for Unsplash images when WebFetch fails to parse the page. It&#x27;ll suggest APIs that don&#x27;t exist. It&#x27;ll confidently explain behavior that&#x27;s completely wrong.&lt;&#x2F;p&gt;
&lt;p&gt;You have to verify everything. Trust but verify isn&#x27;t optional.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-i-actually-use-it&quot;&gt;How I Actually Use It&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;1-explicit-context&quot;&gt;1. Explicit Context&lt;&#x2F;h3&gt;
&lt;p&gt;I maintain a CONTEXT.md file with my preferences:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Code style (no emoji, wrap at 80 chars, prefer &lt;code&gt;if !&lt;&#x2F;code&gt; over &lt;code&gt;unless&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Project patterns (date-based versioning, git workflow preferences)&lt;&#x2F;li&gt;
&lt;li&gt;Common pitfalls (Ruby openssl gem vs extension, Discourse auth patterns)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This cuts down on back-and-forth. Claude knows I want &lt;code&gt;--no-ff&lt;&#x2F;code&gt; merges without me explaining it every time.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;2-incremental-validation&quot;&gt;2. Incremental Validation&lt;&#x2F;h3&gt;
&lt;p&gt;I don&#x27;t let Claude write 500 lines without checking. Small changes, validate, next change. When it starts going off track, I stop and correct immediately.&lt;&#x2F;p&gt;
&lt;p&gt;If tests fail, I read the error myself. If code looks weird, I ask &quot;why did you do it this way?&quot; and often realize there&#x27;s a better approach.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;3-use-it-for-what-it-s-built-for&quot;&gt;3. Use It For What It&#x27;s Built For&lt;&#x2F;h3&gt;
&lt;p&gt;Good uses:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;File operations (read, edit, search)&lt;&#x2F;li&gt;
&lt;li&gt;Repetitive code changes&lt;&#x2F;li&gt;
&lt;li&gt;Documentation generation&lt;&#x2F;li&gt;
&lt;li&gt;Test script creation&lt;&#x2F;li&gt;
&lt;li&gt;Config file management&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Bad uses:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Anything requiring judgment calls&lt;&#x2F;li&gt;
&lt;li&gt;Performance optimization without profiling&lt;&#x2F;li&gt;
&lt;li&gt;Security-sensitive code review&lt;&#x2F;li&gt;
&lt;li&gt;&quot;Just make it work&quot; prompts&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;4-keep-notes&quot;&gt;4. Keep Notes&lt;&#x2F;h3&gt;
&lt;p&gt;I have Claude maintain daily notes of what we worked on. Not as todo tracking, but as a context refresh. When I come back tomorrow, I can read yesterday&#x27;s notes and pick up where we left off.&lt;&#x2F;p&gt;
&lt;p&gt;The notes also catch when Claude forgets something or contradicts earlier decisions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-workflow&quot;&gt;The Workflow&lt;&#x2F;h2&gt;
&lt;p&gt;Typical session:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Me&lt;&#x2F;strong&gt;: &quot;I need to test SMTP error logging with mail-relay-simulator&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Claude&lt;&#x2F;strong&gt;: Reads existing code, finds simulator location, proposes script structure&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Me&lt;&#x2F;strong&gt;: &quot;Yeah but make it modular, don&#x27;t hardcode paths&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Claude&lt;&#x2F;strong&gt;: Updates script with env var + path discovery&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Me&lt;&#x2F;strong&gt;: Runs script, hits error&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Claude&lt;&#x2F;strong&gt;: &quot;That error means X, here&#x27;s the fix&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Me&lt;&#x2F;strong&gt;: Applies fix, tests, moves on&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;It&#x27;s not magic. It&#x27;s a very fast junior developer who never gets tired but also never learns from mistakes unless you tell it explicitly.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-trap&quot;&gt;The Trap&lt;&#x2F;h2&gt;
&lt;p&gt;The trap is letting the AI do your thinking. It&#x27;s really easy to just accept the first solution that looks reasonable. But &quot;looks reasonable&quot; and &quot;is correct&quot; aren&#x27;t the same thing.&lt;&#x2F;p&gt;
&lt;p&gt;I caught Claude trying to use &lt;code&gt;Dir.chdir&lt;&#x2F;code&gt; in a script today. RuboCop flagged it as not thread-safe. The &quot;correct&quot; solution was &lt;code&gt;system(..., chdir: path)&lt;&#x2F;code&gt;. Claude didn&#x27;t know that until the linter told it.&lt;&#x2F;p&gt;
&lt;p&gt;If I&#x27;m not reading the code and understanding the changes, I&#x27;m just a very expensive copy-paste machine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;should-you-use-it&quot;&gt;Should You Use It?&lt;&#x2F;h2&gt;
&lt;p&gt;If you&#x27;re junior: Maybe not yet. You need to build pattern recognition before you can effectively verify AI output. You won&#x27;t know when it&#x27;s bullshitting.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re mid-level: Useful for grunt work, but keep it on a short leash. You should be reading and understanding every change.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re senior: This is productivity steroids. You already know what good code looks like. AI just makes it faster to write.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;&#x2F;h2&gt;
&lt;p&gt;Claude Code isn&#x27;t replacing developers. It&#x27;s replacing the boring parts of development. The parts where you know exactly what needs to happen but it&#x27;s going to take an hour of tedious typing.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m not using less brain power. I&#x27;m using it on the problems that actually matter instead of fighting with test harness boilerplate.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s the win.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building Yaks: A Virtual Currency System for Discourse (Part 4: Custom Titles and Earning System)</title>
          <pubDate>Sun, 19 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-4/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-4/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-4/">&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-yaks-virtual-currency-part-1&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt; covered the
backend architecture, &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-yaks-virtual-currency-part-2&#x2F;&quot;&gt;Part 2&lt;&#x2F;a&gt;
covered topic pinning and expiration, and &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-yaks-virtual-currency-part-3&#x2F;&quot;&gt;Part 3&lt;&#x2F;a&gt;
covered topic boosting and custom avatar flair. This post covers two more
major features: custom user titles and the automatic earning system.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;custom-user-titles&quot;&gt;Custom User Titles&lt;&#x2F;h2&gt;
&lt;p&gt;Custom titles let users spend Yaks to set a custom title displayed next to
their username throughout the forum. Unlike Discourse&#x27;s built-in title system
(which is tied to badges and trust levels), Yak titles are purely
cosmetic and time-limited.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-custom-title.png&quot; alt=&quot;A reply showing a user with the custom user title &amp;quot;The Yaks Man&amp;quot;&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-challenge-serializer-discovery&quot;&gt;The Challenge: Serializer Discovery&lt;&#x2F;h3&gt;
&lt;p&gt;The first attempt seemed straightforward: override the &lt;code&gt;title&lt;&#x2F;code&gt; field in the
user serializers. But after implementing it, the title showed in user cards
and profiles but not next to posts. Why?&lt;&#x2F;p&gt;
&lt;p&gt;Discourse uses different serializer methods for different contexts:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;title&lt;&#x2F;code&gt;: Used in user cards, profiles, group member lists&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;user_title&lt;&#x2F;code&gt;: Used specifically in &lt;code&gt;PostSerializer&lt;&#x2F;code&gt; for post display&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This makes sense from an architecture perspective. Posts need their own
serializer method because they serialize the user as a nested object, not as
the main object.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-solution-override-both&quot;&gt;The Solution: Override Both&lt;&#x2F;h3&gt;
&lt;p&gt;The fix required overriding both serializer methods across multiple
serializers:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# For user cards, profiles, groups
[:post, :user_card, :post_action_user, :user_name,
 :group_post_user, :group_user, :hidden_profile].each do |serializer_name|
  add_to_serializer(serializer_name, :title) do
    title_data = object.custom_fields[&amp;quot;yak_features&amp;quot;]&amp;amp;.dig(&amp;quot;title&amp;quot;)
    if title_data &amp;amp;&amp;amp; title_data[&amp;quot;enabled&amp;quot;]
      title_data[&amp;quot;text&amp;quot;]
    else
      object.title
    end
  end
end

# For post display (critical!)
add_to_serializer(:post, :user_title) do
  user = object&amp;amp;.user
  return nil unless user

  title_data = user.custom_fields[&amp;quot;yak_features&amp;quot;]&amp;amp;.dig(&amp;quot;title&amp;quot;)
  if title_data &amp;amp;&amp;amp; title_data[&amp;quot;enabled&amp;quot;]
    title_data[&amp;quot;text&amp;quot;]
  else
    user.title
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In &lt;code&gt;PostSerializer&lt;&#x2F;code&gt;, &lt;code&gt;object&lt;&#x2F;code&gt; is a Post, so we access the user via
&lt;code&gt;object.user&lt;&#x2F;code&gt;. In user-focused serializers, &lt;code&gt;object&lt;&#x2F;code&gt; is the User directly.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-frontend-modal&quot;&gt;The Frontend Modal&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-custom-title-modal.png&quot; alt=&quot;A modal showing user&amp;#39;s options for creating a custom user title&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The custom title modal is straightforward:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;@tracked customTitle = &amp;quot;&amp;quot;;

get characterCount() {
  return this.customTitle.length;
}

get isOverLimit() {
  return this.characterCount &amp;gt; 50;
}

get canApply() {
  return this.customTitle.trim().length &amp;gt; 0 &amp;amp;&amp;amp;
         !this.isOverLimit &amp;amp;&amp;amp;
         this.currentUser.yak_balance &amp;gt;= this.cost;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Live preview, character counter, balance check. The modal shows exactly what
the title will look like before spending Yaks.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-earning-system&quot;&gt;The Earning System&lt;&#x2F;h2&gt;
&lt;p&gt;This was the most complex feature to implement. Users needed a way to earn
Yaks by contributing to the community, with anti-gaming measures built in.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;requirements&quot;&gt;Requirements&lt;&#x2F;h3&gt;
&lt;p&gt;From the start, the requirements were clear:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Be modular (database-driven, not hardcoded)&lt;&#x2F;li&gt;
&lt;li&gt;Rate limiting (can&#x27;t spam posts to farm Yaks)&lt;&#x2F;li&gt;
&lt;li&gt;Trust level requirements (new accounts can&#x27;t abuse it)&lt;&#x2F;li&gt;
&lt;li&gt;Content length minimums (beyond Discourse&#x27;s defaults, prevent low-effort farming)&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h3 id=&quot;database-schema&quot;&gt;Database Schema&lt;&#x2F;h3&gt;
&lt;p&gt;Instead of hardcoding earning rules in the service, we made them
database-driven:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;create_table :yak_earning_rules do |t|
  t.string :action_key, null: false
  t.string :action_name, null: false
  t.text :description
  t.integer :amount, null: false, default: 0
  t.integer :daily_cap, null: false, default: 0
  t.integer :min_trust_level, null: false, default: 0
  t.boolean :enabled, null: false, default: true
  t.jsonb :settings, default: {}
  t.timestamps
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Seeded with four default rules:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Post Created&lt;&#x2F;strong&gt;: 2 Yaks, 20&#x2F;day cap, TL1+, 20 character minimum&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Topic Created&lt;&#x2F;strong&gt;: 5 Yaks, 10&#x2F;day cap, TL1+, 50 character minimum&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Post Liked&lt;&#x2F;strong&gt;: 3 Yaks, 30&#x2F;day cap, TL1+&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Solution Accepted&lt;&#x2F;strong&gt;: 25 Yaks, no cap, TL1+&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The &lt;code&gt;settings&lt;&#x2F;code&gt; jsonb column allows flexible per-rule configuration like
content length minimums without schema changes.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;service-layer&quot;&gt;Service Layer&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;code&gt;YakEarningService&lt;&#x2F;code&gt; handles all validation and awarding logic:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.award(user:, action_key:, related_post: nil, related_topic: nil)
  rule = YakEarningRule.get_rule(action_key)
  return false if !rule

  # Check trust level requirement
  return false if user.trust_level &amp;lt; rule.min_trust_level

  # Check minimum content length if applicable
  if rule.min_length &amp;gt; 0
    content = related_post&amp;amp;.raw || related_topic&amp;amp;.first_post&amp;amp;.raw || &amp;quot;&amp;quot;
    return false if content.length &amp;lt; rule.min_length
  end

  # Check daily cap
  if rule.has_daily_cap?
    earned_today = get_daily_earning_count(user, action_key)
    return false if earned_today &amp;gt;= rule.daily_cap
  end

  # Award the Yaks
  wallet = YakWallet.find_or_create_by(user: user)

  YakTransaction.create!(
    user: user,
    yak_wallet: wallet,
    amount: rule.amount,
    transaction_type: &amp;quot;earn&amp;quot;,
    description: &amp;quot;Earned from: #{rule.action_name}&amp;quot;,
    related_post: related_post,
    related_topic: related_topic,
  )

  wallet.update!(balance: wallet.balance + rule.amount)

  # Publish balance update to frontend
  MessageBus.publish(&amp;quot;&amp;#x2F;yak-balance&amp;#x2F;#{user.id}&amp;quot;,
                     { balance: wallet.balance },
                     user_ids: [user.id])

  true
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The service returns a boolean so we can track success&#x2F;failure in logs.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;rate-limiting-implementation&quot;&gt;Rate Limiting Implementation&lt;&#x2F;h3&gt;
&lt;p&gt;Daily caps are enforced by counting today&#x27;s transactions:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.get_daily_earning_count(user, action_key)
  wallet = YakWallet.find_by(user: user)
  return 0 if !wallet

  rule = YakEarningRule.find_by(action_key: action_key)
  return 0 if !rule

  start_of_day = Time.zone.now.beginning_of_day

  YakTransaction
    .where(yak_wallet: wallet)
    .where(transaction_type: &amp;quot;earn&amp;quot;)
    .where(&amp;quot;description LIKE ?&amp;quot;, &amp;quot;Earned from: #{rule.action_name}&amp;quot;)
    .where(&amp;quot;created_at &amp;gt;= ?&amp;quot;, start_of_day)
    .count
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Single query per award attempt. Could be cached if it becomes a bottleneck,
but the query is fast.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;event-hooks&quot;&gt;Event Hooks&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse provides events for all the actions we care about:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;DiscourseEvent.on(:post_created) do |post, opts, user|
  next if post.post_type != Post.types[:regular]
  next if post.deleted_at.present?
  next if post.hidden
  next if !post.user

  YakEarningService.award(
    user: post.user,
    action_key: &amp;quot;post_created&amp;quot;,
    related_post: post,
    related_topic: post.topic,
  )
end

DiscourseEvent.on(:like_created) do |post_action|
  post = post_action.post
  next if !post
  next if post.deleted_at.present?
  next if post.hidden
  next if post.user_id == post_action.user_id  # No self-likes

  YakEarningService.award(
    user: post.user,
    action_key: &amp;quot;post_liked&amp;quot;,
    related_post: post,
    related_topic: post.topic,
  )
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;We also hook &lt;code&gt;topic_created&lt;&#x2F;code&gt; and &lt;code&gt;accepted_solution&lt;&#x2F;code&gt; (from the
discourse-solved plugin, if installed).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;real-time-balance-updates&quot;&gt;Real-Time Balance Updates&lt;&#x2F;h3&gt;
&lt;p&gt;The original implementation had a problem: after earning Yaks, the user menu
still showed the old balance. Page refresh required.&lt;&#x2F;p&gt;
&lt;p&gt;The fix uses MessageBus, Discourse&#x27;s real-time messaging system:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Backend&lt;&#x2F;strong&gt;: Publish when balance changes&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;MessageBus.publish(&amp;quot;&amp;#x2F;yak-balance&amp;#x2F;#{user.id}&amp;quot;,
                   { balance: wallet.balance },
                   user_ids: [user.id])
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Frontend&lt;&#x2F;strong&gt;: Subscribe and update&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;const messageBus = container.lookup(&amp;quot;service:message-bus&amp;quot;);
messageBus.subscribe(`&amp;#x2F;yak-balance&amp;#x2F;${currentUser.id}`, (data) =&amp;gt; {
  currentUser.set(&amp;quot;yak_balance&amp;quot;, data.balance);
});
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now when you create a post, the balance in your user menu updates instantly.
No polling, no page refresh.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bugs-fixed&quot;&gt;Bugs Fixed&lt;&#x2F;h2&gt;
&lt;p&gt;Some bugs were discovered during manual testing. All three would have been
caught immediately by integration tests.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;bug-1-event-hook-user-parameter&quot;&gt;Bug 1: Event Hook User Parameter&lt;&#x2F;h3&gt;
&lt;p&gt;The Discourse event signature includes a &lt;code&gt;user&lt;&#x2F;code&gt; parameter:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;DiscourseEvent.on(:post_created) do |post, opts, user|
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first implementation used that parameter directly:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;YakEarningService.award(user: user, ...)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But that &lt;code&gt;user&lt;&#x2F;code&gt; parameter is nil. The actual user must be accessed via
&lt;code&gt;post.user&lt;&#x2F;code&gt;. The test would have failed immediately with &quot;User can&#x27;t be
blank&quot;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;bug-2-topic-raw-doesn-t-exist&quot;&gt;Bug 2: Topic.raw Doesn&#x27;t Exist&lt;&#x2F;h3&gt;
&lt;p&gt;Content length validation tried to access &lt;code&gt;topic.raw&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;content = related_post&amp;amp;.raw || related_topic&amp;amp;.raw || &amp;quot;&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;But topics don&#x27;t have a &lt;code&gt;raw&lt;&#x2F;code&gt; field. Only posts do. The fix:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;content = related_post&amp;amp;.raw || related_topic&amp;amp;.first_post&amp;amp;.raw || &amp;quot;&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;A test creating a topic would have crashed with &quot;undefined method `raw&#x27; for
Topic&quot;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;bug-3-missing-user-in-transaction&quot;&gt;Bug 3: Missing User in Transaction&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;code&gt;YakTransaction&lt;&#x2F;code&gt; has a &lt;code&gt;belongs_to :user&lt;&#x2F;code&gt; association, which validates
presence by default in Rails. But we weren&#x27;t passing it:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;YakTransaction.create!(
  yak_wallet: wallet,
  amount: rule.amount,
  ...
)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Should be:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;YakTransaction.create!(
  user: user,
  yak_wallet: wallet,
  amount: rule.amount,
  ...
)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Any test attempting to create a transaction would have failed validation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;a-new-basic-test-suite-structure&quot;&gt;A New Basic Test Suite Structure&lt;&#x2F;h2&gt;
&lt;p&gt;The test suite covers all validation paths:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;RSpec.describe YakEarningService do
  describe &amp;quot;.award&amp;quot; do
    it &amp;quot;awards Yaks for valid post by TL1 user&amp;quot; do
      post = Fabricate(:post, user: user, raw: &amp;quot;This is a test post...&amp;quot;)

      result = YakEarningService.award(
        user: post.user,
        action_key: &amp;quot;post_created&amp;quot;,
        related_post: post,
        related_topic: post.topic,
      )

      expect(result).to eq(true)
      expect(user.reload.yak_balance).to eq(2)
    end

    it &amp;quot;does not award Yaks to TL0 user&amp;quot; do
      post = Fabricate(:post, user: tl0_user, raw: &amp;quot;...&amp;quot;)

      result = YakEarningService.award(
        user: post.user,
        action_key: &amp;quot;post_created&amp;quot;,
        related_post: post,
      )

      expect(result).to eq(false)
      expect(tl0_user.reload.yak_balance).to eq(0)
    end

    it &amp;quot;respects daily cap&amp;quot; do
      rule = YakEarningRule.find_by(action_key: &amp;quot;post_created&amp;quot;)

      # Create posts up to daily cap
      rule.daily_cap.times do
        post = Fabricate(:post, user: user, raw: &amp;quot;...&amp;quot;)
        YakEarningService.award(user: post.user, ...)
      end

      # Next post should fail due to cap
      post = Fabricate(:post, user: user, raw: &amp;quot;...&amp;quot;)
      result = YakEarningService.award(user: post.user, ...)

      expect(result).to eq(false)
    end
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Tests force you to think about edge cases: trust levels, content length, daily
caps, disabled rules.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;admin-ui&quot;&gt;Admin UI&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-earning-rules-admin.png&quot; alt=&quot;A table showing Yaks earning rules and caps&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The admin UI shows all earning rules in a table:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Action name and description&lt;&#x2F;li&gt;
&lt;li&gt;Amount of Yaks awarded&lt;&#x2F;li&gt;
&lt;li&gt;Daily cap (or &quot;No limit&quot;)&lt;&#x2F;li&gt;
&lt;li&gt;Minimum trust level&lt;&#x2F;li&gt;
&lt;li&gt;Enabled status&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;architecture-wins&quot;&gt;Architecture Wins&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Database-Driven Configuration&lt;&#x2F;strong&gt;: No code changes needed to adjust earning
amounts or daily caps. Just update the database.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Event-Driven&lt;&#x2F;strong&gt;: Loosely coupled. The earning system doesn&#x27;t need to know
about post creation internals, just subscribes to events.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Real-Time Updates&lt;&#x2F;strong&gt;: MessageBus makes instant balance updates trivial. Six
lines of code for publish and subscribe.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Proper Service Layer&lt;&#x2F;strong&gt;: Business logic lives in &lt;code&gt;YakEarningService&lt;&#x2F;code&gt;, not
scattered across controllers and models.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Full Audit Trail&lt;&#x2F;strong&gt;: Every earning action creates a transaction record with
type, amount, description, and related post&#x2F;topic. Complete history.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;performance-considerations&quot;&gt;Performance Considerations&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Single Query for Rate Limiting&lt;&#x2F;strong&gt;: The daily count query is simple and fast.
Could be cached if it becomes a bottleneck.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;No N+1 Queries&lt;&#x2F;strong&gt;: Balance updates happen during the transaction creation, no
separate queries.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;MessageBus Efficiency&lt;&#x2F;strong&gt;: Only publishes to the specific user who earned
Yaks. Not broadcasting to everyone.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Indexed Properly&lt;&#x2F;strong&gt;: Transactions table has indexes on &lt;code&gt;yak_wallet_id&lt;&#x2F;code&gt;,
&lt;code&gt;transaction_type&lt;&#x2F;code&gt;, and &lt;code&gt;created_at&lt;&#x2F;code&gt; for the daily count query.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-left&quot;&gt;What&#x27;s Left&lt;&#x2F;h2&gt;
&lt;p&gt;The plugin is now feature-complete for the MVP:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Wallet system with transaction logging&lt;&#x2F;li&gt;
&lt;li&gt;Multiple purchasable features (post highlighting, topic boosting, custom
flair, custom titles)&lt;&#x2F;li&gt;
&lt;li&gt;Expiration system (features automatically expire)&lt;&#x2F;li&gt;
&lt;li&gt;Earning system (users earn Yaks by contributing)&lt;&#x2F;li&gt;
&lt;li&gt;Admin UI (view stats, packages, features, earning rules)&lt;&#x2F;li&gt;
&lt;li&gt;Real-time updates (no page refresh needed)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Remaining work:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Payment integration (Stripe or similar) for purchasing Yak packages&lt;&#x2F;li&gt;
&lt;li&gt;Guardian authorization checks (ensure users can only spend their own Yaks)&lt;&#x2F;li&gt;
&lt;li&gt;More earning actions (first reply, helpful flags, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;Admin edit functionality for earning rules&lt;&#x2F;li&gt;
&lt;li&gt;Production monitoring and adjustment based on real usage&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Serializers Are Powerful&lt;&#x2F;strong&gt;: Understanding Discourse&#x27;s serializer
architecture unlocks a lot of customization possibilities.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;MessageBus Is Handy&lt;&#x2F;strong&gt;: Real-time updates are easy with MessageBus, yet some
plugins don&#x27;t use it.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Database-Driven Config Is Worth It&lt;&#x2F;strong&gt;: The upfront effort to make earning
rules configurable pays off in flexibility.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Event Hooks Are Reliable&lt;&#x2F;strong&gt;: Discourse&#x27;s event system is solid. Events fire
consistently and provide the data you need.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h2&gt;
&lt;p&gt;Building a virtual currency system is teaching me a lot about Discourse&#x27;s
architecture. Serializers, custom fields, MessageBus, service objects, event
hooks - all pieces that work together to create a cohesive plugin.&lt;&#x2F;p&gt;
&lt;p&gt;The earning system was the most complex feature, but also the most rewarding.
Watching a user create a post and seeing their balance update instantly,
knowing that every piece of the system is working together (event hooks,
service validation, database transactions, MessageBus publishing, frontend
subscription) - that&#x27;s satisfying.&lt;&#x2F;p&gt;
&lt;p&gt;The full source code is available at
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-yaks&quot;&gt;github.com&#x2F;ducks&#x2F;discourse-yaks&lt;&#x2F;a&gt;.
If you&#x27;re building a Discourse plugin and want to see how all these pieces fit
together, the repo should be a useful reference.&lt;&#x2F;p&gt;
&lt;p&gt;Next up: Payment integration and production deployment. Stay tuned for Part 5.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building Yaks: A Virtual Currency System for Discourse (Part 3: Advanced Features)</title>
          <pubDate>Sat, 18 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-3/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-3/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-3/">&lt;p&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-yaks-virtual-currency-part-1&#x2F;&quot;&gt;Part 1&lt;&#x2F;a&gt; covered the
backend architecture and &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-yaks-virtual-currency-part-2&#x2F;&quot;&gt;Part 2&lt;&#x2F;a&gt;
covered topic pinning and the expiration system. This post covers two more
complex features: topic boosting (pinning topics globally with visual
highlighting) and custom avatar flair (letting users customize their forum
identity).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;topic-pin-vs-topic-boost&quot;&gt;Topic Pin vs Topic Boost&lt;&#x2F;h2&gt;
&lt;p&gt;The plugin has two topic pinning features with different scopes:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Topic Pin&lt;&#x2F;strong&gt;: Pins a topic to the top of its category only. The topic stays
at the top of the category page but doesn&#x27;t appear across other categories.
No visual highlighting.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Topic Boost&lt;&#x2F;strong&gt;: Pins a topic globally across all categories AND adds a
colored border to make it stand out in topic lists. Much more visibility,
costs more Yaks.&lt;&#x2F;p&gt;
&lt;p&gt;This post focuses on topic boost, the more powerful and visually striking
feature.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;topic-boost-making-threads-stand-out&quot;&gt;Topic Boost: Making Threads Stand Out&lt;&#x2F;h2&gt;
&lt;p&gt;Topic boost is a premium feature that pins a topic globally (across all
categories) and adds a colored border to make it stand out in topic lists.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-topic-boosting-1.png&quot; alt=&quot;Topic boosting in action - a green-bordered topic stands out at the top of the topic list&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;backend-leveraging-discourse-apis&quot;&gt;Backend: Leveraging Discourse APIs&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse has native topic pinning built in. The API is straightforward:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;topic.update_pinned(status, global, pinned_until)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Parameters:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;status&lt;&#x2F;code&gt;: true to pin, false to unpin&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;global&lt;&#x2F;code&gt;: true for global pin (all categories), false for category only&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pinned_until&lt;&#x2F;code&gt;: timestamp as string (not Time object)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The service implementation for topic boost:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;when &amp;quot;topic_boost&amp;quot;
  duration = feature.settings[&amp;quot;duration_hours&amp;quot;]&amp;amp;.hours || 72.hours
  related_topic.update_pinned(true, true, duration.from_now.to_s)

  current_features = related_topic.custom_fields[&amp;quot;yak_features&amp;quot;] || {}
  current_features[&amp;quot;boosted&amp;quot;] = {
    enabled: true,
    color: feature_data[:color] || &amp;quot;gold&amp;quot;,
    applied_at: Time.zone.now.to_i,
  }
  related_topic.custom_fields[&amp;quot;yak_features&amp;quot;] = current_features
  related_topic.save_custom_fields
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This does two things:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Pins the topic globally for 72 hours using Discourse&#x27;s native pinning&lt;&#x2F;li&gt;
&lt;li&gt;Stores visual customization data (color choice) in topic custom fields&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h3 id=&quot;frontend-value-transformers&quot;&gt;Frontend: Value Transformers&lt;&#x2F;h3&gt;
&lt;p&gt;To apply CSS classes to boosted topics in topic lists, we use Discourse&#x27;s
value transformer API. This is the modern replacement for the deprecated
widget decorators.&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;api.registerValueTransformer(&amp;quot;topic-list-item-class&amp;quot;, ({ value, context }) =&amp;gt; {
  const topic = context?.topic;
  if (topic?.yak_features?.boosted?.enabled) {
    value.push(&amp;quot;yak-boosted-topic&amp;quot;);
    const color = topic.yak_features.boosted.color || &amp;quot;gold&amp;quot;;
    value.push(`yak-color-${color}`);
  }
  return value;
});
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The transformer receives a &lt;code&gt;value&lt;&#x2F;code&gt; array and adds CSS classes based on the
topic&#x27;s custom fields. Optional chaining (&lt;code&gt;?.&lt;&#x2F;code&gt;) prevents errors if the data
is missing.&lt;&#x2F;p&gt;
&lt;p&gt;We also apply styling to the first post in boosted topics:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;const topic = helper.getModel().topic;
const topicYakFeatures = topic?.yak_features;

const isFirstPostInBoostedTopic =
  post.post_number === 1 &amp;amp;&amp;amp; topicYakFeatures?.boosted?.enabled;

if (isFirstPostInBoostedTopic) {
  article.classList.add(&amp;quot;yak-boosted-topic-post&amp;quot;);
  const color = topicYakFeatures.boosted.color || &amp;quot;gold&amp;quot;;
  article.classList.add(`yak-color-${color}`);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This gives visual consistency. The colored border appears both in topic lists
and on the opening post.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;avoiding-n-1-queries&quot;&gt;Avoiding N+1 Queries&lt;&#x2F;h3&gt;
&lt;p&gt;When adding custom field serialization, you must tell Discourse to preload
the data to avoid N+1 query warnings:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;TopicList.preloaded_custom_fields &amp;lt;&amp;lt; &amp;quot;yak_features&amp;quot; if TopicList.respond_to?(:preloaded_custom_fields)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Without this, accessing &lt;code&gt;topic.custom_fields[&quot;yak_features&quot;]&lt;&#x2F;code&gt; in serializers
triggers a database query for every topic in the list. With preloading, all
custom fields load in a single query.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;pin-auto-dismiss-behavior&quot;&gt;Pin Auto-Dismiss Behavior&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse has built-in pin dismissal behavior. When a user visits a pinned
topic, Discourse creates a &lt;code&gt;TopicUser&lt;&#x2F;code&gt; record with &lt;code&gt;cleared_pinned_at&lt;&#x2F;code&gt;
timestamp. For that user, the topic shows as unpinned.&lt;&#x2F;p&gt;
&lt;p&gt;This is intentional design to prevent banner blindness. The topic stays
pinned for everyone else, but individual users can dismiss it after viewing.
The colored border remains visible to all users regardless of pin state.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;css-variables-for-theme-customization&quot;&gt;CSS Variables for Theme Customization&lt;&#x2F;h3&gt;
&lt;p&gt;Originally, colors were hardcoded:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;scss&quot; class=&quot;language-scss &quot;&gt;&lt;code class=&quot;language-scss&quot; data-lang=&quot;scss&quot;&gt;.yak-boosted-topic.yak-color-gold {
  border-color: #ffd700;
  background: rgba(255, 215, 0, 0.1);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This made it difficult for theme authors to customize colors. The fix was CSS
variables:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;scss&quot; class=&quot;language-scss &quot;&gt;&lt;code class=&quot;language-scss&quot; data-lang=&quot;scss&quot;&gt;:root {
  --yak-color-gold: #ffd700;
  --yak-color-gold-bg: rgba(255, 215, 0, 0.1);
  --yak-color-blue: #4169e1;
  --yak-color-blue-bg: rgba(65, 105, 225, 0.1);
  &amp;#x2F;&amp;#x2F; ...
}

.yak-boosted-topic.yak-color-gold {
  border-color: var(--yak-color-gold);
  background: var(--yak-color-gold-bg);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now theme authors can override colors without modifying plugin code:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;scss&quot; class=&quot;language-scss &quot;&gt;&lt;code class=&quot;language-scss&quot; data-lang=&quot;scss&quot;&gt;:root {
  --yak-color-gold: #ff9900;
  --yak-color-gold-bg: rgba(255, 153, 0, 0.15);
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h2 id=&quot;custom-avatar-flair-user-identity&quot;&gt;Custom Avatar Flair: User Identity&lt;&#x2F;h2&gt;
&lt;p&gt;Avatar flair is the small badge that appears next to a user&#x27;s avatar.
Normally it&#x27;s set at the group level (moderators get one badge, admins get
another). The custom flair feature lets individual users choose their own
icon and color scheme for 30 days.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-avatar-flair.png&quot; alt=&quot;Custom avatar flair with a purple crown icon next to the user&amp;#39;s avatar&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-challenge-integrating-with-existing-components&quot;&gt;The Challenge: Integrating with Existing Components&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse has an existing &lt;code&gt;UserAvatarFlair&lt;&#x2F;code&gt; component that reads flair data
from serializers. The component expects certain fields (&lt;code&gt;flair_group_id&lt;&#x2F;code&gt;,
&lt;code&gt;flair_url&lt;&#x2F;code&gt;, &lt;code&gt;flair_bg_color&lt;&#x2F;code&gt;, etc.) and renders accordingly.&lt;&#x2F;p&gt;
&lt;p&gt;Initially, I tried using widget decorators to inject custom data. This hit
deprecation warnings. The modern pattern is overriding serializer fields
directly.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;serializer-override-pattern&quot;&gt;Serializer Override Pattern&lt;&#x2F;h3&gt;
&lt;p&gt;Instead of decorating components, we override the serializer methods:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;[:post, :user_card, :post_action_user].each do |serializer_name|
  add_to_serializer(serializer_name, :flair_url) do
    user = serializer_name == :post ? object.user : object
    flair = user.custom_fields[&amp;quot;yak_features&amp;quot;]&amp;amp;.dig(&amp;quot;flair&amp;quot;)
    if flair &amp;amp;&amp;amp; flair[&amp;quot;enabled&amp;quot;]
      flair[&amp;quot;icon&amp;quot;]  # Return icon name like &amp;quot;rocket&amp;quot; or &amp;quot;crown&amp;quot;
    else
      user.flair_group&amp;amp;.flair_icon || user.flair_group&amp;amp;.flair_upload&amp;amp;.url
    end
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This checks user custom fields first. If yak custom flair is present, return
that data. Otherwise, fall back to group flair.&lt;&#x2F;p&gt;
&lt;p&gt;Similar overrides for &lt;code&gt;flair_bg_color&lt;&#x2F;code&gt;, &lt;code&gt;flair_color&lt;&#x2F;code&gt;, and &lt;code&gt;flair_name&lt;&#x2F;code&gt;.
This pattern works across all serializers (post, user card, action user)
without touching the component.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-flair-group-id-problem&quot;&gt;The flair_group_id Problem&lt;&#x2F;h3&gt;
&lt;p&gt;The &lt;code&gt;UserAvatarFlair&lt;&#x2F;code&gt; component has an early return:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;if (!user || !user.flair_group_id) {
  return; &amp;#x2F;&amp;#x2F; No flair rendered
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Users with custom flair but no group flair would fail this check. The
component would exit early and never render anything.&lt;&#x2F;p&gt;
&lt;p&gt;The fix was setting a dummy marker value:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;add_to_serializer(serializer_name, :flair_group_id) do
  user = serializer_name == :post ? object.user : object
  flair = user.custom_fields[&amp;quot;yak_features&amp;quot;]&amp;amp;.dig(&amp;quot;flair&amp;quot;)
  if flair &amp;amp;&amp;amp; flair[&amp;quot;enabled&amp;quot;]
    -1  # Dummy value to pass existence check
  else
    user.flair_group_id
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With &lt;code&gt;flair_group_id&lt;&#x2F;code&gt; set to &lt;code&gt;-1&lt;&#x2F;code&gt;, the component passes the existence check.
It then finds &lt;code&gt;flair_url&lt;&#x2F;code&gt; is set (to the icon name) and renders the custom
flair.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;frontend-icon-and-color-picker&quot;&gt;Frontend: Icon and Color Picker&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-flair-picker.png&quot; alt=&quot;Custom avatar flair picker modal showing red fire being selected&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The flair modal presents three selection grids:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;const ICONS = [&amp;quot;star&amp;quot;, &amp;quot;heart&amp;quot;, &amp;quot;fire&amp;quot;, &amp;quot;bolt&amp;quot;, &amp;quot;gem&amp;quot;, &amp;quot;crown&amp;quot;, &amp;quot;rocket&amp;quot;, &amp;quot;trophy&amp;quot;];
const BG_COLORS = [&amp;quot;#ffd700&amp;quot;, &amp;quot;#4169e1&amp;quot;, &amp;quot;#dc143c&amp;quot;, &amp;quot;#32cd32&amp;quot;, &amp;quot;#9370db&amp;quot;, &amp;quot;#ff8c00&amp;quot;];
const TEXT_COLORS = [&amp;quot;#ffffff&amp;quot;, &amp;quot;#000000&amp;quot;, &amp;quot;#ffd700&amp;quot;];
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Users click icons to select one, then choose background and text colors. A
live preview shows the combination:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;&amp;lt;div class=&amp;quot;flair-preview&amp;quot;&amp;gt;
  &amp;lt;span class=&amp;quot;user-flair&amp;quot; style={{this.previewStyle}}&amp;gt;
    {{d-icon this.selectedIcon}}
  &amp;lt;&amp;#x2F;span&amp;gt;
&amp;lt;&amp;#x2F;div&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The preview updates reactively as selections change. Once the user confirms,
the modal sends the choices to the backend, which stores them in user custom
fields.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;making-feature-cards-clickable&quot;&gt;Making Feature Cards Clickable&lt;&#x2F;h3&gt;
&lt;p&gt;On the wallet page, custom flair is the only feature that needs a modal
(icon&#x2F;color picker). Other features just need a color picker, which is
handled inline.&lt;&#x2F;p&gt;
&lt;p&gt;To make the custom flair card clickable, we split the template:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;{{#if (eq feature.id &amp;quot;custom_flair&amp;quot;)}}
  &amp;lt;div class=&amp;quot;feature-card clickable&amp;quot; {{on &amp;quot;click&amp;quot; this.openFlairModal}}&amp;gt;
    {{! card content }}
  &amp;lt;&amp;#x2F;div&amp;gt;
{{else}}
  &amp;lt;div class=&amp;quot;feature-card&amp;quot;&amp;gt;
    {{! card content }}
  &amp;lt;&amp;#x2F;div&amp;gt;
{{&amp;#x2F;if}}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Only the custom flair card gets the click handler. Other cards remain static.
This avoids passing &lt;code&gt;undefined&lt;&#x2F;code&gt; callbacks to the &lt;code&gt;{{on}}&lt;&#x2F;code&gt; helper.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;The plugin now has:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Post highlighting (colored borders)&lt;&#x2F;li&gt;
&lt;li&gt;Topic pinning (category-only)&lt;&#x2F;li&gt;
&lt;li&gt;Topic boosting (global pin + highlight)&lt;&#x2F;li&gt;
&lt;li&gt;Custom avatar flair (user identity)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Still to implement:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Post pinning (pin individual post to top of topic)&lt;&#x2F;li&gt;
&lt;li&gt;Post boosting (increase visibility in feeds)&lt;&#x2F;li&gt;
&lt;li&gt;Earning system (auto-reward quality posts)&lt;&#x2F;li&gt;
&lt;li&gt;Guardian authorization (permission checks)&lt;&#x2F;li&gt;
&lt;li&gt;Admin dashboard improvements&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The earning system is the next major piece. Right now, users can only spend
Yaks. They need ways to earn them through quality contributions, not just
purchases.&lt;&#x2F;p&gt;
&lt;p&gt;Code is on GitHub: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-yaks&quot;&gt;ducks&#x2F;discourse-yaks&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building Yaks: A Virtual Currency System for Discourse (Part 2: Features and Expiration)</title>
          <pubDate>Fri, 17 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-2/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-2/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-2/">&lt;p&gt;In &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;building-yaks-virtual-currency-part-1&quot;&gt;Part 1&lt;&#x2F;a&gt;, I covered
the backend architecture for Yaks: wallets, transactions, and the service
layer. The database models were in place, but the system could only apply
one feature (post highlighting) and had no way to clean up expired features.&lt;&#x2F;p&gt;
&lt;p&gt;Part 2 covers:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Implementing topic pinning (our second feature)&lt;&#x2F;li&gt;
&lt;li&gt;Refactoring the service layer to be truly modular&lt;&#x2F;li&gt;
&lt;li&gt;Building an expiration system with background jobs&lt;&#x2F;li&gt;
&lt;li&gt;Starting the admin configuration UI&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;spend-yaks.png&quot; alt=&quot;Spend Yaks button on topic footer&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;spend-yaks-2.png&quot; alt=&quot;Spend Yaks modal showing available features&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;topic-pinning&quot;&gt;Topic Pinning&lt;&#x2F;h2&gt;
&lt;p&gt;Topic pinning lets users spend Yaks to pin their topic to the top of its
category for a configurable duration. This uses Discourse&#x27;s native pinning
mechanism, but requires currency to access.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-this-feature-matters&quot;&gt;Why This Feature Matters&lt;&#x2F;h3&gt;
&lt;p&gt;Forums prioritize content by recency. Older discussions get buried. Topic
pinning gives users a way to temporarily boost visibility for important
discussions without needing moderator intervention.&lt;&#x2F;p&gt;
&lt;p&gt;The constraint is time. After the configured duration expires, the pin is
removed and the topic returns to normal sorting.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-service-architecture-problem&quot;&gt;The Service Architecture Problem&lt;&#x2F;h3&gt;
&lt;p&gt;When implementing topic pinning, I hit an architectural issue. The existing
&lt;code&gt;YakFeatureService.apply_feature&lt;&#x2F;code&gt; method was post-centric:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.apply_feature(user, feature_key, related_post, feature_data: {})
  # ... validation logic ...

  feature_use = YakFeatureUse.create!(
    user: user,
    yak_feature: feature,
    related_post: related_post,  # Always requires a post
    feature_data: feature_data
  )
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This design assumed every feature applies to a post. But topic pinning
applies to a topic, not a post. I could hack around it by passing
&lt;code&gt;related_post.topic&lt;&#x2F;code&gt;, but that&#x27;s wrong. The service should support both
contexts.&lt;&#x2F;p&gt;
&lt;p&gt;Looking at the implementation, the issue was clear. The service wasn&#x27;t
modular at all. It was built for posts and only posts. Adding topic
support meant refactoring the core design.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;refactoring-the-service&quot;&gt;Refactoring the Service&lt;&#x2F;h3&gt;
&lt;p&gt;The fix was adding support for both posts and topics:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# Old (post-centric)
def self.apply_feature(user, feature_key, related_post, feature_data: {})

# New (modular, all keyword)
def self.apply_feature(
  user:,
  feature_key:,
  related_post: nil,
  related_topic: nil,
  feature_data: {}
)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now the service accepts either a post or a topic (or neither, for
user-level features like custom flair). Making all parameters keyword
arguments forces explicit call sites, which prevents mistakes when you have
multiple optional params.&lt;&#x2F;p&gt;
&lt;p&gt;Validation got context-specific methods:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.can_apply_to_post?(feature_key, post, user)
  return false if post.trashed? || post.deleted_at.present?
  return false if post.user_id != user.id

  # Check for existing active feature
  !YakFeatureUse.exists?(
    user: user,
    yak_feature: YakFeature.find_by(feature_key: feature_key),
    related_post: post,
    expires_at: Time.zone.now..Float::INFINITY
  )
end

def self.can_apply_to_topic?(feature_key, topic, user)
  return false if topic.closed || topic.archived
  return false if topic.user_id != user.id

  # Check for existing active feature
  !YakFeatureUse.exists?(
    user: user,
    yak_feature: YakFeature.find_by(feature_key: feature_key),
    related_topic: topic,
    expires_at: Time.zone.now..Float::INFINITY
  )
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This pattern will scale. When we add user-level features (custom flair),
we&#x27;ll add &lt;code&gt;can_apply_to_user?&lt;&#x2F;code&gt; without touching the core service logic.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;integrating-with-discourse-s-topic-pinning&quot;&gt;Integrating with Discourse&#x27;s Topic Pinning&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse has built-in topic pinning. The &lt;code&gt;Topic&lt;&#x2F;code&gt; model has an
&lt;code&gt;update_pinned&lt;&#x2F;code&gt; method:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;duration = feature.duration_hours.hours
topic.update_pinned(true, false, duration.from_now.to_s)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Three parameters:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;pinned&lt;&#x2F;code&gt; - Enable or disable the pin&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;global&lt;&#x2F;code&gt; - Pin globally (across all categories) or just in this topic&#x27;s
category&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;pinned_until&lt;&#x2F;code&gt; - When to automatically unpin (must be a string timestamp)&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The third parameter is critical. It must be a string, not a Time object.
This caught me during implementation:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# Wrong (creates YakFeatureUse but topic doesn&amp;#x27;t actually pin)
topic.update_pinned(true, false, feature.duration_hours.hours.from_now)

# Correct
topic.update_pinned(true, false, feature.duration_hours.hours.from_now.to_s)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The feature use was being created, the Yaks were being deducted, but the
topic wasn&#x27;t pinning. The issue was the timestamp format. &lt;code&gt;update_pinned&lt;&#x2F;code&gt;
silently fails if you pass a Time object instead of a string.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;expiration-system&quot;&gt;Expiration System&lt;&#x2F;h2&gt;
&lt;p&gt;Features need to expire. Each feature has a configurable duration stored in
the database. We need a way to clean up expired features and undo their
effects.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-architecture&quot;&gt;The Architecture&lt;&#x2F;h3&gt;
&lt;p&gt;The system has three parts:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Regular Job&lt;&#x2F;strong&gt;: &lt;code&gt;ExpireYakFeature&lt;&#x2F;code&gt; - Handles a single expiration&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Scheduled Job&lt;&#x2F;strong&gt;: &lt;code&gt;CleanupExpiredYakFeatures&lt;&#x2F;code&gt; - Finds expired features
and queues regular jobs&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Service Method&lt;&#x2F;strong&gt;: &lt;code&gt;YakFeatureService.expire_feature&lt;&#x2F;code&gt; - Business logic
for expiration&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The primary expiration mechanism runs at creation time. When a feature use
is created, the expiration job is scheduled to run exactly when it expires:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.apply_feature(...)
  feature_use = YakFeatureUse.create!(...)

  if feature_use.expires_at
    Jobs.enqueue_at(feature_use.expires_at, :expire_yak_feature,
      feature_use_id: feature_use.id)
  end

  # ...
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is efficient. No polling. The job runs exactly when needed.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-scheduled-job&quot;&gt;The Scheduled Job&lt;&#x2F;h3&gt;
&lt;p&gt;Runs hourly to find expired features:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;module Jobs
  class CleanupExpiredYakFeatures &amp;lt; ::Jobs::Scheduled
    every 1.hour

    def execute(args)
      expired_features = YakFeatureUse
        .where(&amp;quot;expires_at IS NOT NULL AND expires_at &amp;lt;= ?&amp;quot;, Time.zone.now)
        .where(expired: false)

      expired_features.find_each do |feature_use|
        Jobs.enqueue(:expire_yak_feature, feature_use_id: feature_use.id)
      end

      { processed: expired_features.count }
    end
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It queries for expired features (where &lt;code&gt;expires_at&lt;&#x2F;code&gt; is in the past) and
queues a job for each one.&lt;&#x2F;p&gt;
&lt;p&gt;This hourly cleanup job is a safety net. If the server restarts before a
scheduled job runs, or if something goes wrong with job scheduling, the
cleanup job catches it. It&#x27;s backup, not the primary mechanism.&lt;&#x2F;p&gt;
&lt;p&gt;Why &lt;code&gt;find_each&lt;&#x2F;code&gt; instead of &lt;code&gt;each&lt;&#x2F;code&gt;? Performance. &lt;code&gt;find_each&lt;&#x2F;code&gt; loads records
in batches (1000 by default) instead of loading everything into memory. If
you have 10,000 expired features, &lt;code&gt;each&lt;&#x2F;code&gt; would load all 10,000 at once.
&lt;code&gt;find_each&lt;&#x2F;code&gt; loads 1000, processes them, loads the next 1000.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-regular-job&quot;&gt;The Regular Job&lt;&#x2F;h3&gt;
&lt;p&gt;Processes one expiration:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;module Jobs
  class ExpireYakFeature &amp;lt; ::Jobs::Base
    def execute(args)
      feature_use = YakFeatureUse.find_by(id: args[:feature_use_id])
      return unless feature_use

      YakFeatureService.expire_feature(feature_use)
    end
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Why a separate job? Fault tolerance. If one expiration fails (database
error, bug in the expiration logic), it doesn&#x27;t stop the others from
processing.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-service-method&quot;&gt;The Service Method&lt;&#x2F;h3&gt;
&lt;p&gt;Handles the business logic:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.expire_feature(feature_use)
  feature_key = feature_use.yak_feature.feature_key

  case feature_key
  when &amp;quot;post_highlight&amp;quot;
    remove_post_highlight(feature_use.related_post)
  when &amp;quot;topic_pin&amp;quot;
    unpin_topic(feature_use.related_topic)
  end

  feature_use.update!(expired: true)
end

private

def self.remove_post_highlight(post)
  return unless post

  features = post.custom_fields[&amp;quot;yak_features&amp;quot;] || {}
  features.delete(&amp;quot;highlight&amp;quot;)

  if features.empty?
    post.custom_fields.delete(&amp;quot;yak_features&amp;quot;)
  else
    post.custom_fields[&amp;quot;yak_features&amp;quot;] = features
  end

  post.save_custom_fields
end

def self.unpin_topic(topic)
  return unless topic
  topic.update_pinned(false, false, nil)
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Expiration removes the visual effects and marks the feature use as expired.&lt;&#x2F;p&gt;
&lt;p&gt;Why mark as expired instead of deleting? Audit trail. Users can see their
feature history. Admins can debug issues. Refunds are easier because you
have the original feature use record.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;frontend-integration&quot;&gt;Frontend Integration&lt;&#x2F;h2&gt;
&lt;p&gt;The frontend needed two things:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;A &quot;Spend Yaks&quot; button on topics&lt;&#x2F;li&gt;
&lt;li&gt;Context-aware modal that shows appropriate features&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h3 id=&quot;topic-footer-button&quot;&gt;Topic Footer Button&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse has an API for adding buttons to topic footers:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;import { withPluginApi } from &amp;quot;discourse&amp;#x2F;lib&amp;#x2F;plugin-api&amp;quot;;

export default {
  name: &amp;quot;yak-topic-actions&amp;quot;,
  initialize() {
    withPluginApi((api) =&amp;gt; {
      api.registerTopicFooterButton({
        id: &amp;quot;yak-spend&amp;quot;,
        icon: &amp;quot;coins&amp;quot;,
        label: &amp;quot;yaks.topic_action.spend&amp;quot;,
        action() {
          const modal = getOwner(this).lookup(&amp;quot;service:modal&amp;quot;);
          modal.show(SpendYaksModal, {
            model: {
              topic: this.topic,
            },
          });
        },
        dropdown() {
          return this.site.mobileView;
        },
        classNames: [&amp;quot;yak-spend&amp;quot;],
        dependentKeys: [&amp;quot;topic.closed&amp;quot;, &amp;quot;topic.archived&amp;quot;],
        displayed() {
          return (
            this.currentUser &amp;amp;&amp;amp;
            this.topic.user_id === this.currentUser.id &amp;amp;&amp;amp;
            !this.topic.closed &amp;amp;&amp;amp;
            !this.topic.archived
          );
        },
      });
    });
  },
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;displayed()&lt;&#x2F;code&gt; function controls visibility. The button only shows if:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;User is logged in&lt;&#x2F;li&gt;
&lt;li&gt;User owns the topic&lt;&#x2F;li&gt;
&lt;li&gt;Topic isn&#x27;t closed or archived&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;context-aware-modal&quot;&gt;Context-Aware Modal&lt;&#x2F;h3&gt;
&lt;p&gt;The modal needed to work for both posts and topics:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;javascript&quot; class=&quot;language-javascript &quot;&gt;&lt;code class=&quot;language-javascript&quot; data-lang=&quot;javascript&quot;&gt;export default class SpendYaksModal extends Component {
  get isPostContext() {
    return !!this.args.model.post;
  }

  get isTopicContext() {
    return !!this.args.model.topic &amp;amp;&amp;amp; !this.args.model.post;
  }

  get postFeatures() {
    return this.allFeatures.filter((f) =&amp;gt; f.category === &amp;quot;post&amp;quot;);
  }

  get topicFeatures() {
    return this.allFeatures.filter((f) =&amp;gt; f.category === &amp;quot;topic&amp;quot;);
  }

  get availableFeatures() {
    if (this.isPostContext) return this.postFeatures;
    if (this.isTopicContext) return this.topicFeatures;
    return [];
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One modal, two contexts. The UI adapts based on whether you clicked &quot;Spend
Yaks&quot; on a post or a topic.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;admin-ui-in-progress&quot;&gt;Admin UI (In Progress)&lt;&#x2F;h2&gt;
&lt;p&gt;The final piece is admin configuration. Currently, features and purchase
packages are hardcoded. They need to be editable in the UI.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;database-backed-packages&quot;&gt;Database-Backed Packages&lt;&#x2F;h3&gt;
&lt;p&gt;Created a &lt;code&gt;yak_packages&lt;&#x2F;code&gt; table:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;create_table :yak_packages do |t|
  t.string :name, null: false
  t.text :description
  t.integer :price_cents, null: false, default: 0
  t.integer :yaks, null: false, default: 0
  t.integer :bonus_yaks, null: false, default: 0
  t.boolean :enabled, null: false, default: true
  t.integer :position, null: false, default: 0
  t.timestamps
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Price is stored as cents (integers) instead of dollars (floats) to avoid
floating-point precision issues. A $5.00 package is 500 cents.&lt;&#x2F;p&gt;
&lt;p&gt;The model has helper methods:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class YakPackage &amp;lt; ActiveRecord::Base
  def total_yaks
    yaks + bonus_yaks
  end

  def price_usd
    price_cents &amp;#x2F; 100.0
  end

  def price_usd=(usd)
    self.price_cents = (usd.to_f * 100).to_i
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This lets you work in dollars in the UI but store as cents in the database.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;crud-endpoints&quot;&gt;CRUD Endpoints&lt;&#x2F;h3&gt;
&lt;p&gt;Added REST endpoints to the admin controller:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def packages
  packages = YakPackage.ordered
  render json: { packages: packages.map { |p| serialize_package(p) } }
end

def create_package
  package = YakPackage.new(package_params)
  if package.save
    render json: { package: serialize_package(package) }
  else
    render_json_error(package.errors.full_messages.join(&amp;quot;, &amp;quot;))
  end
end

def update_package
  package = YakPackage.find(params[:id])
  if package.update(package_params)
    render json: { package: serialize_package(package) }
  else
    render_json_error(package.errors.full_messages.join(&amp;quot;, &amp;quot;))
  end
end

def delete_package
  package = YakPackage.find(params[:id])
  package.destroy!
  render json: success_json
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Standard Rails REST pattern. The admin can create, edit, and delete
packages without touching code.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;admin-ui-structure&quot;&gt;Admin UI Structure&lt;&#x2F;h3&gt;
&lt;p&gt;The UI follows Discourse&#x27;s admin plugin pattern (inspired by the Chat
plugin):&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;admin&amp;#x2F;assets&amp;#x2F;javascripts&amp;#x2F;discourse&amp;#x2F;
├── routes&amp;#x2F;admin-plugins&amp;#x2F;show&amp;#x2F;discourse-yaks-management&amp;#x2F;
├── controllers&amp;#x2F;admin-plugins&amp;#x2F;show&amp;#x2F;discourse-yaks-management&amp;#x2F;
├── templates&amp;#x2F;admin&amp;#x2F;plugins&amp;#x2F;show&amp;#x2F;discourse-yaks-management&amp;#x2F;
└── initializers&amp;#x2F;yaks-admin-plugin-configuration-nav.js
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The structure creates tabs:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Settings&lt;&#x2F;strong&gt; - Site settings (automatic)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Management&lt;&#x2F;strong&gt; - Custom UI for packages, features, stats&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The templates use Discourse&#x27;s admin components:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;hbs&quot; class=&quot;language-hbs &quot;&gt;&lt;code class=&quot;language-hbs&quot; data-lang=&quot;hbs&quot;&gt;&amp;lt;div class=&amp;quot;admin-config-page&amp;quot;&amp;gt;
  &amp;lt;div class=&amp;quot;admin-plugin-config-page&amp;quot;&amp;gt;
    &amp;lt;div class=&amp;quot;d-page-header&amp;quot;&amp;gt;
      &amp;lt;div class=&amp;quot;d-page-header__title-row&amp;quot;&amp;gt;
        &amp;lt;h1 class=&amp;quot;d-page-header__title&amp;quot;&amp;gt;Yak Management&amp;lt;&amp;#x2F;h1&amp;gt;
      &amp;lt;&amp;#x2F;div&amp;gt;
      &amp;lt;p class=&amp;quot;d-page-header__description&amp;quot;&amp;gt;
        Manage your virtual currency system. Configure purchase packages
        and premium features.
      &amp;lt;&amp;#x2F;p&amp;gt;
    &amp;lt;&amp;#x2F;div&amp;gt;

    &amp;lt;div class=&amp;quot;admin-plugin-config-page__content&amp;quot;&amp;gt;
      &amp;lt;!-- Tables for stats, packages, features --&amp;gt;
    &amp;lt;&amp;#x2F;div&amp;gt;
  &amp;lt;&amp;#x2F;div&amp;gt;
&amp;lt;&amp;#x2F;div&amp;gt;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This matches Discourse&#x27;s standard admin page structure. Using the
framework&#x27;s components ensures consistency with the rest of the admin area.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;current-status&quot;&gt;Current Status&lt;&#x2F;h3&gt;
&lt;p&gt;The admin UI backend is complete (CRUD endpoints, modals, tables), but the
tab navigation isn&#x27;t working yet. The Settings tab appears, but the
Management tab doesn&#x27;t. This is a routing issue, not a data issue.&lt;&#x2F;p&gt;
&lt;p&gt;The tables work when accessed directly. The functionality is there. The
navigation just needs debugging.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-s-next&quot;&gt;What&#x27;s Next&lt;&#x2F;h2&gt;
&lt;p&gt;The immediate task is fixing the admin UI tab navigation. Once that&#x27;s
working, admins will have full control over the system configuration:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Add&#x2F;edit&#x2F;delete purchase packages (price, Yak amounts, bonus structure)&lt;&#x2F;li&gt;
&lt;li&gt;Configure feature costs and durations (how many Yaks, how long they last)&lt;&#x2F;li&gt;
&lt;li&gt;View system statistics (total wallets, Yaks in circulation, active
features)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;After that:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Implement the earning system (reward quality posts based on configurable
criteria)&lt;&#x2F;li&gt;
&lt;li&gt;Build the remaining features (post pin, post boost, custom flair)&lt;&#x2F;li&gt;
&lt;li&gt;Add purchase flow integration (Stripe for buying Yaks with real money)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The plugin is functional. Two features work end-to-end. The expiration
system is running. The architecture is modular enough to add new features
without major refactoring.&lt;&#x2F;p&gt;
&lt;p&gt;Part 3 will cover the earning system and completing the admin UI.&lt;&#x2F;p&gt;
&lt;p&gt;The code is on GitHub: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-yaks&quot;&gt;ducks&#x2F;discourse-yaks&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 42&#x2F;52</title>
          <pubDate>Fri, 17 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-42/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-42/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-42/">&lt;p&gt;&lt;strong&gt;AWS SDK Ruby contributions and follow-up&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You published &lt;em&gt;Contributing to AWS SDK Ruby - Part 3&lt;&#x2F;em&gt;, which wrapped up your series
about improving the Ruby AWS SDK. You mentioned the post got 3 boosts on Mastodon
and linked to it from Hachyderm. We also talked briefly about your earlier PRs and
how they connect -- adding a feature first, then fixing a bug.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Transit Tracker and Discourse experiments&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You released and shared &lt;em&gt;Building a Live Transit Departure Board with Discourse&lt;&#x2F;em&gt;,
a creative exploration of using Discourse for something non-forum-like. We talked
about whether posting it on Discourse&#x27;s official blog or community site would be
weird, and you decided it&#x27;d actually fit since you work there. You also added
screenshots later to make the post more engaging.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;GoatCounter and analytics debugging&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You tried exporting page-view data from your self-hosted GoatCounter instance, but
ran into a network failure while testing the API. You realized you might not have
the admin section working yet. You also revisited an older issue about countries
being listed twice in your data and looked at the GoatCounter repo to see why.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Personal tools and privacy&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We discussed private movement trackers for GrapheneOS, and you settled on &lt;em&gt;PFA&lt;&#x2F;em&gt; as
a lightweight way to track steps and distance without leaking data.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Side notes&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You mentioned two new blog posts being live -- &lt;em&gt;Transit Tracker in Discourse&lt;&#x2F;em&gt; and
&lt;em&gt;Yak&#x27;s Virtual Currency - Part 2&lt;&#x2F;em&gt; -- both updated with screenshots.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building Slumprutt: A Random Route Generator in Under an Hour</title>
          <pubDate>Thu, 16 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-slumprutt-random-route-generator/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-slumprutt-random-route-generator/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-slumprutt-random-route-generator/">&lt;p&gt;I&#x27;ve wanted a random route generator for awhile now. My problem is I walk
and bike the same routes over and over. My brain goes on autopilot. I wanted
something that would throw me new routes to explore in my own neighborhood.&lt;&#x2F;p&gt;
&lt;p&gt;I wanted something simple: &quot;Give me a random 5-mile loop from my house.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;So I built it. With Claude Code&#x27;s help, we went from idea to working app
in under an hour.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-name&quot;&gt;The Name&lt;&#x2F;h2&gt;
&lt;p&gt;&quot;Slumprutt&quot; comes from Swedish &quot;slumpmässig rutt&quot; (slumpmässig = random or
by chance, rutt = route), commonly shortened to just &quot;slumprutt&quot;. Seemed
fitting for a random route generator.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The Stack&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Backend&lt;&#x2F;strong&gt;: TypeScript + Node.js + Express&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Frontend&lt;&#x2F;strong&gt;: Svelte + TypeScript + Leaflet&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Routing&lt;&#x2F;strong&gt;: OSRM (Open Source Routing Machine)&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Maps&lt;&#x2F;strong&gt;: OpenStreetMap&lt;&#x2F;p&gt;
&lt;p&gt;OSRM deserves special mention. It&#x27;s a free public API that provides
road-accurate routing with turn-by-turn directions. Incredibly fast
(sub-second response times) and requires zero setup. Just hit their public
endpoint and get back real routes that follow actual roads.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-algorithm&quot;&gt;The Algorithm&lt;&#x2F;h2&gt;
&lt;p&gt;The core challenge was generating interesting random loops. Not just random
waypoints, but routes that actually feel like purposeful walks or bike rides.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s what I landed on:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;export function generateLoopWaypoints(
  start: Coordinate,
  targetDistanceKm: number,
  variationIndex: number = 0
): Coordinate[] {
  &amp;#x2F;&amp;#x2F; Use fewer waypoints (3-5) for longer segments
  const numWaypoints = Math.max(3, Math.min(5, 3 + variationIndex));

  &amp;#x2F;&amp;#x2F; Reduced multiplier to 0.6 to account for road routing overhead
  const baseRadius = targetDistanceKm &amp;#x2F; (2 * Math.PI) * 0.6;

  const waypoints: Coordinate[] = [];
  const latPerKm = 1 &amp;#x2F; 111; &amp;#x2F;&amp;#x2F; 1 degree latitude ≈ 111km
  const lonPerKm = 1 &amp;#x2F; (111 * Math.cos((start.lat * Math.PI) &amp;#x2F; 180));

  &amp;#x2F;&amp;#x2F; Rotate each route by 120° to avoid overlap
  const primaryDirection = (variationIndex * (2 * Math.PI)) &amp;#x2F; 3;

  for (let i = 0; i &amp;lt; numWaypoints; i++) {
    const baseAngle = (i &amp;#x2F; numWaypoints) * 2 * Math.PI + primaryDirection;
    &amp;#x2F;&amp;#x2F; More randomness for diverse paths
    const angleVariation = (Math.random() - 0.5) * 0.8;
    const angle = baseAngle + angleVariation;
    &amp;#x2F;&amp;#x2F; More radius variation to explore different distances
    const radiusVariation = 0.5 + Math.random() * 1.0;
    const radius = baseRadius * radiusVariation;

    waypoints.push({
      lat: start.lat + Math.sin(angle) * radius * latPerKm,
      lon: start.lon + Math.cos(angle) * radius * lonPerKm,
    });
  }

  return waypoints;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Key design decisions:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fewer waypoints&lt;&#x2F;strong&gt; (3-5 instead of 8+) means longer road segments and
more interesting routes. Too many waypoints and you just walk back and
forth.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Smaller radius with overhead adjustment&lt;&#x2F;strong&gt; (0.6x) accounts for the fact
that road routing adds significant distance compared to straight-line
waypoints. Initially used 1.5x which resulted in 7-8 mile routes when
requesting 1 mile.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Directional rotation&lt;&#x2F;strong&gt; (120° per route) prevents all routes from
heading in the same direction. Generate 3 routes and they&#x27;ll spread out
into different quadrants.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;More randomness&lt;&#x2F;strong&gt; (0.8 angle variation, 0.5-1.5 radius variation)
creates diverse paths that explore different areas. This prevents
multiple routes from clustering in the same locations.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Then we feed those waypoints to OSRM, which turns them into actual road
routes with turn-by-turn directions.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;challenges&quot;&gt;Challenges&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;distance-input-validation&quot;&gt;Distance Input Validation&lt;&#x2F;h3&gt;
&lt;p&gt;First issue: weird validation errors when changing distance inputs. I had
&lt;code&gt;step=&quot;0.1&quot;&lt;&#x2F;code&gt; on both the miles and kilometers inputs. The problem:
3.2 miles converts to 5.1494888 km, which rounds to 5.15. The browser
expected 5.1 or 5.2 (multiples of 0.1) and rejected 5.15.&lt;&#x2F;p&gt;
&lt;p&gt;Fix: &lt;code&gt;step=&quot;any&quot;&lt;&#x2F;code&gt; accepts any decimal value.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;map-not-visible&quot;&gt;Map Not Visible&lt;&#x2F;h3&gt;
&lt;p&gt;Built the whole UI, ran it, and... no map. Just an empty space where the
map should be.&lt;&#x2F;p&gt;
&lt;p&gt;The issue: CSS height inheritance. The map div had &lt;code&gt;height: 100%&lt;&#x2F;code&gt; but its
parent containers didn&#x27;t have explicit heights. The fix required setting
height on three elements:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;css&quot; class=&quot;language-css &quot;&gt;&lt;code class=&quot;language-css&quot; data-lang=&quot;css&quot;&gt;:global(html),
:global(body),
:global(#app) {
  height: 100%;
  width: 100%;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is a common Svelte gotcha when you want a full-viewport layout.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;routes-don-t-follow-roads&quot;&gt;Routes Don&#x27;t Follow Roads&lt;&#x2F;h3&gt;
&lt;p&gt;Initial implementation drew straight lines between waypoints. Routes went
through buildings, parks, water. Not useful for actual navigation.&lt;&#x2F;p&gt;
&lt;p&gt;This is where OSRM saved the day. One API call with waypoints, get back
actual road geometry:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;const coords = route.points.map(p =&amp;gt; `${p.lon},${p.lat}`).join(&amp;#x27;;&amp;#x27;);
const url = `https:&amp;#x2F;&amp;#x2F;router.project-osrm.org&amp;#x2F;route&amp;#x2F;v1&amp;#x2F;${profile}&amp;#x2F;${coords}?overview=full&amp;amp;geometries=geojson&amp;amp;steps=true`;

const response = await fetch(url);
const data = await response.json();
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;steps=true&lt;&#x2F;code&gt; parameter gives you turn-by-turn directions too, which
I added to the UI in collapsible sections under each route.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;boring-back-and-forth-routes&quot;&gt;Boring Back-and-Forth Routes&lt;&#x2F;h3&gt;
&lt;p&gt;First test: &quot;Walk this way, turn around, walk back.&quot; Technically a loop, but
boring as hell.&lt;&#x2F;p&gt;
&lt;p&gt;The issue was too many waypoints creating short segments. The fix was the
algorithm changes I mentioned earlier: fewer waypoints, larger radius,
directional rotation. Now routes actually explore different areas instead
of just oscillating.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;typescript-across-the-stack&quot;&gt;TypeScript Across the Stack&lt;&#x2F;h2&gt;
&lt;p&gt;I initially questioned whether TypeScript was overkill for this project.
Turns out it was perfect.&lt;&#x2F;p&gt;
&lt;p&gt;Shared types between frontend and backend caught so many potential bugs:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;typescript&quot; class=&quot;language-typescript &quot;&gt;&lt;code class=&quot;language-typescript&quot; data-lang=&quot;typescript&quot;&gt;export interface Coordinate {
  lat: number;
  lon: number;
}

export interface GeneratedRoute {
  id: number;
  points: Coordinate[];
  waypoints: Coordinate[];
  isLoop: boolean;
  distance?: number;
  duration?: number;
  steps?: RouteStep[];
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;When the backend returns &lt;code&gt;GeneratedRoute[]&lt;&#x2F;code&gt;, the frontend knows exactly
what shape to expect. No guessing, no runtime surprises.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;development-environment&quot;&gt;Development Environment&lt;&#x2F;h2&gt;
&lt;p&gt;Created a Nix shell for reproducible development:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;{ pkgs ? import &amp;lt;nixpkgs&amp;gt; {} }:

pkgs.mkShell {
  buildInputs = with pkgs; [
    nodejs_22
    typescript
    nodePackages.tsx
  ];
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Anyone can clone the repo, run &lt;code&gt;nix-shell&lt;&#x2F;code&gt;, and have the exact same
environment. No &quot;works on my machine&quot; issues.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;result&quot;&gt;Result&lt;&#x2F;h2&gt;
&lt;p&gt;The app works. You enter an address, pick a mode (walk&#x2F;bike&#x2F;car), set a
distance, and hit generate. It shows you multiple random routes on an
interactive map with turn-by-turn directions.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;slumprutt-1.png&quot; alt=&quot;Initial interface with form inputs&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;slumprutt-2.png&quot; alt=&quot;Generated routes displayed on map&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;slumprutt-3.png&quot; alt=&quot;Route highlighting on hover&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;slumprutt-4.png&quot; alt=&quot;Turn-by-turn walking directions&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;ve already used it to find new walking routes in my neighborhood.&lt;&#x2F;p&gt;
&lt;p&gt;Is it perfect? No, it&#x27;s not. You will get routes that go down sketchy roads or
routes that still go back and forth. But it was fun and it&#x27;s helpful to me.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Try it yourself&lt;&#x2F;strong&gt;: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;slumprutt&quot;&gt;github.com&#x2F;ducks&#x2F;slumprutt&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;&#x2F;h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;OSRM is incredible&lt;&#x2F;strong&gt; - Free, fast, accurate. No API key required. If
you&#x27;re building anything with routing, use OSRM.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TypeScript prevents so many bugs&lt;&#x2F;strong&gt; - Especially for coordinate&#x2F;route
data that flows through multiple layers. Type errors at compile time
beat runtime errors every time.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fewer waypoints make better routes&lt;&#x2F;strong&gt; - Counterintuitive, but 3-5
waypoints create more interesting loops than 8-10.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Svelte&#x27;s reactivity is clean&lt;&#x2F;strong&gt; - No useState&#x2F;useEffect boilerplate.
Just &lt;code&gt;$: if (condition) { doThing() }&lt;&#x2F;code&gt; and it works.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Building with AI is wild&lt;&#x2F;strong&gt; - Went from idea to working app in under
an hour. Not just scaffolding, but a fully functional app with an
interesting algorithm, API integration, and polished UI.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
</description>
      </item>
      <item>
          <title>AWS SDK Ruby Part 3: When &quot;Working as Intended&quot; Means &quot;Broken by Design&quot;</title>
          <pubDate>Tue, 14 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby-pt3/</link>
          <guid>https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby-pt3/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby-pt3/">&lt;h3 id=&quot;the-saga-continues&quot;&gt;The Saga Continues&lt;&#x2F;h3&gt;
&lt;p&gt;If you&#x27;ve been following along (&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;contributing-to-aws-sdk-ruby&#x2F;&quot;&gt;Part
1&lt;&#x2F;a&gt; and
&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;contributing-to-aws-sdk-ruby-pt2&#x2F;&quot;&gt;Part
2&lt;&#x2F;a&gt;),
you know I&#x27;ve been on a journey fixing AWS SDK Ruby credential handling. First
I added the missing &lt;code&gt;credential_source = Environment&lt;&#x2F;code&gt; implementation, then I
discovered it didn&#x27;t actually work due to credential chain precedence issues.&lt;&#x2F;p&gt;
&lt;p&gt;After extensive testing and code analysis, I submitted &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;pull&#x2F;3303&quot;&gt;PR
#3303&lt;&#x2F;a&gt; with a fix. The
maintainer initially responded positively:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Hey, thanks for opening an issue. I believe you are correct and this is
something we need to fix.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;Great! But then...&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-reversal&quot;&gt;The Reversal&lt;&#x2F;h3&gt;
&lt;p&gt;After I provided detailed testing comparing AWS CLI behavior, demonstrated the
fix working in production, and showed exactly which code was unreachable due to
the chain precedence, the maintainer walked it back:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;From what I understand, I think the default chain will try to resolve
credentials as quickly as possible, so if environment variables are set it will
use them directly instead of using them as a credential source. We try not to
change the behavior of the default chain since returning credentials in a
different order can break many customers.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;And then closed both the issue and PR.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-this-is-wrong&quot;&gt;Why This Is Wrong&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;1. The Feature Is Completely Broken&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;When you configure:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ini&quot; class=&quot;language-ini &quot;&gt;&lt;code class=&quot;language-ini&quot; data-lang=&quot;ini&quot;&gt;[default]
role_arn = arn:aws:iam::123456789012:role&amp;#x2F;MyRole
credential_source = Environment
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;What happens?&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;env_credentials&lt;&#x2F;code&gt; (position 7) finds &lt;code&gt;AWS_ACCESS_KEY_ID&lt;&#x2F;code&gt; and
&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Chain returns those credentials and stops&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;assume_role_credentials&lt;&#x2F;code&gt; (position 10) never executes&lt;&#x2F;li&gt;
&lt;li&gt;The &lt;code&gt;role_arn&lt;&#x2F;code&gt; and &lt;code&gt;credential_source&lt;&#x2F;code&gt; configuration is &lt;strong&gt;completely
ignored&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;The maintainer confirmed this: &quot;All of the &lt;code&gt;static_profile_&lt;&#x2F;code&gt; methods handle
credentials resolution when a client is configured explicitly with a profile.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;So the feature works... but only if you don&#x27;t use the credential chain, which
defeats the entire purpose of having a credential chain.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;2. The Code Contradicts Their Argument&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Look at
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;blob&#x2F;version-3&#x2F;gems&#x2F;aws-sdk-core&#x2F;lib&#x2F;aws-sdk-core&#x2F;shared_config.rb#L392-L400&quot;&gt;&lt;code&gt;shared_config.rb:392-400&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def credentials_from_source(credential_source, config)
  case credential_source
  when &amp;#x27;Ec2InstanceMetadata&amp;#x27;
    # ...
  when &amp;#x27;EcsContainer&amp;#x27;
    # ...
  when &amp;#x27;Environment&amp;#x27;
    creds = Credentials.new(
      ENV[&amp;#x27;AWS_ACCESS_KEY_ID&amp;#x27;],
      ENV[&amp;#x27;AWS_SECRET_ACCESS_KEY&amp;#x27;],
      ENV[&amp;#x27;AWS_SESSION_TOKEN&amp;#x27;]
    )
    creds.metrics = [&amp;#x27;CREDENTIALS_ENV_VARS&amp;#x27;]
    creds
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This code exists specifically to use environment variables as source credentials
for role assumption. But the credential chain prevents it from ever executing
when using the default profile.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;3. The Workaround Proves It&#x27;s Broken&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The maintainer suggested: &quot;if specifying the profile resolves your issue I
would recommend doing that.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;But that&#x27;s not a solution, it&#x27;s an admission the feature is broken. The
credential chain exists so you DON&#x27;T have to explicitly pass &lt;code&gt;profile:&lt;&#x2F;code&gt;
parameters everywhere. Setting &lt;code&gt;AWS_PROFILE=default&lt;&#x2F;code&gt; or using the default
profile should work. That&#x27;s the entire design philosophy.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;4. Who Would This &quot;Break&quot;?&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The maintainer worried about &quot;breaking many customers.&quot; But let&#x27;s think about
this:&lt;&#x2F;p&gt;
&lt;p&gt;My fix only affects users who have &lt;strong&gt;both&lt;&#x2F;strong&gt; of these conditions:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A profile with &lt;code&gt;role_arn&lt;&#x2F;code&gt; AND &lt;code&gt;credential_source = Environment&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Environment variables &lt;code&gt;AWS_ACCESS_KEY_ID&lt;&#x2F;code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;&#x2F;code&gt; set&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If someone configures both, they&#x27;re explicitly declaring: &quot;use these
environment variables AS SOURCE CREDENTIALS to assume this role.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;Who would configure this and expect it to be ignored? That&#x27;s not a customer
being protected, that&#x27;s a broken feature being excused.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-i-did-instead&quot;&gt;What I Did Instead&lt;&#x2F;h3&gt;
&lt;p&gt;Since the AWS team won&#x27;t fix the credential chain, I worked around it by
explicitly passing the &lt;code&gt;profile:&lt;&#x2F;code&gt; parameter to AWS SDK clients. This bypasses
the broken credential chain entirely.&lt;&#x2F;p&gt;
&lt;p&gt;The approach is simple:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def aws_client_options
  opts = { region: config.region }

  # Explicitly pass profile if configured
  if config.profile.present?
    opts[:profile] = config.profile
  elsif config.access_key_id.present? &amp;amp;&amp;amp; config.secret_key.present?
    opts[:access_key_id] = config.access_key_id
    opts[:secret_access_key] = config.secret_key
  end
  # Otherwise omit credentials, let SDK auto-discover

  opts
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;With AWS config:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ini&quot; class=&quot;language-ini &quot;&gt;&lt;code class=&quot;language-ini&quot; data-lang=&quot;ini&quot;&gt;[profile app-uploads]
role_arn = arn:aws:iam::123456789012:role&amp;#x2F;AppUploads
credential_source = Environment
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This works because explicitly passing &lt;code&gt;profile:&lt;&#x2F;code&gt; to the SDK bypasses the broken
credential chain and goes directly to the profile-based role assumption code.
It&#x27;s not fixing the SDK bug, but it enables working role assumption with
temporary credentials instead of long-lived static keys.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-i-learned-the-hard-way&quot;&gt;What I Learned (The Hard Way)&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;Open source maintainers aren&#x27;t always right.&lt;&#x2F;strong&gt; Even when they&#x27;re from AWS,
even when they&#x27;re maintaining critical infrastructure, they can make bad
decisions.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Document your wins even when they don&#x27;t get merged.&lt;&#x2F;strong&gt; This fix works in
production. Other developers will hit this same issue. Having a public record
of the problem and solution helps everyone.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The bug report itself has value.&lt;&#x2F;strong&gt; Even though they closed it, the issue and
PR document exactly what&#x27;s broken and why. Future developers will find it and
understand they&#x27;re not crazy. The feature really is broken.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-bigger-picture&quot;&gt;The Bigger Picture&lt;&#x2F;h3&gt;
&lt;p&gt;This experience reminded me technical correctness doesn&#x27;t always win.&lt;&#x2F;p&gt;
&lt;p&gt;I had:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Clear reproduction steps&lt;&#x2F;li&gt;
&lt;li&gt;Root cause analysis with line numbers&lt;&#x2F;li&gt;
&lt;li&gt;A working fix with tests&lt;&#x2F;li&gt;
&lt;li&gt;Production validation&lt;&#x2F;li&gt;
&lt;li&gt;Comparison with AWS CLI behavior&lt;&#x2F;li&gt;
&lt;li&gt;Maintainer acknowledgment that it was broken&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And they still closed it.&lt;&#x2F;p&gt;
&lt;p&gt;Sometimes the politics of &quot;don&#x27;t change anything&quot; beat &quot;fix the broken
feature.&quot; That&#x27;s frustrating, but it&#x27;s reality.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;for-other-contributors&quot;&gt;For Other Contributors&lt;&#x2F;h3&gt;
&lt;p&gt;If you hit this issue:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Yes, &lt;code&gt;credential_source = Environment&lt;&#x2F;code&gt; is broken with the default credential
chain&lt;&#x2F;li&gt;
&lt;li&gt;No, it&#x27;s not you, it&#x27;s the SDK&lt;&#x2F;li&gt;
&lt;li&gt;The workaround is explicitly passing &lt;code&gt;profile:&lt;&#x2F;code&gt; to clients (or using
&lt;code&gt;source_profile&lt;&#x2F;code&gt; with plaintext keys)&lt;&#x2F;li&gt;
&lt;li&gt;Explicitly passing the profile parameter bypasses the broken chain and
enables proper role assumption&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;For AWS maintainers reading this:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;You have unreachable code in &lt;code&gt;credentials_from_source&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Your credential chain prevents documented features from working&lt;&#x2F;li&gt;
&lt;li&gt;&quot;Breaking changes&quot; is a poor excuse for not fixing broken features&lt;&#x2F;li&gt;
&lt;li&gt;The feature is already broken for everyone using default profiles&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;the-end-for-now&quot;&gt;The End (For Now)&lt;&#x2F;h3&gt;
&lt;p&gt;This is where the story ends. I found a workaround, documented it for others,
and moved on.&lt;&#x2F;p&gt;
&lt;p&gt;Sometimes that&#x27;s all you can do. Sometimes &quot;no&quot; from maintainers means finding
another path. Sometimes proving you&#x27;re technically correct doesn&#x27;t matter if
the project isn&#x27;t willing to fix their bugs.&lt;&#x2F;p&gt;
&lt;p&gt;But hey, at least the deployment works now. And maybe this blog series will
help the next developer who hits this same wall and wonders why a documented
feature doesn&#x27;t actually work.&lt;&#x2F;p&gt;
&lt;p&gt;The adventure is over. The bug remains. The workaround persists.&lt;&#x2F;p&gt;
&lt;p&gt;Such is open source.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building Yaks: A Virtual Currency System for Discourse (Part 1: Backend Architecture)</title>
          <pubDate>Sat, 11 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-1/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-1/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-yaks-virtual-currency-part-1/">&lt;p&gt;I&#x27;m building a virtual currency system for Discourse. Users earn and spend
&quot;Yaks&quot; on premium post features like colored highlighting, pinned posts, and
custom user flair.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-wallet.png&quot; alt=&quot;Image showing 1000 Yaks in wallet&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;yaks-highlighted-post-1.png&quot; alt=&quot;Post highlighting with purple border&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This is Part 1 of a series documenting the development. This post focuses on
the backend architecture. Future posts will cover the frontend UI, earning
mechanisms, and feature implementations.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-yaks&quot;&gt;Why Yaks?&lt;&#x2F;h2&gt;
&lt;p&gt;The name works on two levels. First, &quot;yak&quot; as a verb means to talk or chat
persistently. Forums are where people yak.&lt;&#x2F;p&gt;
&lt;p&gt;Second, &quot;yak shaving&quot; is programmer slang for doing a seemingly pointless
series of tasks. Unfortunately, sometimes, talking (arguing) with people online
can feel apparently useless. The name acknowledges that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-virtual-currency&quot;&gt;Why Virtual Currency?&lt;&#x2F;h2&gt;
&lt;p&gt;Forums have moderation tools and permission systems, but they&#x27;re binary: you
can do something or you can&#x27;t. Virtual currency adds a middle layer where users
can temporarily access premium features by spending earned currency.&lt;&#x2F;p&gt;
&lt;p&gt;The use cases:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Highlight important posts with colored borders&lt;&#x2F;li&gt;
&lt;li&gt;Pin your post to the top of a topic for 24 hours&lt;&#x2F;li&gt;
&lt;li&gt;Add custom flair next to your username&lt;&#x2F;li&gt;
&lt;li&gt;Boost your post in feeds and search results&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;These aren&#x27;t permissions. They&#x27;re temporary, purchasable upgrades.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-data-model&quot;&gt;The Data Model&lt;&#x2F;h2&gt;
&lt;p&gt;The system has four core models:&lt;&#x2F;p&gt;
&lt;h3 id=&quot;yakwallet&quot;&gt;YakWallet&lt;&#x2F;h3&gt;
&lt;p&gt;Each user has a wallet that tracks their balance and lifetime totals:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class YakWallet &amp;lt; ActiveRecord::Base
  belongs_to :user
  has_many :yak_transactions

  validates :balance, numericality: { greater_than_or_equal_to: 0 }
  validates :lifetime_earned, numericality: { greater_than_or_equal_to: 0 }
  validates :lifetime_spent, numericality: { greater_than_or_equal_to: 0 }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The wallet is the source of truth for a user&#x27;s currency. Every earn, spend, and
refund goes through it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;yaktransaction&quot;&gt;YakTransaction&lt;&#x2F;h3&gt;
&lt;p&gt;Every balance change is logged. This creates an immutable audit trail:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class YakTransaction &amp;lt; ActiveRecord::Base
  belongs_to :user
  belongs_to :yak_wallet

  enum transaction_type: { earn: 0, spend: 1, refund: 2 }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Transactions include:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Amount (positive for earn&#x2F;refund, negative for spend)&lt;&#x2F;li&gt;
&lt;li&gt;Type (earn, spend, refund)&lt;&#x2F;li&gt;
&lt;li&gt;Source (where it came from: &lt;code&gt;stripe_purchase&lt;&#x2F;code&gt;, &lt;code&gt;quality_post&lt;&#x2F;code&gt;,
&lt;code&gt;feature_post_highlight&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Description (human-readable explanation)&lt;&#x2F;li&gt;
&lt;li&gt;Metadata (JSON field for additional context)&lt;&#x2F;li&gt;
&lt;li&gt;Related post&#x2F;topic (if applicable)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Why log everything? Transparency. Users can see exactly where their Yaks went.
Admins can debug balance issues. Refunds are straightforward because you have
the original transaction ID.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;yakfeature&quot;&gt;YakFeature&lt;&#x2F;h3&gt;
&lt;p&gt;Features define what users can spend Yaks on:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class YakFeature &amp;lt; ActiveRecord::Base
  has_many :yak_feature_uses

  validates :feature_key, presence: true, uniqueness: true
  validates :cost, numericality: { greater_than: 0 }
  validates :category, inclusion: { in: %w[post user topic] }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each feature has:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A unique key (&lt;code&gt;post_highlight&lt;&#x2F;code&gt;, &lt;code&gt;post_pin&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Display name and description&lt;&#x2F;li&gt;
&lt;li&gt;Cost in Yaks&lt;&#x2F;li&gt;
&lt;li&gt;Category (post, user, topic)&lt;&#x2F;li&gt;
&lt;li&gt;Settings (duration, default values, constraints)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Currently implemented:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Post Highlighting&lt;&#x2F;strong&gt; (25 Yaks): Colored border and background (gold, blue,
red, green, purple)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Planned features:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Post Pin&lt;&#x2F;strong&gt; (50 Yaks): Pin to top of topic for 24 hours&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Custom Flair&lt;&#x2F;strong&gt; (100 Yaks): Custom text next to username for 30 days&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Post Boost&lt;&#x2F;strong&gt; (30 Yaks): Priority in feeds for 72 hours&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The cost is per feature, not per variation. Post highlighting costs 25 Yaks
regardless of which color you choose.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;yakfeatureuse&quot;&gt;YakFeatureUse&lt;&#x2F;h3&gt;
&lt;p&gt;When a user purchases a feature, we create a YakFeatureUse record:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class YakFeatureUse &amp;lt; ActiveRecord::Base
  belongs_to :user
  belongs_to :yak_feature
  belongs_to :yak_transaction
  belongs_to :related_post, class_name: &amp;quot;Post&amp;quot;, optional: true
  belongs_to :related_topic, class_name: &amp;quot;Topic&amp;quot;, optional: true

  scope :active, -&amp;gt; { where(&amp;quot;expires_at IS NULL OR expires_at &amp;gt; ?&amp;quot;, Time.zone.now) }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This tracks:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Who applied the feature&lt;&#x2F;li&gt;
&lt;li&gt;Which feature was applied&lt;&#x2F;li&gt;
&lt;li&gt;What post&#x2F;topic it was applied to&lt;&#x2F;li&gt;
&lt;li&gt;When it expires (if applicable)&lt;&#x2F;li&gt;
&lt;li&gt;Feature-specific data (color choice, custom text, etc.)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The &lt;code&gt;active&lt;&#x2F;code&gt; scope makes it easy to query currently active features and clean
up expired ones.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-service-layer&quot;&gt;The Service Layer&lt;&#x2F;h2&gt;
&lt;p&gt;Business logic lives in services, not controllers. Controllers handle HTTP,
services handle business rules.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-are-services&quot;&gt;What Are Services?&lt;&#x2F;h3&gt;
&lt;p&gt;Discourse has a standardized service pattern using &lt;code&gt;Service::Base&lt;&#x2F;code&gt;. Services
define:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Contracts&lt;&#x2F;strong&gt;: Input validation using schemas&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Policies&lt;&#x2F;strong&gt;: Preconditions that must be true&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Steps&lt;&#x2F;strong&gt;: The actual execution flow&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Example structure:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;class MyService &amp;lt; Service::Base
  contract do
    attribute :user_id, :integer
    validates :user_id, presence: true
  end

  policy :user_exists
  step :do_work

  private

  def user_exists
    User.exists?(id: contract.user_id)
  end

  def do_work
    # actual logic
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This pattern separates validation, authorization, and execution. If the
contract fails, the service returns an error before hitting any business logic.
If a policy fails, execution stops.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;yakfeatureservice&quot;&gt;YakFeatureService&lt;&#x2F;h3&gt;
&lt;p&gt;The Yaks plugin currently uses a simpler service pattern (a plain Ruby class
with class methods) because the logic is straightforward. It could be
refactored to &lt;code&gt;Service::Base&lt;&#x2F;code&gt; if validation becomes more complex.&lt;&#x2F;p&gt;
&lt;p&gt;The service handles:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Validating the feature exists and is enabled&lt;&#x2F;li&gt;
&lt;li&gt;Checking the user can afford it&lt;&#x2F;li&gt;
&lt;li&gt;Checking the feature can be applied (no duplicate active uses)&lt;&#x2F;li&gt;
&lt;li&gt;Creating the transaction&lt;&#x2F;li&gt;
&lt;li&gt;Creating the feature use record&lt;&#x2F;li&gt;
&lt;li&gt;Applying the visual effects&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.apply_feature(user, feature_key, related_post: nil, feature_data: {})
  feature = YakFeature.enabled.find_by(feature_key: feature_key)
  return { success: false, error: &amp;quot;Feature not found&amp;quot; } unless feature

  return { success: false, error: &amp;quot;Insufficient balance&amp;quot; } unless feature.affordable_by?(user)

  wallet = YakWallet.for_user(user)

  transaction = wallet.spend_yaks(
    feature.cost,
    feature_key,
    &amp;quot;Applied #{feature.feature_name}&amp;quot;,
    related_post_id: related_post&amp;amp;.id,
    metadata: feature_data
  )

  return { success: false, error: &amp;quot;Insufficient balance&amp;quot; } unless transaction

  feature_use = YakFeatureUse.create!(
    user: user,
    yak_feature: feature,
    yak_transaction: transaction,
    related_post: related_post,
    expires_at: calculate_expiration(feature),
    feature_data: feature_data
  )

  apply_feature_effects(feature_key, related_post, feature_data)

  { success: true, feature_use: feature_use, new_balance: user.yak_balance }
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Why put this in a service instead of the controller? Because controllers should
handle HTTP concerns (params, rendering, status codes). Business logic
(validating affordability, deducting currency, applying effects) belongs in a
service.&lt;&#x2F;p&gt;
&lt;p&gt;This makes testing easier. You can test the business logic without setting up
HTTP requests. You can reuse the service from rake tasks, background jobs, or
the Rails console.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-double-balance-check&quot;&gt;The Double Balance Check&lt;&#x2F;h3&gt;
&lt;p&gt;Notice the service checks affordability twice:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# First check
return { success: false, error: &amp;quot;Insufficient balance&amp;quot; } unless feature.affordable_by?(user)

wallet = YakWallet.for_user(user)

# Second check (wallet.spend_yaks returns nil if balance insufficient)
transaction = wallet.spend_yaks(...)
return { success: false, error: &amp;quot;Insufficient balance&amp;quot; } unless transaction
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Why check twice? Race conditions.&lt;&#x2F;p&gt;
&lt;p&gt;Between the first check and the actual spend, another request could deduct from
the user&#x27;s balance. Without the second check, you could end up with negative
balances.&lt;&#x2F;p&gt;
&lt;p&gt;The first check is an optimization (fail fast before loading the wallet). The
second check is correctness (verify balance inside the database transaction).&lt;&#x2F;p&gt;
&lt;p&gt;The wallet&#x27;s &lt;code&gt;spend_yaks&lt;&#x2F;code&gt; method uses ActiveRecord transactions:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def spend_yaks(amount, feature_key, description, options = {})
  return nil if amount &amp;lt;= 0 || balance &amp;lt; amount

  transaction do
    decrement!(:balance, amount)
    increment!(:lifetime_spent, amount)
    yak_transactions.create!(...)
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;If the balance check fails inside the transaction, it returns &lt;code&gt;nil&lt;&#x2F;code&gt; and nothing
is deducted. This prevents concurrent requests from causing overdrafts.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;applying-features&quot;&gt;Applying Features&lt;&#x2F;h2&gt;
&lt;p&gt;Features modify post custom fields:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def self.apply_feature_effects(feature_key, post, feature_data)
  current_features = post.custom_fields[&amp;quot;yak_features&amp;quot;] || {}

  case feature_key
  when &amp;quot;post_highlight&amp;quot;
    current_features[&amp;quot;highlight&amp;quot;] = {
      enabled: true,
      color: feature_data[:color] || &amp;quot;gold&amp;quot;,
      applied_at: Time.zone.now.to_i
    }
  when &amp;quot;post_pin&amp;quot;
    current_features[&amp;quot;pinned&amp;quot;] = { enabled: true, applied_at: Time.zone.now.to_i }
  end

  post.custom_fields[&amp;quot;yak_features&amp;quot;] = current_features
  post.save_custom_fields
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Custom fields are Discourse&#x27;s way of extending models without migrations. The
&lt;code&gt;yak_features&lt;&#x2F;code&gt; field stores a JSON object with all active features on a post.&lt;&#x2F;p&gt;
&lt;p&gt;This data gets serialized to the frontend:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;add_to_serializer(
  :post,
  :yak_features,
  include_condition: -&amp;gt; { object.custom_fields[&amp;quot;yak_features&amp;quot;].present? }
) do
  object.custom_fields[&amp;quot;yak_features&amp;quot;]
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The frontend can then read &lt;code&gt;post.yak_features.highlight.color&lt;&#x2F;code&gt; and apply the
appropriate CSS.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;current-state&quot;&gt;Current State&lt;&#x2F;h2&gt;
&lt;p&gt;The backend is complete:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Wallet management with balance tracking&lt;&#x2F;li&gt;
&lt;li&gt;Transaction logging with full audit trail&lt;&#x2F;li&gt;
&lt;li&gt;Feature definitions with costs and durations&lt;&#x2F;li&gt;
&lt;li&gt;Service layer for applying features&lt;&#x2F;li&gt;
&lt;li&gt;Custom field serialization for the frontend&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;What&#x27;s missing:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Frontend UI for viewing balance and purchasing features&lt;&#x2F;li&gt;
&lt;li&gt;Earning mechanisms (quality posts, admin grants, purchases)&lt;&#x2F;li&gt;
&lt;li&gt;Feature expiration cleanup job&lt;&#x2F;li&gt;
&lt;li&gt;Admin dashboard for managing features and viewing stats&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;next-steps&quot;&gt;Next Steps&lt;&#x2F;h2&gt;
&lt;p&gt;Part 2 will cover building the frontend UI: displaying the user&#x27;s balance,
browsing available features, and adding a &quot;spend Yaks&quot; button to posts.&lt;&#x2F;p&gt;
&lt;p&gt;Part 3 will cover earning mechanisms and the admin dashboard.&lt;&#x2F;p&gt;
&lt;p&gt;The code is on GitHub: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-yaks&quot;&gt;ducks&#x2F;discourse-yaks&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 41&#x2F;52</title>
          <pubDate>Fri, 10 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-41/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-41/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-41/">&lt;p&gt;This week centered on publishing, polishing, and reflecting on your Discourse
work, with some light data analysis and side discussions along the way.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Blog and Writing&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You published &lt;em&gt;&quot;Building a Live Transit Departure Board with Discourse&quot;&lt;&#x2F;em&gt;, a
follow-up to your earlier post about your first Discourse core contribution.
The piece explored how you built a live transit tracker plugin that reimagines
Discourse as a real-time data board rather than a forum. You asked if posting
it on the official Discourse blog would be weird, ultimately deciding it would
make sense given your role there. You also asked if the piece itself was
interesting -- it was, both technically and conceptually, tying in your love of
open data and infrastructure with your day job.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Data and Analytics&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You dug into your GoatCounter analytics, examining visitor distribution across
countries. After cleaning up duplicate entries, you found &lt;strong&gt;37 unique countries&lt;&#x2F;strong&gt;
represented, spanning &lt;strong&gt;six continents&lt;&#x2F;strong&gt; -- North America, South America, Europe,
Asia, Africa, and Oceania. Europe and Asia were the strongest regions overall.
The data was reformatted into a clean markdown table for future reference.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Open Source and Development&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You briefly noted another bug you found in the AWS SDK for Ruby, following your
previous fix and contribution, reinforcing your growing involvement in that
project&#x27;s ecosystem.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Overall&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;It was a quieter week but a meaningful one -- marked by the satisfaction of
publishing a well-crafted post that blends creativity and engineering. The
analytics work helped ground your readership data, while your continued OSS
engagement kept your technical momentum going.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building a Live Transit Departure Board with Discourse</title>
          <pubDate>Wed, 08 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-transit-tracker-in-discourse/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-transit-tracker-in-discourse/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-transit-tracker-in-discourse/">&lt;p&gt;I&#x27;ve been working at Discourse for a few months now, learning how flexible
the platform is. It&#x27;s a forum. It&#x27;s designed for conversations. But what if
we used it for something completely different?&lt;&#x2F;p&gt;
&lt;p&gt;Something like tracking flights and trains?&lt;&#x2F;p&gt;
&lt;p&gt;I wanted to learn more about Discourse by building something that felt real and
that would force me into actual problems. Most importantly though, I wanted to
build something that involves my interests and would be fun. I love transit
infrastructure and open data so why not a transit tracker using free government
feeds? I had this image in my head: those old split-flap airport departure
boards, the ones that click and whir as letters rotate into place.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-source.jpg&quot; alt=&quot;Split-flap departure board&quot; &#x2F;&gt;
&lt;em&gt;Photo by &lt;a href=&quot;https:&#x2F;&#x2F;unsplash.com&#x2F;@amsterdamcityarchives&quot;&gt;Amsterdam City Archives&lt;&#x2F;a&gt; on
&lt;a href=&quot;https:&#x2F;&#x2F;unsplash.com&#x2F;photos&#x2F;7diobitpahY&quot;&gt;Unsplash&lt;&#x2F;a&gt;&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;What if I could recreate that aesthetic, but powered by Discourse topics
instead of actual flights?&lt;&#x2F;p&gt;
&lt;p&gt;You can see the result here: &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-transit-tracker&quot;&gt;discourse-transit-tracker on
GitHub&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-this-makes-no-sense-and-why-i-did-it-anyway&quot;&gt;Why This Makes No Sense (And Why I Did It Anyway)&lt;&#x2F;h2&gt;
&lt;p&gt;Let&#x27;s be clear: &lt;strong&gt;Discourse is not a transit tracking system&lt;&#x2F;strong&gt;. It&#x27;s a
forum platform built for human conversations, not GTFS feeds and real-time
departure data.&lt;&#x2F;p&gt;
&lt;p&gt;But that&#x27;s exactly what made it interesting.&lt;&#x2F;p&gt;
&lt;p&gt;Discourse topics are incredibly flexible. They have custom fields, tags,
categories, and a robust permission system. If you squint hard enough, a
flight departure is just a &quot;post&quot; with structured data. The departure time?
A custom field. The status (on-time, delayed, departed)? A tag. The
airline? Maybe a category.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s ridiculous. But it &lt;em&gt;could work&lt;&#x2F;em&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;learning-with-claude-code&quot;&gt;Learning with Claude Code&lt;&#x2F;h2&gt;
&lt;p&gt;I built this entire plugin using Claude Code, Anthropic&#x27;s CLI tool. Not
because I couldn&#x27;t write it myself, but because I wanted to learn Discourse
patterns while writing quality code from the start.&lt;&#x2F;p&gt;
&lt;p&gt;&quot;Vibe programming&quot; gets a bad rap. People hear &quot;AI-assisted development&quot;
and think it&#x27;s about blindly accepting generated code. But that&#x27;s not how I
used it. Claude Code became a learning tool. I&#x27;d describe what I wanted to
build, Claude would suggest an approach using Discourse conventions, and
I&#x27;d understand why those patterns exist.&lt;&#x2F;p&gt;
&lt;p&gt;This allowed me to learn the platform faster than reading docs alone would
have taught me. I saw real implementations of custom fields, service
objects, Ember components, and ActiveRecord patterns. And because Claude
follows Discourse&#x27;s style guide and architecture, the code I wrote actually
fits the codebase.&lt;&#x2F;p&gt;
&lt;p&gt;This is what good AI-assisted development looks like: not replacing
understanding, but accelerating it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-technical-stack&quot;&gt;The Technical Stack&lt;&#x2F;h2&gt;
&lt;p&gt;The plugin integrates three data sources, each with its own challenges.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;amtrak-gtfs&quot;&gt;Amtrak (GTFS)&lt;&#x2F;h3&gt;
&lt;p&gt;I built an &lt;code&gt;AmtrakGtfsService&lt;&#x2F;code&gt; that downloads Amtrak&#x27;s GTFS feed (a ZIP
file with CSVs), parses routes, stops, trips, and schedules, and creates
departure topics with detailed stop information. No API key required.&lt;&#x2F;p&gt;
&lt;p&gt;The service:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Downloads and extracts &lt;code&gt;GTFS.zip&lt;&#x2F;code&gt; from Amtrak&#x27;s CDN&lt;&#x2F;li&gt;
&lt;li&gt;Parses &lt;code&gt;routes.txt&lt;&#x2F;code&gt;, &lt;code&gt;stops.txt&lt;&#x2F;code&gt;, &lt;code&gt;trips.txt&lt;&#x2F;code&gt;, and &lt;code&gt;stop_times.txt&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;For each trip in the next 24 hours, creates a topic with:
&lt;ul&gt;
&lt;li&gt;All basic departure info as custom fields&lt;&#x2F;li&gt;
&lt;li&gt;A detailed stops array with lat&#x2F;lon coordinates and times&lt;&#x2F;li&gt;
&lt;li&gt;A formatted schedule table as Post #2&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;Running &lt;code&gt;bin&#x2F;rake transit_tracker:import_amtrak&lt;&#x2F;code&gt; processes ~2,300 trips and
creates ~600 departure topics (many trips share the same departure, so they
get merged).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The Problem: &quot;Title has already been used&quot;&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The first import run worked fine, processing ~2,300 trips and creating ~600
topics. But when I ran it again to test updates, I hit a wall:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Processing 340 trips...
Created 1 topic
Error: Title has already been used (339 times)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Only 1 out of 340 trips succeeded on the second run. The rest failed.&lt;&#x2F;p&gt;
&lt;p&gt;Discourse requires unique topic titles. My title format was: &lt;code&gt;&quot;City of New Orleans to New Orleans at 19:05&quot;&lt;&#x2F;code&gt;. Multiple different trains with the same
route, destination, and departure minute produced identical titles.&lt;&#x2F;p&gt;
&lt;p&gt;My lookup strategy was to find topics by &lt;code&gt;trip_id&lt;&#x2F;code&gt; + &lt;code&gt;service_date&lt;&#x2F;code&gt; stored
in custom fields. That worked fine when topics existed. But on subsequent
runs, when a topic wasn&#x27;t found by custom field (maybe the &lt;code&gt;trip_id&lt;&#x2F;code&gt;
changed), I&#x27;d try to create one, and Discourse would reject it because
another train had already claimed that title.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The Fix: Fallback Lookup by Title&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The solution was to add a fallback lookup by title before trying to create:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# First, try to find by trip_id + service_date (the ideal natural key)
topic = Topic.joins(:_custom_fields)
  .where(topic_custom_fields: {
    name: &amp;quot;transit_trip_id&amp;quot;,
    value: attributes[:trip_id]
  })
  .where(...)
  .first

# If still no match, try to find by title to avoid duplicates
if !topic
  title = build_title(attributes)
  category_id = determine_category(attributes[:mode])
  topic = Topic.where(title: title, category_id: category_id).first
  if topic
    Rails.logger.info &amp;quot;[TransitTracker] Found existing topic by title,
      will update&amp;quot;
    is_new = false
  end
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This way, if multiple trips share a title, they merge into the same topic
and get updated instead of failing.&lt;&#x2F;p&gt;
&lt;p&gt;Result: &lt;strong&gt;327 departures created, 0 errors&lt;&#x2F;strong&gt;. All trips within the 24-hour
window imported successfully. The duplicate trips just update the same
topic with fresh data.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nyc-mta-subway-gtfs&quot;&gt;NYC MTA Subway (GTFS)&lt;&#x2F;h3&gt;
&lt;p&gt;The NYC MTA subway system is massive. Their GTFS feed contains over 500,000
stop times covering weeks of schedules across dozens of routes.&lt;&#x2F;p&gt;
&lt;p&gt;My first approach was simple: import everything, just like I did with
Amtrak. Parse the entire feed, create topics for every departure in the
next 24 hours.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The Problem: 19GB of RAM&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;I ran the import and watched my Rails process climb. 1GB. 5GB. 10GB. It
kept going. By the time it finished parsing, &lt;strong&gt;it had consumed over 19GB of
RAM&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Loading and processing 500k+ stop times to create 20,000+ Discourse topics
consumed massive amounts of memory. At scale, big data creates big
problems.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The Fix: Reduce the Time Window&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The fix? Reduce the import window from 24 hours to 6 hours:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# Only import departures within the next 6 hours
dep_time = parse_gtfs_time(today, first_stop[:departure_time])
next if dep_time &amp;lt; now || dep_time &amp;gt; (now + 6.hours)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Result: ~5,000 topics instead of 20,000+, memory usage stayed under 2GB,
and the board still shows plenty of departures. For a live departure board,
you don&#x27;t need train schedules from tomorrow anyway.&lt;&#x2F;p&gt;
&lt;p&gt;The final implementation includes official MTA line colors (red 1&#x2F;2&#x2F;3,
green 4&#x2F;5&#x2F;6, yellow N&#x2F;Q&#x2F;R&#x2F;W, etc.) and creates ~5,000 departure topics with
complete schedules.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;aviationstack-api&quot;&gt;AviationStack API&lt;&#x2F;h3&gt;
&lt;p&gt;Tracks flight departures with gate assignments, delays, and code-share
detection. Requires an API key from &lt;a href=&quot;https:&#x2F;&#x2F;aviationstack.com&#x2F;&quot;&gt;aviationstack.com&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The Problem: Duplicate Code-Share Flights&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Multiple airlines sell seats on the same physical flight under different
flight numbers. That&#x27;s called code-sharing. So you might have &lt;code&gt;AA123&lt;&#x2F;code&gt;,
&lt;code&gt;BA456&lt;&#x2F;code&gt;, and &lt;code&gt;IB789&lt;&#x2F;code&gt; all referring to the exact same plane leaving from
Gate E7 at 07:30.&lt;&#x2F;p&gt;
&lt;p&gt;At first, I tried to build my own detection using departure time + gate +
destination as a natural key. But then I looked closer at the AviationStack
API response and found it: a &lt;code&gt;codeshared&lt;&#x2F;code&gt; field that tells you exactly
which flight is the operating carrier.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;The Fix: Use the API&#x27;s Built-In Field&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;# Handle code-share flights: use operating carrier as natural key
codeshared = flight_info[&amp;quot;codeshared&amp;quot;]
if codeshared.present?
  # This is a marketing carrier selling seats on another airline&amp;#x27;s flight
  # Use the operating flight as trip_id so all code-shares merge
  operating_flight = codeshared[&amp;quot;flight_iata&amp;quot;] || codeshared[&amp;quot;flight_icao&amp;quot;]
  trip_id = &amp;quot;#{operating_flight}-#{departure_info[&amp;#x27;scheduled&amp;#x27;]}&amp;quot;

  Rails.logger.info &amp;quot;[TransitTracker] Code-share detected:
    #{marketing_flight} operated by #{operating_flight}&amp;quot;
else
  # Regular flight, use its own flight number
  trip_id = &amp;quot;#{flight_info[&amp;#x27;iata&amp;#x27;]}-#{departure_info[&amp;#x27;scheduled&amp;#x27;]}&amp;quot;
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Why reinvent the wheel? The API already does the hard work of identifying
code-shares. I just use the operating carrier&#x27;s flight number as the
&lt;code&gt;trip_id&lt;&#x2F;code&gt;, and all marketing carriers automatically merge into the same
topic.&lt;&#x2F;p&gt;
&lt;p&gt;Result: &lt;code&gt;AA 1234 &#x2F; BA 5678 &#x2F; IB 789&lt;&#x2F;code&gt; displayed as one departure.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-architecture&quot;&gt;The Architecture&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;topics-as-transit-legs&quot;&gt;Topics as Transit Legs&lt;&#x2F;h3&gt;
&lt;p&gt;Each flight (or train, or bus) is a Discourse topic. I created a
&lt;code&gt;TransitLeg&lt;&#x2F;code&gt; model that wraps Topic and handles all the custom fields:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;transit_dep_sched_at&lt;&#x2F;code&gt; (scheduled departure time)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;transit_dep_est_at&lt;&#x2F;code&gt; (estimated departure time for delays)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;transit_route_short_name&lt;&#x2F;code&gt; (flight numbers)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;transit_headsign&lt;&#x2F;code&gt; (destination)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;transit_gate&lt;&#x2F;code&gt; &#x2F; &lt;code&gt;transit_platform&lt;&#x2F;code&gt; (where to board)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;transit_dest&lt;&#x2F;code&gt; (airport code)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;transit_stops&lt;&#x2F;code&gt; (JSON array of all stops with coordinates and times)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Tags handle the mode (&lt;code&gt;flight&lt;&#x2F;code&gt;, &lt;code&gt;train&lt;&#x2F;code&gt;, &lt;code&gt;bus&lt;&#x2F;code&gt;) and status
(&lt;code&gt;status:scheduled&lt;&#x2F;code&gt;, &lt;code&gt;status:delayed&lt;&#x2F;code&gt;, &lt;code&gt;status:departed&lt;&#x2F;code&gt;).&lt;&#x2F;p&gt;
&lt;h3 id=&quot;posts-as-schedule-details&quot;&gt;Posts as Schedule Details&lt;&#x2F;h3&gt;
&lt;p&gt;Here&#x27;s where using Discourse as the foundation really paid off.&lt;&#x2F;p&gt;
&lt;p&gt;When you click on a departure row, it expands to show the complete route
schedule with all stops and arrival&#x2F;departure times. But I didn&#x27;t build a
custom data structure for this. &lt;strong&gt;I used Discourse posts&lt;&#x2F;strong&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Each departure topic has:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Post #1&lt;&#x2F;strong&gt; (the OP): Basic departure info (route, times, gate)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Post #2&lt;&#x2F;strong&gt;: A markdown table with the complete schedule&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Post #3+&lt;&#x2F;strong&gt;: Any delay notifications or status updates&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;When you expand a row, you&#x27;re literally seeing the topic&#x27;s replies rendered
inline. It slides down with a smooth animation, showing the full schedule
table styled to match the departure board aesthetic.&lt;&#x2F;p&gt;
&lt;p&gt;The schedule post looks like this:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;markdown&quot; class=&quot;language-markdown &quot;&gt;&lt;code class=&quot;language-markdown&quot; data-lang=&quot;markdown&quot;&gt;## 🚂 Complete Schedule

**Route:** City of New Orleans
**Direction:** Chicago

| Stop                                  | Arrival | Departure |
|---------------------------------------|---------|-----------|
| New Orleans Union Passenger Terminal  | 12:45   | 12:45     |
| Hammond Amtrak Station                | 13:42   | 13:45     |
| McComb                                | 14:30   | 14:32     |
| ...                                   | ...     | ...       |
| Chicago Union Station                 | 08:15   | 08:15     |

_Schedule times are in local timezone. This is the planned schedule and may
be subject to delays._
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It renders beautifully in the expanded row with the board&#x27;s dark styling.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-this-works-really-well&quot;&gt;Why This Works Really Well&lt;&#x2F;h3&gt;
&lt;p&gt;Using posts instead of a custom schema means:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Update history is built-in&lt;&#x2F;strong&gt;. If a train gets delayed, we post an
update and users see the entire timeline.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Moderation tools work&lt;&#x2F;strong&gt;. If there&#x27;s bad data, moderators can edit posts
using Discourse&#x27;s existing tools.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Comments could work&lt;&#x2F;strong&gt;. Users could reply to departures (we don&#x27;t allow
this now, but the infrastructure is there).&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;No additional database tables&lt;&#x2F;strong&gt;. Posts are just posts, Discourse
handles all the storage.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;screenshots&quot;&gt;Screenshots&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;Flights - Collapsed View&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-planes.png&quot; alt=&quot;Flight departures board showing routes, gates, destinations, and countdowns&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Flights - Expanded View&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-planes-expanded.png&quot; alt=&quot;Expanded flight showing airline details, gates, terminals, and code-share information&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;NYC Subway - Collapsed View&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-mta.png&quot; alt=&quot;MTA subway board with authentic line colors&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;NYC Subway - Expanded View&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-mta-expanded.png&quot; alt=&quot;Expanded subway departure showing complete stop-by-stop schedule&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Amtrak Trains - Collapsed View&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-trains.png&quot; alt=&quot;Amtrak departure board&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Amtrak Trains - Expanded View&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;transit-tracker-trains-expanded.png&quot; alt=&quot;Expanded train departure showing full station schedule&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h2 id=&quot;bonus-tooling-consistent-screenshots&quot;&gt;Bonus Tooling: Consistent Screenshots&lt;&#x2F;h2&gt;
&lt;p&gt;Getting these screenshots pixel-perfect required some tooling. I wanted to
be able to:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Select a region once&lt;&#x2F;li&gt;
&lt;li&gt;Click things in the browser to expand&#x2F;collapse&lt;&#x2F;li&gt;
&lt;li&gt;Take multiple screenshots of the exact same region&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;I built a Nix shell with Wayland screenshot tools (&lt;code&gt;grim&lt;&#x2F;code&gt; + &lt;code&gt;slurp&lt;&#x2F;code&gt;):&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;bash&quot; class=&quot;language-bash &quot;&gt;&lt;code class=&quot;language-bash&quot; data-lang=&quot;bash&quot;&gt;nix-shell ~&amp;#x2F;discourse&amp;#x2F;nix-shells&amp;#x2F;screenshot.nix

screenshot-region flight-1.png     # Select region once
# Click to expand in browser
screenshot-repeat flight-2.png     # Same exact region
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The &lt;code&gt;slurp&lt;&#x2F;code&gt; tool saves the region geometry, and &lt;code&gt;screenshot-repeat&lt;&#x2F;code&gt; reuses
it for perfect alignment across multiple screenshots.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;strong&gt;I learn best by writing real tools.&lt;&#x2F;strong&gt; Tutorial projects teach syntax, but
they don&#x27;t force you into the messy, real-world problems that make you
actually understand a framework.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;discourse-topics-are-more-flexible-than-you-think&quot;&gt;Discourse Topics Are More Flexible Than You Think&lt;&#x2F;h3&gt;
&lt;p&gt;Custom fields, tags, and categories gave me all the structured data I needed.
Topics aren&#x27;t just &quot;posts&quot;. They&#x27;re flexible containers for any kind of
information.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;posts-are-the-perfect-update-mechanism&quot;&gt;Posts Are the Perfect Update Mechanism&lt;&#x2F;h3&gt;
&lt;p&gt;Instead of building a custom &quot;updates&quot; system with timestamps and status
changes, I just used Discourse posts. When a delay happens, post an update.
The topic becomes a living history of what happened to that departure.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;entity-resolution-check-your-data-before-building-logic&quot;&gt;Entity Resolution: Check Your Data Before Building Logic&lt;&#x2F;h3&gt;
&lt;p&gt;My first implementation created duplicate topics for every code-share
flight. I started building my own deduplication logic using a natural key
(departure time + gate + destination). But then I actually read the API
response and found it: a &lt;code&gt;codeshared&lt;&#x2F;code&gt; field that identifies which flights
are the same. I was about to solve a problem the API had already solved for
me.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;gtfs-parsing-has-edge-cases&quot;&gt;GTFS Parsing Has Edge Cases&lt;&#x2F;h3&gt;
&lt;p&gt;GTFS times can exceed 24 hours (e.g., &quot;25:30:00&quot; means 1:30 AM the next
day). ZIP files can have encoding issues. Stop sequences aren&#x27;t always
sequential. Real-world data is messy.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;performance-matters-at-scale&quot;&gt;Performance Matters at Scale&lt;&#x2F;h3&gt;
&lt;p&gt;The MTA feed has 500k+ stop times. A 6-hour import window instead of 24
hours keeps memory usage reasonable and topic counts manageable (~5,000
instead of 20,000+).&lt;&#x2F;p&gt;
&lt;h2 id=&quot;is-this-practical&quot;&gt;Is This Practical?&lt;&#x2F;h2&gt;
&lt;p&gt;Probably not for real transit tracking. But it&#x27;s a great example of pushing
Discourse in unexpected directions to understand the platform deeply.&lt;&#x2F;p&gt;
&lt;p&gt;The same pattern (topics as structured data + posts as updates) could work
for:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Package tracking (topics = packages, posts = scan events)&lt;&#x2F;li&gt;
&lt;li&gt;Server status boards (topics = servers, posts = incidents)&lt;&#x2F;li&gt;
&lt;li&gt;Deployment pipelines (topics = deploys, posts = stage completions)&lt;&#x2F;li&gt;
&lt;li&gt;Event schedules (topics = sessions, posts = time&#x2F;room changes)&lt;&#x2F;li&gt;
&lt;li&gt;Support ticket boards (topics = tickets, expandable = full history)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The split-flap aesthetic is a bonus.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;try-it-yourself&quot;&gt;Try It Yourself&lt;&#x2F;h2&gt;
&lt;p&gt;The plugin is open source:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;discourse-transit-tracker&quot;&gt;ducks&#x2F;discourse-transit-tracker&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Clone it, run the Amtrak import (no API key required), and see what
Discourse topics can become when you push them beyond forum discussions.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>My First Official Contribution to Discourse</title>
          <pubDate>Mon, 06 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/first-official-discourse-contribution/</link>
          <guid>https://jakegoldsborough.com/blog/2025/first-official-discourse-contribution/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/first-official-discourse-contribution/">&lt;p&gt;This past week marked a small but meaningful milestone for me - I submitted and
merged my first pull request to the Discourse core project. The fix is simple
but important: handle a &lt;code&gt;nil&lt;&#x2F;code&gt; commit URL path in &lt;code&gt;Admin::PluginsController&lt;&#x2F;code&gt;,
preventing a 500 error in the plugins admin UI.
(&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;discourse&#x2F;discourse&#x2F;pull&#x2F;35186&quot;&gt;github.com&lt;&#x2F;a&gt;)&lt;&#x2F;p&gt;
&lt;h3 id=&quot;debugging-a-random-admin-panel-crash&quot;&gt;Debugging a random admin panel crash&lt;&#x2F;h3&gt;
&lt;p&gt;I&#x27;ve been at Discourse for about two and a half months now, and most of my work
so far has been focused on internal tools and infrastructure rather than the
main codebase itself. It&#x27;s been a great way to learn how things fit together
behind the scenes, but I&#x27;ve been itching to make a direct impact on the project
that everyone actually sees and uses.&lt;&#x2F;p&gt;
&lt;p&gt;This PR was a small but deliberate step in that direction. I wanted to start
contributing to the public codebase, not just to fix a bug, but to get more
familiar with the patterns, tests, and expectations that make Discourse what it
is. It&#x27;s one thing to understand how we run it internally - it&#x27;s another to
touch the core code that powers thousands of communities around the world.&lt;&#x2F;p&gt;
&lt;p&gt;There&#x27;s also a kind of quiet satisfaction in cleaning up a small edge case like
this. It&#x27;s the kind of detail that no one notices when it&#x27;s working, but it
makes the whole system sturdier. That&#x27;s the kind of work I enjoy doing.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-the-bug-was-succinctly&quot;&gt;What the bug was, succinctly&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;In &lt;strong&gt;&lt;code&gt;lib&#x2F;plugin&#x2F;instance.rb&lt;&#x2F;code&gt;&lt;&#x2F;strong&gt;, the method &lt;code&gt;discourse_owned?&lt;&#x2F;code&gt; was calling
&lt;code&gt;.split&lt;&#x2F;code&gt; on &lt;code&gt;parsed_commit_url.path&lt;&#x2F;code&gt; without checking if &lt;code&gt;path&lt;&#x2F;code&gt; might be
&lt;code&gt;nil&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;In the scenario where a plugin has a commit URL parsed successfully but the
&lt;code&gt;path&lt;&#x2F;code&gt; is &lt;code&gt;nil&lt;&#x2F;code&gt; (for example, plugins without a git remote configured), this
led to a crash (HTTP 500) in &lt;code&gt;&#x2F;admin&#x2F;plugins&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;My patch adds a &lt;code&gt;nil&lt;&#x2F;code&gt; check: if &lt;code&gt;parsed_commit_url.path&lt;&#x2F;code&gt; is &lt;code&gt;nil&lt;&#x2F;code&gt;, the method
returns &lt;code&gt;false&lt;&#x2F;code&gt; (or skips the split) gracefully. I also added a test case to
assert this behavior.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In short: prevent a rare but real crash, and make the behavior deterministic in
that edge case.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;how-the-review-and-merge-went&quot;&gt;How the review and merge went&lt;&#x2F;h3&gt;
&lt;p&gt;The PR moved relatively smoothly:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;I opened the PR and described the bug and proposed patch.&lt;&#x2F;li&gt;
&lt;li&gt;The maintainers reviewed and approved it.&lt;&#x2F;li&gt;
&lt;li&gt;After passing all checks, the PR was merged into &lt;code&gt;main&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;The branch was deleted afterward.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;It was a small change - only +11 &#x2F; -1 lines - but I made sure the fix was clean,
covered by a test, and clearly explained. Small contributions like this build
trust and open the door to more.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;takeaways&quot;&gt;Takeaways&lt;&#x2F;h4&gt;
&lt;p&gt;Fixing something this small reminded me that even the simplest changes can
improve confidence in a codebase. It also helped me see more of the moving parts
that make Discourse work - how plugins tie in, how errors surface in the admin
panel, and how tests are structured to catch regressions early.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s easy to think of &quot;first contributions&quot; as symbolic, but what really mattered
was understanding how to move through the process: spot an issue, confirm it,
write a clean fix, explain it clearly, and see it merged. That flow is something
I want to keep refining.&lt;&#x2F;p&gt;
&lt;p&gt;Going forward, I want to:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Explore other bug reports in Discourse (especially in plugins and the admin
UI)&lt;&#x2F;li&gt;
&lt;li&gt;Take on slightly more complex patches (refactors, performance improvements,
new features)&lt;&#x2F;li&gt;
&lt;li&gt;Use this experience to contribute in a modular, maintainable way&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Going off the Rails on the AWS Credential Chain</title>
          <pubDate>Sat, 04 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby-pt2/</link>
          <guid>https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby-pt2/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby-pt2/">&lt;h3 id=&quot;the-plot-twist&quot;&gt;The Plot Twist&lt;&#x2F;h3&gt;
&lt;p&gt;Remember that &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;contributing-to-aws-sdk-ruby&#x2F;&quot;&gt;AWS SDK
contribution&lt;&#x2F;a&gt;
I wrote about? The one where I fixed missing &lt;code&gt;credential_source = Environment&lt;&#x2F;code&gt;
support? Well, turns out that was only half the story.&lt;&#x2F;p&gt;
&lt;p&gt;After my PR got merged and we updated our Discourse deployment to &lt;code&gt;aws-sdk-core 3.233.0&lt;&#x2F;code&gt;, I was excited to finally see role assumption working. The missing
credential source was fixed, our config looked perfect, and all the unit tests
were passing.&lt;&#x2F;p&gt;
&lt;p&gt;But when I deployed it to our test cluster, I still got an error. It was a new
error, but an error nonetheless. Instead of &lt;code&gt;UnsupportedCredentialType&lt;&#x2F;code&gt;, I was
now getting a permission denied error when trying to use AWS S3 operations.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-real-problem&quot;&gt;The Real Problem&lt;&#x2F;h3&gt;
&lt;p&gt;Here&#x27;s what was happening. I&#x27;d check the identity in our Rails console:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;discourse(prod)&amp;gt; sts = Aws::STS::Client.new
discourse(prod)&amp;gt; puts sts.get_caller_identity.arn
arn:aws:iam::123456789012:user&amp;#x2F;MyUser
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That should have been showing an assumed role ARN like:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;arn:aws:iam::123456789012:assumed-role&amp;#x2F;MyRole&amp;#x2F;session-name
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The SDK was still using environment variables directly instead of using them as
source credentials to assume the configured role.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-investigation-round-2&quot;&gt;The Investigation (Round 2)&lt;&#x2F;h3&gt;
&lt;p&gt;Back to the AWS SDK source code, this time looking at
&lt;code&gt;credential_provider_chain.rb&lt;&#x2F;code&gt;. And there it was - a classic ordering problem:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ruby&quot; class=&quot;language-ruby &quot;&gt;&lt;code class=&quot;language-ruby&quot; data-lang=&quot;ruby&quot;&gt;def providers
[
# ... other providers ...
[:env_credentials, {}], # Position 7 ← Wins!
# ... other providers ...
[:assume_role_credentials, {}], # Position 10 ← Never reached
]
end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The credential chain finds &lt;code&gt;AWS_ACCESS_KEY_ID&lt;&#x2F;code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;&#x2F;code&gt; in
position 7, returns those credentials immediately, and never reaches the role
assumption logic at position 10.&lt;&#x2F;p&gt;
&lt;p&gt;But here&#x27;s the thing - this isn&#x27;t as simple as just moving
&lt;code&gt;assume_role_credentials&lt;&#x2F;code&gt; higher in the chain. That might break existing
behavior for thousands of applications that rely on direct environment variable
usage.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-real-architecture-problem&quot;&gt;The Real Architecture Problem&lt;&#x2F;h3&gt;
&lt;p&gt;What should happen with this config:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;ini&quot; class=&quot;language-ini &quot;&gt;&lt;code class=&quot;language-ini&quot; data-lang=&quot;ini&quot;&gt;[default]
role_arn = arn:aws:iam::123456789012:role&amp;#x2F;MyRole
credential_source = Environment
role_session_name = test-session
region = us-west-2
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;strong&gt;Expected flow:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;SDK sees &lt;code&gt;role_arn&lt;&#x2F;code&gt; + &lt;code&gt;credential_source = Environment&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;SDK uses &lt;code&gt;AWS_ACCESS_KEY_ID&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;&#x2F;code&gt; as &lt;strong&gt;source credentials&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;SDK calls &lt;code&gt;AssumeRole&lt;&#x2F;code&gt; with those source credentials&lt;&#x2F;li&gt;
&lt;li&gt;SDK returns &lt;strong&gt;assumed role credentials&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;strong&gt;Actual flow:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;SDK finds &lt;code&gt;AWS_ACCESS_KEY_ID&lt;&#x2F;code&gt;&#x2F;&lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;&#x2F;code&gt; in environment&lt;&#x2F;li&gt;
&lt;li&gt;SDK returns those credentials directly (chain stops)&lt;&#x2F;li&gt;
&lt;li&gt;Role assumption config is never processed&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h3 id=&quot;the-contribution&quot;&gt;The Contribution&lt;&#x2F;h3&gt;
&lt;p&gt;I filed &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;issues&#x2F;3301&quot;&gt;Bug #3301&lt;&#x2F;a&gt; with:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Clear reproduction steps&lt;&#x2F;li&gt;
&lt;li&gt;Root cause analysis pointing to the specific code location&lt;&#x2F;li&gt;
&lt;li&gt;Expected vs. actual behavior with ARN examples&lt;&#x2F;li&gt;
&lt;li&gt;A workaround using &lt;code&gt;source_profile&lt;&#x2F;code&gt; (though it requires plaintext keys)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;the-response&quot;&gt;The Response&lt;&#x2F;h3&gt;
&lt;p&gt;The AWS team&#x27;s response was exactly what you hope for:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Hey, thanks for opening an issue. I believe you are correct and this is
something we need to fix. I need to confirm some details regarding
AssumeRoleCredentials profile chaining with the team first and I&#x27;ll keep you
updated.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That&#x27;s maintainer validation that this is a real architectural issue, not user
error.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-i-learned-again&quot;&gt;What I Learned (Again)&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;Complex systems have complex bugs:&lt;&#x2F;strong&gt; My first fix solved the missing feature,
but revealed a deeper architectural problem with credential chain precedence.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Sometimes the obvious fix isn&#x27;t right:&lt;&#x2F;strong&gt; Moving &lt;code&gt;assume_role_credentials&lt;&#x2F;code&gt;
higher might seem simple, but it requires careful consideration of context and
backward compatibility.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Good bug reports matter:&lt;&#x2F;strong&gt; Clear reproduction steps, root cause analysis, and
concrete examples help maintainers understand and prioritize issues.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Persistence pays off:&lt;&#x2F;strong&gt; I could have stopped after the first fix and lived
with workarounds. But pushing deeper led to identifying a more fundamental
issue.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-bigger-picture&quot;&gt;The Bigger Picture&lt;&#x2F;h3&gt;
&lt;p&gt;This experience reinforced something important about systems work: fixing one
layer often reveals problems in the next layer down.&lt;&#x2F;p&gt;
&lt;p&gt;The first bug was a missing feature - straightforward to implement. The second
bug is an architectural design issue that requires careful consideration of how
credential resolution should work when role assumption is involved.&lt;&#x2F;p&gt;
&lt;p&gt;Both bugs are real, both needed fixing, and both will help developers who hit
the same issues. But they required completely different approaches - one needed
code, the other needed a thoughtful design discussion with the maintainers.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;status-update&quot;&gt;Status Update&lt;&#x2F;h3&gt;
&lt;p&gt;As of this writing, the AWS team is discussing the fix for the credential chain
precedence issue. Our Discourse implementation is ready to go - we&#x27;re just
waiting on the upstream architectural fix.&lt;&#x2F;p&gt;
&lt;p&gt;Sometimes the best contribution you can make is clearly identifying and
documenting a problem, even when the solution isn&#x27;t obvious. That&#x27;s exactly
what happened here.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-adventure-continues&quot;&gt;The Adventure Continues&lt;&#x2F;h3&gt;
&lt;p&gt;Read &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;contributing-to-aws-sdk-ruby-pt3&#x2F;&quot;&gt;Part
3&lt;&#x2F;a&gt; to
see how the maintainers responded to the bug report and the workaround I ended
up implementing.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 40&#x2F;52</title>
          <pubDate>Fri, 03 Oct 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-40/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-40/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-40/">&lt;p&gt;The week covered a mix of practical, technical, and personal topics.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;travel-and-residency&quot;&gt;Travel and Residency&lt;&#x2F;h3&gt;
&lt;p&gt;Discussions continued around moving to Czechia. You asked about employer
documents, contracts, the passport biopage, tax implications, accommodation
requirements, and realistic timelines for when you could start working from
Prague. Flatio was identified as a likely short-term housing option.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;anki-and-language-learning&quot;&gt;Anki and Language Learning&lt;&#x2F;h3&gt;
&lt;p&gt;You set up an Anki deck for learning Czech. The focus was on the alphabet,
pronunciation, and use of IPA symbols. We went through how to structure the
deck, what templates should look like, and clarified things like vowel length.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nixos-and-framework-issues&quot;&gt;NixOS and Framework Issues&lt;&#x2F;h3&gt;
&lt;p&gt;There were troubleshooting sessions with your Framework 16 and NixOS. Problems
included the command key breaking after an RGB reset, the RGB matrix not working
in Linux, and questions about whether this tied to a NixOS upgrade. We looked at
inputmodule installation, what parts of &#x2F;etc are declarative, and the Framework
warranty&#x2F;return policy.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;broader-nixos-community&quot;&gt;Broader NixOS Community&lt;&#x2F;h3&gt;
&lt;p&gt;You asked about the NixOS steering committee drama and its connection to
Anduril, looking for current context on community governance issues.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;practical-life-logistics&quot;&gt;Practical Life Logistics&lt;&#x2F;h3&gt;
&lt;p&gt;A few everyday topics came up:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Steps for getting a car repaired after being rear-ended.&lt;&#x2F;li&gt;
&lt;li&gt;How to cancel a Moda healthcare plan.&lt;&#x2F;li&gt;
&lt;li&gt;Best backup plan when changing operating systems.&lt;&#x2F;li&gt;
&lt;li&gt;Selling an old car (1974 Ford Ranchero) in Portland, with estimates for repair
or restoration and whether shops do at-home consultations.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;projects-and-ideas&quot;&gt;Projects and Ideas&lt;&#x2F;h3&gt;
&lt;p&gt;You continued brainstorming unique Discourse plugin ideas. This included using
Discourse to track flights, trains, or transit legs (with Prague trams as a test
case) and asking about APIs and boilerplate plugin setup.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;miscellaneous&quot;&gt;Miscellaneous&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;GoatCounter analytics and why a single link share might register multiple
views (including prefetching behavior).&lt;&#x2F;li&gt;
&lt;li&gt;Color distribution analysis on an uploaded image, with rotations and
measurements.&lt;&#x2F;li&gt;
&lt;li&gt;iPad Mini use cases.&lt;&#x2F;li&gt;
&lt;li&gt;Services to help sell or donate items at home.&lt;&#x2F;li&gt;
&lt;li&gt;Shipping personal items from the US to Europe.&lt;&#x2F;li&gt;
&lt;li&gt;Kastaplast first run disc valuations and market prices, including Falk, Lots,
Stål, Göte, Berg, and Reko.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Overall, the week balanced ongoing emigration planning and language study with
NixOS troubleshooting, plugin experiments, and practical life logistics.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 36-39&#x2F;52</title>
          <pubDate>Fri, 26 Sep 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-36/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-36/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-36/">&lt;p&gt;&lt;strong&gt;Travel and Prague notes&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A first Europe trip to Prague sparked a travel post and follow ups. Sections
about the castle, tram lines 6 and 17, and Czech Cubism were drafted. Airport
and TSA logistics were covered, including CT scanner lanes at PDX, KLM meal
expectations, missed connection handling at AMS, and return routing via JFK.
Mint Mobile&#x27;s international pass proved sufficient for a week, with OsmAnd
recommended for offline maps and Obtainium used to install apps without Play
Store.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Czech visa preparation&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Residency planning continued. A rental listing in Holesovice prompted an email
draft to the agent and an explanation of Czech 4+1 flat notation. Resources for
US to CZ expats, the role of Flatio, timing without a fixed move date, and
document prep were explored. An employer affidavit template was rewritten in
plain text. Fingerprinting and FBI background check expectations were
clarified. Czech terms like software engineer and contract types such as HPP
were noted.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Cameras and creative gear&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Beginner travel camera options were compared: Nikon Z30 and Canon R50 kits
versus the Ricoh GRIII for street and night work. Strengths, tradeoffs, and
lens choices were outlined. For carry systems, compact bags and local stock for
brands like WANDRD were discussed, with an eye toward minimal kits.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Discourse experiments and ideas&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A flurry of off label Discourse concepts landed, from non forum app patterns to
playful ideas like Conway&#x27;s Game of Life. Practicalities came up too: VPS disk
pressure from journal files, running locally instead, and revisiting plugin
basics. A specific plugin that tracks days until the next cake day was analyzed
for implementation details and feasibility.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Desktop tooling and small wins&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;On Wayland, grim plus wl copy needed an explicit MIME type to fix clipboard
issues. A Nushell function location was clarified, and a prompt flow was added
to optionally save screenshots after copying. Hyprland confirmations and UX
touches resurfaced as blog worthy tips.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Selling, shipping, and decluttering&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Paths for selling a non running 1974 Ford Ranchero in Portland were mapped out:
where to get initial consultations, rough cost expectations for engine checks
and tune ups, and whether restoration would raise sale value. Services that
help sell or donate household items were listed. Options for shipping personal
goods from the US to Europe were compared at a high level.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Email deliverability and DNS&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Email headers were unpacked for a non technical audience. DKIM and DMARC were
explained in plain terms, realistic DNS propagation timing was set, and common
DMARC failure causes were reviewed. The idea of account warming was addressed
with a pragmatic lens. Header domains and alignment details were parsed from
sample Authentication Results lines.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Disc golf market checks&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Kastaplast first run prices were researched with a focus on Falk, Lots, Stal,
and Gote. Market ranges, recency caveats, and condition effects were noted. A
correction landed for the Reko mix, with early plastics clarified. A well used
FR Berg valuation was discussed with realism about wear and ceiling prices.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Personal finance and admin&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;401k choices were outlined: cash out implications, rollovers to IRAs, and how a
move abroad intersects with taxes and planning. Help from fiduciary advisors
and any digital nomad friendly country programs were surveyed at a high level.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Logos, apps, and posts&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A map based Loop Finder app concept took shape using Vue, with local
GraphHopper via Docker Compose to remove API key friction. Zola templating for
shared resume layouts was explored. A prior post on typing Swedish with a US
keyboard kept attracting search traffic.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Housekeeping on the site&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Styling for headings aimed at mobile readability while respecting an 80 char
column. Prior weekly summaries stayed the model: plain text markdown, no fancy
chars, prose first, and wrapped lines. Future posts were queued from several
threads above, especially Prague transport notes, Hyprland prompts, and the
email DNS explainer.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>First time in Europe: One week in Prague</title>
          <pubDate>Tue, 16 Sep 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/visiting-prague-first-time-in-europe/</link>
          <guid>https://jakegoldsborough.com/blog/2025/visiting-prague-first-time-in-europe/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/visiting-prague-first-time-in-europe/">&lt;h3 id=&quot;a-country-boy-goes-to-europe&quot;&gt;A Country Boy Goes to Europe&lt;&#x2F;h3&gt;
&lt;p&gt;This past week was the annual global meetup for
&lt;a href=&quot;https:&#x2F;&#x2F;discourse.org&quot;&gt;Discourse&lt;&#x2F;a&gt;, the company I recently joined. I was lucky
enough to get hired right before the planning cutoff date, and they were very
generous to include me so early in my tenure. That inclusion made me feel
valued and trusted. This year the meetup was held in Prague, Czech Republic.
I was excited because I have never been out of North America.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;initial-leg-and-a-missed-connection-in-amsterdam&quot;&gt;Initial Leg and a Missed Connection in Amsterdam&lt;&#x2F;h3&gt;
&lt;p&gt;The trip started with a flight from Portland to Amsterdam. It was pretty
smooth, even if long. The only real issue was that the flight arrived late.
On top of that, the passport control line in Amsterdam may have been the
longest line I have ever seen in an airport. Those two things combined forced
me to miss my connecting flight to Prague. Luckily I was flying KLM, which is
very good about rebooking a new flight when it is their fault.&lt;&#x2F;p&gt;
&lt;p&gt;I had to do &lt;strong&gt;A LOT&lt;&#x2F;strong&gt; of walking and waiting, but I got rebooked and made it
to my new flight.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;meeting-my-new-coworkers&quot;&gt;Meeting My New Coworkers&lt;&#x2F;h3&gt;
&lt;p&gt;For all the excitement, I was nervous too. I am introverted, and walking into
a room of about 70 people I did not know was daunting. I am not great at
introducing myself and I do not feel &quot;important&quot; enough to assume people want
to meet me. I was not the only first timer, but I was by far the newest hire.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;prague-castle&quot;&gt;Prague Castle&lt;&#x2F;h3&gt;
&lt;p&gt;Prague Castle is not a single building as much as it is a hilltop campus of
courtyards, churches, palaces, and gardens.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;st-vitus-cathedral&quot;&gt;St. Vitus Cathedral&lt;&#x2F;h4&gt;
&lt;p&gt;Although I am anti-religion, I can appreciate the architecture of old world
churches. Pictures cannot do this building justice. The height, beauty, and
aging are things you just have to see in person.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;cathedral-1.jpeg&quot; alt=&quot;St. Vitus Cathedral, front view&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;cathedral-stained-glass.jpeg&quot; alt=&quot;St. Vitus Cathedral stained glass windows&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h4 id=&quot;golden-lane-zlata-ulicka&quot;&gt;Golden Lane (Zlatá ulička)&lt;&#x2F;h4&gt;
&lt;p&gt;This pocket street sits between the Bílá Tower and Daliborka Tower. The tiny,
colorful houses were built into the castle fortifications around the end of
the 16th century and stayed lived in up to the Second World War. Several of
the homes now show period interiors that trace everyday life on the lane
across five centuries.&lt;&#x2F;p&gt;
&lt;p&gt;House No. 22 is the best known stop. Franz Kafka stayed here for a time
during the First World War. Facades were repainted in the 1950s following
designs by Jiří Trnka, which is part of why the lane looks so storybook
today.&lt;&#x2F;p&gt;
&lt;p&gt;Climb the steps at House No. 12 to the terrace above Daliborka. The round
tower dates to 1496 and served as a prison until 1781. You will see notes
about the knight Dalibor of Kozojedy, the tower&#x27;s first recorded inmate.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;the-castle-proper-old-royal-palace&quot;&gt;The Castle Proper (Old Royal Palace)&lt;&#x2F;h4&gt;
&lt;p&gt;The Old Royal Palace centers on Vladislav Hall: huge, yet surprisingly open.
The broad Riders&#x27; Staircase was built so horsemen could enter, which says a
lot about the space&#x27;s ambitions. Outside, the courtyards do the real work of
the visit: sit, watch the guard change, and let the pace reset. On the south
side, terraces step down toward the river with quiet garden paths and big sky
views.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;view-from-castle-1.jpeg&quot; alt=&quot;View south over Prague from the castle terraces&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;public-trams-it&quot;&gt;Public Trams-it&lt;&#x2F;h3&gt;
&lt;p&gt;Prague&#x27;s tram network is part moving museum, part everyday superpower.&lt;&#x2F;p&gt;
&lt;p&gt;A quick history:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;1875: horse trams debut in Prague&lt;&#x2F;li&gt;
&lt;li&gt;1891: inventor František Křižík launches the first electric tram (Letná)&lt;&#x2F;li&gt;
&lt;li&gt;1962: the Tatra T3, the red cream classic, begins service and becomes a
design icon still seen today&lt;&#x2F;li&gt;
&lt;li&gt;2010s to current: 100% low floor Škoda 15T ForCity sets the modern standard&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I caught almost every ride at Veletržní palác, a 2 minute walk from Mama
Shelter. Lines &lt;strong&gt;6&lt;&#x2F;strong&gt; and &lt;strong&gt;17&lt;&#x2F;strong&gt; stop here, so I could either hug the river or
cut across town without thinking too hard. Daytime trams run about every 8 to
20 minutes, so it felt like show up and go.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;tram-6&quot;&gt;Tram 6&lt;&#x2F;h4&gt;
&lt;p&gt;Line 6 connects Nádraží Holešovice to Kubánské náměstí, which makes it a
useful cross town link if you are moving between Holešovice, the center, and
Vršovice. Daytime trams in Prague typically run every 8 to 20 minutes, so you
can treat both 6 and 17 like show up and go service.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;tram-17&quot;&gt;Tram 17&lt;&#x2F;h4&gt;
&lt;p&gt;Line 17 hugs the Vltava for long stretches, so it is the easy, scenic way to
glide past Národní divadlo, Staroměstská, Výtoň, and the riverfront. It links
the north (Vozovna Kobylisy) with the south (Sídliště Modřany and Libuš). If
you want a view rich ride, sit by the window on this one.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;czech-cubist-museum-at-the-house-of-the-black-madonna&quot;&gt;Czech Cubist Museum at The House of the Black Madonna&lt;&#x2F;h3&gt;
&lt;p&gt;Czech Cubism did not stop at the canvas. It leapt into architecture,
furniture, and everyday objects. One of the movement&#x27;s showpieces is located
at The House at the Black Madonna, which was built in 1911 to 1912 and
designed by Josef Gočár. It was a Cubist designed department store turned
cultural landmark of Old Town. Today it hosts The Museum of Czech Cubism and
a restored Grand Cafe Orient.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;cubist-illustration.jpeg&quot; alt=&quot;Czech Cubist poster&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;cubist-bench.jpeg&quot; alt=&quot;Czech Cubist bench&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;cubist-couch.jpeg&quot; alt=&quot;Czech Cubist couch&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;cubist-chair.jpeg&quot; alt=&quot;Czech Cubist chair&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;permanent-souvenirs&quot;&gt;Permanent Souvenirs&lt;&#x2F;h3&gt;
&lt;p&gt;I decided to get a couple permanent souvenirs in the form of tattoos while
there.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;dario-squirrel.jpeg&quot; alt=&quot;Squirrel grabbing nuts, blackwork tattoo&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;Tattoo by &lt;a href=&quot;https:&#x2F;&#x2F;www.instagram.com&#x2F;dario_kristoffersen&#x2F;&quot;&gt;Dario Kristoffersen&lt;&#x2F;a&gt; at &lt;a href=&quot;https:&#x2F;&#x2F;www.onelovetattoo.cz&#x2F;tattooers&#x2F;dario-kristoffersen&quot;&gt;One Love Tattoo&lt;&#x2F;a&gt;.
Prague, 2025.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;tarlito-praha-castle.jpeg&quot; alt=&quot;Prague Castle, blackwork tattoo&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;Tattoo by &lt;a href=&quot;https:&#x2F;&#x2F;www.instagram.com&#x2F;tarlito&#x2F;&quot;&gt;Tarlito&lt;&#x2F;a&gt; at &lt;a href=&quot;https:&#x2F;&#x2F;www.onelovetattoo.cz&#x2F;tattooers&#x2F;tarlito&quot;&gt;One Love Tattoo&lt;&#x2F;a&gt;. Prague,
2025.&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;summary-and-reflection&quot;&gt;Summary and Reflection&lt;&#x2F;h3&gt;
&lt;p&gt;This was honestly the best trip of my life. I absolutely loved Prague and that
old European energy. There was something different in the air. To me, it feels
like a smaller, older New York City.&lt;&#x2F;p&gt;
&lt;p&gt;I loved it so much that I have already started a checklist for a digital nomad
work visa. I could see going back for longer periods to visit and work. I
regret not going to the transit museum, so that is high on the list for the
next visit.&lt;&#x2F;p&gt;
&lt;p&gt;Děkuji, že jste si to přečetli.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Reverse Engineering A Unique Data Structure - Part 3, Course &amp; Course Details</title>
          <pubDate>Wed, 03 Sep 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-3/</link>
          <guid>https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-3/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-3/">&lt;p&gt;Second part of the series, Search, can be found here:
&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;reverse-engineering-unique-data-structure-pt-2&#x2F;&quot;&gt;Search for Places and Courses&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;In the previous two posts, we learned how to &lt;code&gt;GET&lt;&#x2F;code&gt;, scrub, and parse &lt;code&gt;Courses&lt;&#x2F;code&gt;
data, as well as how to use the search endpoint for &lt;code&gt;Places&lt;&#x2F;code&gt; and &lt;code&gt;Courses&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;viewing-a-specific-course&quot;&gt;Viewing a Specific Course&lt;&#x2F;h4&gt;
&lt;p&gt;In this post, we will build on the previous data and show how to find the endpoint
for a specific course and drill down into more data. We&#x27;ll focus on the
&lt;code&gt;courseDetails&lt;&#x2F;code&gt;, using the beloved Maple Hill as our example:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;_id&amp;quot;: &amp;quot;M6vT9WAEmtFKRFr2v&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;Maple Hill&amp;quot;,
  &amp;quot;ratingCount&amp;quot;: 10064,
  &amp;quot;ratingAverage&amp;quot;: 4.899286515846228,
  &amp;quot;locationText&amp;quot;: &amp;quot;Leicester, MA&amp;quot;,
  &amp;quot;courseId&amp;quot;: 1523,
  &amp;quot;shortId&amp;quot;: &amp;quot;lCej&amp;quot;,
  &amp;quot;highlights&amp;quot;: [
    {
      &amp;quot;score&amp;quot;: 2.803980588912964,
      &amp;quot;path&amp;quot;: &amp;quot;name&amp;quot;,
      &amp;quot;texts&amp;quot;: [
        {
          &amp;quot;value&amp;quot;: &amp;quot;Maple&amp;quot;,
          &amp;quot;type&amp;quot;: &amp;quot;hit&amp;quot;
        },
        {
          &amp;quot;value&amp;quot;: &amp;quot; &amp;quot;,
          &amp;quot;type&amp;quot;: &amp;quot;text&amp;quot;
        },
        {
          &amp;quot;value&amp;quot;: &amp;quot;Hill&amp;quot;,
          &amp;quot;type&amp;quot;: &amp;quot;hit&amp;quot;
        }
      ]
    }
  ],
  &amp;quot;searchScore&amp;quot;: 15.834041595458984,
  &amp;quot;autocompleteScore&amp;quot;: 16.079005921251294
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The URL for a course will be a slugified course name, plus the &lt;code&gt;shortId&lt;&#x2F;code&gt;.
For Maple Hill, this URL is:
&lt;code&gt;https:&#x2F;&#x2F;myfrisbee.com&#x2F;courses&#x2F;maple-hill-lCej.data&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Based on observation, spaces become dashes, and special characters are removed
when generating the slug.&lt;&#x2F;p&gt;
&lt;p&gt;For example, Keith L. Christner Family DG Course becomes
keith-l-christner-family-dg-course.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;inspecting-the-data&quot;&gt;Inspecting the Data&lt;&#x2F;h4&gt;
&lt;p&gt;The response from this endpoint is &lt;strong&gt;massive&lt;&#x2F;strong&gt;--so much so that Firefox
truncates it in the Network tab. I had to use curl and save the file locally
just to inspect it.&lt;&#x2F;p&gt;
&lt;p&gt;Again, we are greeted by a large array of mixed types. The more I&#x27;m reading and
learning, it seems like this may not actually be done for any kind of
obfuscation, but for performance or a client side hydration strategy. The
response is very large but it&#x27;s a full page of data and probably replaces
multiple API calls.&lt;&#x2F;p&gt;
&lt;p&gt;This time, the response goes even deeper: it includes a schema map of schema
maps, where keys don&#x27;t point directly to data, but to other schema maps that
must be resolved first.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;routes&amp;#x2F;courses&amp;#x2F;$slug&amp;#x2F;index&amp;quot;,
  {
    &amp;quot;_3&amp;quot;: 143
  },
  {
    &amp;quot;_144&amp;quot;: 145,
    &amp;quot;_335&amp;quot;: 336,
    &amp;quot;_343&amp;quot;: 344,
    &amp;quot;_24719&amp;quot;: 24720,
    &amp;quot;_24957&amp;quot;: 24958,
    &amp;quot;_25013&amp;quot;: 25014,
    &amp;quot;_25150&amp;quot;: 25151,
    &amp;quot;_25410&amp;quot;: 25411,
    &amp;quot;_25435&amp;quot;: 25436,
    &amp;quot;_25481&amp;quot;: 25482,
    &amp;quot;_25513&amp;quot;: -7,
    &amp;quot;_25514&amp;quot;: 25515
  },
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The second object is just full of references to other references. If you follow
each one of those schema maps, we can sometimes get the location of each
field&#x27;s key and value. Often times, there are just more schema maps to follow.
Here is a small snippet:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;smartLayouts&amp;quot;: [345, 3534, 12323, 13767, 14131, 14506, 19520, 22543],
&amp;quot;classicLayouts&amp;quot;: [24721],
&amp;quot;reviews&amp;quot;: [24959, 24981, 24992, 25002],
&amp;quot;photos&amp;quot;: [25015, 25083, 25097, 25107, 25128],
&amp;quot;nearbyCourses&amp;quot;: [25152, 25226, 25280, 25344],
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Going to &lt;code&gt;array[345]&lt;&#x2F;code&gt;, we get this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;_66&amp;quot;: 346,
  &amp;quot;_84&amp;quot;: 85,
  &amp;quot;_347&amp;quot;: 348,
  &amp;quot;_349&amp;quot;: 350,
  &amp;quot;_72&amp;quot;: 3505,
  &amp;quot;_3506&amp;quot;: 195,
  &amp;quot;_3507&amp;quot;: 3508,
  &amp;quot;_147&amp;quot;: 256,
  &amp;quot;_3509&amp;quot;: 3510,
  &amp;quot;_3511&amp;quot;: 3512,
  &amp;quot;_3513&amp;quot;: 3514,
  &amp;quot;_3515&amp;quot;: 3516,
  &amp;quot;_3517&amp;quot;: 3518,
  &amp;quot;_104&amp;quot;: 3519,
  &amp;quot;_167&amp;quot;: 3520,
  &amp;quot;_3521&amp;quot;: 131,
  &amp;quot;_3522&amp;quot;: 320,
  &amp;quot;_3523&amp;quot;: 3524,
  &amp;quot;_3525&amp;quot;: 3526,
  &amp;quot;_3527&amp;quot;: 2679,
  &amp;quot;_3528&amp;quot;: 679,
  &amp;quot;_3529&amp;quot;: 3530,
  &amp;quot;_396&amp;quot;: 3531
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Going to some of those references leads to actual data, but again, often times
it&#x27;s just more mappings.&lt;&#x2F;p&gt;
&lt;p&gt;Now that it makes sense manually, let&#x27;s write some code to do it programmatically.&lt;&#x2F;p&gt;
&lt;p&gt;At a high level, the flow is:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;const schemaMap = resolveCourseSchemaMapSchema(data);
const courseDetailsSchema = schemaMap.courseDetail;
const courseDetails = resolveKeyAndValueNames(courseDetailsSchema, mockCourse);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ol&gt;
&lt;li&gt;Break the big array down into a schema map of schema maps&lt;&#x2F;li&gt;
&lt;li&gt;Pass that &lt;code&gt;courseDetailsSchema&lt;&#x2F;code&gt; and our original array to &lt;code&gt;resolveKeyAndValueNames&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h4 id=&quot;resolving-a-course-s-details-structure&quot;&gt;Resolving a Course&#x27;s Details Structure&lt;&#x2F;h4&gt;
&lt;p&gt;The function below does 3 important things:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;finds our route key label. this key seems to precede the schema map.&lt;&#x2F;li&gt;
&lt;li&gt;follows the next pointer to the schema map schema map&lt;&#x2F;li&gt;
&lt;li&gt;passes that to &lt;code&gt;resolveKeyAndValueNames&lt;&#x2F;code&gt; to resolve each set of key&#x2F;values&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;pre&gt;&lt;code&gt;export function resolveCourseSchemaMapSchema(data: any[]) {
  for (let i = 0; i &amp;lt; data.length - 2; i++) {
    const label = data[i];
    const pointerMap = data[i + 1];
    const schemaMap = data[i + 2];

    if (
      label === &amp;quot;routes&amp;#x2F;courses&amp;#x2F;$slug&amp;#x2F;index&amp;quot; &amp;amp;&amp;amp;
      typeof pointerMap === &amp;quot;object&amp;quot; &amp;amp;&amp;amp;
      typeof schemaMap === &amp;quot;object&amp;quot; &amp;amp;&amp;amp;
      Object.keys(pointerMap).length === 1
    ) {
      const pointerIndex = Object.values(pointerMap)[0];
      const referencedMap = data[pointerIndex];

      if (typeof referencedMap === &amp;quot;object&amp;quot;) {
        return resolveKeyAndValueNames(referencedMap, data);
      }
    }
  }
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;That leaves us with a decoded schema object, where each field (like
&lt;code&gt;courseDetail&lt;&#x2F;code&gt;, &lt;code&gt;reviews&lt;&#x2F;code&gt;, &lt;code&gt;smartLayouts&lt;&#x2F;code&gt;) still contains schema references or
arrays of indexes:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
  courseDetail: {
    _66: 67,
    _68: 69,
    _146: 131,
    _147: 148,
    _72: 73,
    _149: 109,
    _150: 151,
    _152: 108,
    _84: 85,
    _153: 154,
    _96: 97,
    _155: 156,
    _158: 159,
    _160: 161,
    _162: 163,
    _164: 165,
    _102: 172,
    _110: 111,
    _130: 131,
    _174: 175,
    _176: 177,
    _180: 181,
    _184: 185,
    _56: 302,
    _303: 304,
    _305: 131,
    _132: 7,
    _133: 131,
    _306: 307,
    _308: 309,
    _310: 311,
    _312: 313,
    _314: 315,
    _316: 317,
    _318: 319,
    _134: 131,
    _323: 324,
    _328: 21,
    _329: 330,
    _331: 7,
    _332: 131,
    _333: 7,
    _334: 7
  },
  normalizedCourseTraffic: { _337: 338 },
  smartLayouts: [
      345,  3534,
    12323, 13767,
    14131, 14506,
    19520, 22543
  ],
  classicLayouts: [ 24721 ],
  reviews: [ 24959, 24981, 24992, 25002 ],
  photos: [ 25015, 25083, 25097, 25107, 25128 ],
  nearbyCourses: [ 25152, 25226, 25280, 25344 ],
  nearbyStores: [ 25412, 25424 ],
  events: [ 25437, 25460, 25470 ],
  badges: [
    25483, 25494, 25498,
    25500, 25505, 25506,
    25508, 25509, 25511,
    25512
  ],
  globalLayoutAverages: undefined,
  userStatus: &amp;#x27;loggedOut&amp;#x27;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Next is our core function. We take the &lt;code&gt;courseDetail&lt;&#x2F;code&gt; schema map and pass it
and our original data array to &lt;code&gt;resolveKeyAndValueNames&lt;&#x2F;code&gt; to resolve the
actual values.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;#x2F;**
 * Resolves a schema map where both keys and values are index references into the data array.
 *&amp;#x2F;
export function resolveKeyAndValueNames(schema: Record&amp;lt;string, number&amp;gt;, data: any[]): Record&amp;lt;string, any&amp;gt; {
  const result: Record&amp;lt;string, any&amp;gt; = {};
  for (const rawKey in schema) {
    const keyIndex = parseInt(rawKey.replace(&amp;#x2F;^_&amp;#x2F;, &amp;quot;&amp;quot;), 10);
    const valIndex = schema[rawKey];

    const fieldName = data[keyIndex];
    if (typeof fieldName !== &amp;quot;string&amp;quot;) {
      throw new Error(`Expected string field name at index ${keyIndex}, got: ${typeof fieldName}`);
    }

    result[fieldName] = data[valIndex];
  }
  return result;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;We are now left with a nicely formatted and readable object of values:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;_id&amp;quot;: &amp;quot;M6vT9WAEmtFKRFr2v&amp;quot;,
  &amp;quot;holeCount&amp;quot;: 18,
  &amp;quot;isPay&amp;quot;: true,
  &amp;quot;status&amp;quot;: &amp;quot;Active&amp;quot;,
  &amp;quot;name&amp;quot;: &amp;quot;Maple Hill&amp;quot;,
  &amp;quot;latitude&amp;quot;: 42.276001,
  &amp;quot;website&amp;quot;: &amp;quot;http:&amp;#x2F;&amp;#x2F;maplehilldiscgolf.com&amp;quot;,
  &amp;quot;longitude&amp;quot;: -71.895699,
  &amp;quot;courseId&amp;quot;: 1523,
  &amp;quot;country&amp;quot;: &amp;quot;United States&amp;quot;,
  &amp;quot;availabilityStatus&amp;quot;: &amp;quot;available&amp;quot;,
  &amp;quot;landType&amp;quot;: [157],
  &amp;quot;targetType&amp;quot;: &amp;quot;basket&amp;quot;,
  &amp;quot;targetTypeDescription&amp;quot;: &amp;quot;Black Hole Portal, Black Hole Gravity Version 2&amp;quot;,
  &amp;quot;availabilityTypeDescription&amp;quot;: &amp;quot;Closed the Sunday before Thanksgiving to New Years for Christmas Tree Season. &amp;quot;,
  &amp;quot;conditions&amp;quot;: {
    &amp;quot;_104&amp;quot;: 166,
    &amp;quot;_167&amp;quot;: -5,
    &amp;quot;_168&amp;quot;: 169,
    &amp;quot;_170&amp;quot;: 171
  },
  &amp;quot;location&amp;quot;: {
    &amp;quot;_104&amp;quot;: 105,
    &amp;quot;_106&amp;quot;: 173
  },
  &amp;quot;shortId&amp;quot;: &amp;quot;lCej&amp;quot;,
  &amp;quot;hasBathroom&amp;quot;: true,
  &amp;quot;propertyType&amp;quot;: &amp;quot;dedicated&amp;quot;,
  &amp;quot;activeTargetTypes&amp;quot;: [178, 179],
  &amp;quot;activeTeeTypes&amp;quot;: [182, 183],
  &amp;quot;layoutConfiguration&amp;quot;: {
    &amp;quot;_186&amp;quot;: 187,
    &amp;quot;_223&amp;quot;: 224,
    &amp;quot;_251&amp;quot;: 252,
    &amp;quot;_289&amp;quot;: 290,
    &amp;quot;_299&amp;quot;: 300,
    &amp;quot;_301&amp;quot;: -5
  },
  &amp;quot;timezone&amp;quot;: &amp;quot;America&amp;#x2F;New_York&amp;quot;,
  &amp;quot;otherFees&amp;quot;: &amp;quot;$5 Walker fee for those not playing&amp;quot;,
  &amp;quot;isSmartLayoutEnabled&amp;quot;: true,
  &amp;quot;hasDrinkingWater&amp;quot;: false,
  &amp;quot;isDogFriendly&amp;quot;: true,
  &amp;quot;amenitiesRating&amp;quot;: 4.754434159544442,
  &amp;quot;designRating&amp;quot;: 4.920076530755979,
  &amp;quot;sceneryRating&amp;quot;: 4.923442842581462,
  &amp;quot;signageRating&amp;quot;: 4.747814409366677,
  &amp;quot;teeRating&amp;quot;: 4.882374770235053,
  &amp;quot;upkeepRating&amp;quot;: 4.891556089317444,
  &amp;quot;difficultyBins&amp;quot;: [320, 321, 322],
  &amp;quot;isCartFriendly&amp;quot;: true,
  &amp;quot;contactInfo&amp;quot;: {
    &amp;quot;_325&amp;quot;: 326,
    &amp;quot;_327&amp;quot;: 326
  },
  &amp;quot;accessTypeDescription&amp;quot;: &amp;quot;&amp;quot;,
  &amp;quot;accessibility&amp;quot;: &amp;quot;notAccessible&amp;quot;,
  &amp;quot;byob&amp;quot;: false,
  &amp;quot;dedicatedTargets&amp;quot;: true,
  &amp;quot;hasAvailabilityRestrictions&amp;quot;: false,
  &amp;quot;underConstruction&amp;quot;: false
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Note: Some fields like &lt;code&gt;conditions&lt;&#x2F;code&gt;, &lt;code&gt;location&lt;&#x2F;code&gt;, and &lt;code&gt;layoutConfiguration&lt;&#x2F;code&gt; are
still partially encoded -- they contain their own schema maps or references that
need to be decoded separately using &lt;code&gt;resolveKeyAndValueNames&lt;&#x2F;code&gt;. We&#x27;ll cover
those in a future post when we dive deeper into layout and field-level data.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h4&gt;
&lt;p&gt;In this post, we decoded the structure of MyFrisbee&#x27;s &lt;code&gt;courseDetails&lt;&#x2F;code&gt; payloads by:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Resolving the &lt;code&gt;&quot;routes&#x2F;courses&#x2F;$slug&#x2F;index&quot;&lt;&#x2F;code&gt; entry&lt;&#x2F;li&gt;
&lt;li&gt;Using &lt;code&gt;resolveCourseSchemaMapSchema&lt;&#x2F;code&gt; to get a field-labeled schema&lt;&#x2F;li&gt;
&lt;li&gt;Running that through &lt;code&gt;resolveKeyAndValueNames&lt;&#x2F;code&gt; to extract readable values&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This pattern gives us a fully usable course object with real field names. While
some nested fields (like &lt;code&gt;conditions&lt;&#x2F;code&gt; or &lt;code&gt;layoutConfiguration&lt;&#x2F;code&gt;) still require
additional decoding, we now have the tools to confidently work with MyFrisbee&#x27;s
structured course data.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;next-time&quot;&gt;Next Time&lt;&#x2F;h4&gt;
&lt;p&gt;In the next post, we&#x27;ll go one level deeper--resolving nested layout data like
hole-by-hole distances and tee types.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Reverse Engineering A Unique Data Structure - Part 2, Search</title>
          <pubDate>Sat, 30 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-2/</link>
          <guid>https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-2/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-2/">&lt;p&gt;First part of the series, Courses, can be found here:
&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;reverse-engineering-unique-data-structure-pt-1&#x2F;&quot;&gt;Courses&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h4 id=&quot;search-for-places&quot;&gt;Search for Places&lt;&#x2F;h4&gt;
&lt;p&gt;In the first post in this series, we were able to parse and extract a list
of &lt;code&gt;Course&lt;&#x2F;code&gt; data. This is great, but we&#x27;re simply getting back 20 random courses
that might not mean anything to the end user. Lucky for us, there are a couple
search options.&lt;&#x2F;p&gt;
&lt;p&gt;Going to &lt;a href=&quot;https:&#x2F;&#x2F;myfrisbee.com&#x2F;courses&quot;&gt;https:&#x2F;&#x2F;myfrisbee.com&#x2F;courses&lt;&#x2F;a&gt; and starting to
type in the search box reveals some network calls:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&#x2F;places&#x2F;search?term=charlotte&amp;amp;limit=5&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This is getting called for every character that is typed and should probably
be debounced a little.&lt;&#x2F;p&gt;
&lt;p&gt;Anyway, from that call, we get some pretty well formed data instead of the
index&#x2F;schema map data like before. Here is one result:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
&amp;quot;_id&amp;quot;: &amp;quot;NxHZZBpPjDnXHbbZg&amp;quot;,
&amp;quot;type&amp;quot;: &amp;quot;city&amp;quot;,
&amp;quot;slug&amp;quot;: &amp;quot;charlotte-nc&amp;quot;,
&amp;quot;name&amp;quot;: &amp;quot;Charlotte&amp;quot;,
&amp;quot;country&amp;quot;: &amp;quot;United States&amp;quot;,
&amp;quot;countryCode&amp;quot;: &amp;quot;US&amp;quot;,
&amp;quot;admin1&amp;quot;: &amp;quot;North Carolina&amp;quot;,
&amp;quot;city&amp;quot;: &amp;quot;Charlotte&amp;quot;,
&amp;quot;location&amp;quot;: [
  -80.84313,
  35.22709
],
&amp;quot;score&amp;quot;: 98.75883083083083,
&amp;quot;fullLocationText&amp;quot;: &amp;quot;Charlotte North Carolina United States&amp;quot;,
&amp;quot;highlights&amp;quot;: [
  {
    &amp;quot;score&amp;quot;: 1.4719792604446411,
    &amp;quot;path&amp;quot;: &amp;quot;fullLocationText&amp;quot;,
    &amp;quot;texts&amp;quot;: [
      {
        &amp;quot;value&amp;quot;: &amp;quot;Charlotte North Carolina&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;hit&amp;quot;
      },
      {
        &amp;quot;value&amp;quot;: &amp;quot; United States&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;text&amp;quot;
      }
    ]
  }
],
&amp;quot;autocompleteScore&amp;quot;: 72.5995921895284
},
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;There seem to be a few options for &lt;code&gt;type&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;city&lt;&#x2F;li&gt;
&lt;li&gt;admin1 (which appears to represent state in the US)&lt;&#x2F;li&gt;
&lt;li&gt;country&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Now, you can take that &lt;code&gt;slug&lt;&#x2F;code&gt; and plug it into the courses call:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&#x2F;courses.data?placeId=charlotte-nc&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;See the first &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;reverse-engineering-unique-data-structure-pt-1&#x2F;&quot;&gt;post&lt;&#x2F;a&gt;
on how to parse and extract that data.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;search-for-courses&quot;&gt;Search for Courses&lt;&#x2F;h4&gt;
&lt;p&gt;The Places endpoint was not the only one being hit though. When typing in the
search box, you will also see this:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&#x2F;api&#x2F;courses&#x2F;search?includeGenerallyUnavailableCourses=true&amp;amp;term=hornets nest&amp;amp;limit=5&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Again, we get some pretty well formed data:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
&amp;quot;_id&amp;quot;: &amp;quot;jJAW46X3WX8qT7mXR&amp;quot;,
&amp;quot;locationText&amp;quot;: &amp;quot;Charlotte, VT&amp;quot;,
&amp;quot;ratingAverage&amp;quot;: 3.4976632020706235,
&amp;quot;courseId&amp;quot;: 11220,
&amp;quot;ratingCount&amp;quot;: 239,
&amp;quot;name&amp;quot;: &amp;quot;Charlotte Beach&amp;quot;,
&amp;quot;shortId&amp;quot;: &amp;quot;j04j&amp;quot;,
&amp;quot;highlights&amp;quot;: [
  {
    &amp;quot;score&amp;quot;: 2.771970748901367,
    &amp;quot;path&amp;quot;: &amp;quot;name&amp;quot;,
    &amp;quot;texts&amp;quot;: [
      {
        &amp;quot;value&amp;quot;: &amp;quot;Charlotte&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;hit&amp;quot;
      },
      {
        &amp;quot;value&amp;quot;: &amp;quot; Beach&amp;quot;,
        &amp;quot;type&amp;quot;: &amp;quot;text&amp;quot;
      }
    ]
  }
],
&amp;quot;searchScore&amp;quot;: 11.487470626831055,
&amp;quot;autocompleteScore&amp;quot;: 11.66234797214058
},
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Next time I will show you how to take that data and get a specific &lt;code&gt;Course&lt;&#x2F;code&gt;
endpoint and data.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;another-way-to-fuzzy-find-courses&quot;&gt;Another Way to Fuzzy Find Courses&lt;&#x2F;h4&gt;
&lt;p&gt;Not exactly a search but there appears to be at least one more way of fuzzy finding
courses. The &lt;code&gt;courses.data&lt;&#x2F;code&gt; endpoint can take another param called
&lt;code&gt;courseTerm&lt;&#x2F;code&gt;. The full URL would be:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;&#x2F;courses.data?courseTerm=charlotte&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This will return course results with &quot;Charlotte&quot; in the name. I&#x27;m thinking
this endpoint is there for list type pages and the search endpoint might just be
for autocomplete drop downs.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h4&gt;
&lt;p&gt;Showed how to search for places and courses&lt;&#x2F;p&gt;
&lt;h4 id=&quot;plan-for-next-time&quot;&gt;Plan for Next Time&lt;&#x2F;h4&gt;
&lt;p&gt;Drill down into specific course data&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 35&#x2F;52</title>
          <pubDate>Fri, 29 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-35/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-35/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-35/">&lt;p&gt;This week covered a mix of personal projects, resume updates, and site tweaks.
On the budgeting front, cashctl planning continued, with refinements to the
workflow around paydays, file naming, and how income gets assigned to periods.
Simulated paycheck commands and outputs were discussed to bring the tool closer
to being usable.&lt;&#x2F;p&gt;
&lt;p&gt;Work on the personal site also came up. A Zola question about sharing one resume
template across multiple pages was raised, along with CSS improvements for
headings that stay mobile friendly while keeping within an 80-character layout.&lt;&#x2F;p&gt;
&lt;p&gt;Resume polish was another theme. A contribution to the AWS SDK for Ruby was
turned into a resume-style entry, with placement under the Discourse role
considered for now. Additional feedback was asked for on how to position open
source work.&lt;&#x2F;p&gt;
&lt;p&gt;The node-postgres-exporter project also got a GitHub star, and further
improvements were considered, such as in-memory caching and local storage to
smooth over network blips.&lt;&#x2F;p&gt;
&lt;p&gt;There was also some lighter talk--identifying a pine tree, considering Vermont
properties, and chatting about power adapters and credit scores.&lt;&#x2F;p&gt;
&lt;p&gt;Overall, the week blended cashctl development, resume building, static site
refinements, and a bit of infrastructure and personal exploration.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Reverse Engineering A Unique Data Structure - Part 1, Courses</title>
          <pubDate>Wed, 27 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-1/</link>
          <guid>https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-1/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/reverse-engineering-unique-data-structure-pt-1/">&lt;p&gt;&lt;strong&gt;Note: Real app name and URL have been replaced&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;I am a very avid fan and player of disc golf.&lt;&#x2F;p&gt;
&lt;p&gt;Disc golf is basically the rules of golf but you&#x27;re throwing a frisbee
instead of hitting a ball. There are differences of course. We don&#x27;t throw
the disc into a hole in the ground, but the basic idea of starting in one place
and getting an object to another remains the same. It&#x27;s a beautiful sport that
combines the mystique of physics and flight with the beauty and tranquility of
nature. I get to take a hike and throw stuff through the woods, what&#x27;s not to
love? Like golf, there is a scoring system, and that&#x27;s where &quot;MyFrisbee&quot; comes
in.&lt;&#x2F;p&gt;
&lt;p&gt;&quot;MyFrisbee&quot; is a disc golf scoring app. You can search for courses, find
leagues, and most importantly, keep score. It&#x27;s a useful tool with one
problem - there is no public API. I have seen this asked for publicly but
MyFrisbee has shown no plans for this in the immediate future, and that&#x27;s where
I come in. I really like figuring out how things work.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;starting-small&quot;&gt;Starting small&lt;&#x2F;h4&gt;
&lt;p&gt;This is the first real API I&#x27;ve tried reverse engineering so I wanted to start
small. Some of the features of MyFrisbee are paid and only available in the app and
I didn&#x27;t want to dive into that just yet. There are parts of the service that
are available on the website so I wanted to start there.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;courses&quot;&gt;Courses&lt;&#x2F;h4&gt;
&lt;p&gt;The biggest feature of the free version of the app is probably the course
directory so that&#x27;s where I started.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;myfrisbee.com&#x2F;courses&quot;&gt;https:&#x2F;&#x2F;myfrisbee.com&#x2F;courses&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Going directly to the courses URL, you can use the Developer Tools and the
Network tab to already find some useful calls. The most important is a call
to &lt;code&gt;&#x2F;__manifest&lt;&#x2F;code&gt;. This seems to strongly suggest at least the site is a Remix
Run based app, also indicated by the &lt;code&gt;p&lt;&#x2F;code&gt; parameters.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;remix.run&#x2F;&quot;&gt;https:&#x2F;&#x2F;remix.run&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The full URL looks like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;GET
	https:&amp;#x2F;&amp;#x2F;myfrisbee.com&amp;#x2F;__manifest?p=&amp;#x2F;courses&amp;amp;p=&amp;#x2F;courses&amp;#x2F;carey-park-disc-golf-course-e2a5&amp;amp;p=&amp;#x2F;courses&amp;#x2F;cheney-state-park-disc-golf-course-west-DnNL&amp;amp;p=&amp;#x2F;courses&amp;#x2F;chisholm-trail-3RqD&amp;amp;p=&amp;#x2F;courses&amp;#x2F;cowskin-mKI4&amp;amp;p=&amp;#x2F;courses&amp;#x2F;cowskin-west-MXwZ&amp;amp;p=&amp;#x2F;courses&amp;#x2F;dragon-disc-golf-course-ePkd&amp;amp;p=&amp;#x2F;courses&amp;#x2F;flying-bee-country-club-WOm5&amp;amp;p=&amp;#x2F;courses&amp;#x2F;garden-plain-city-park-dgc-dsry&amp;amp;p=&amp;#x2F;courses&amp;#x2F;helten-gardens-Bupy&amp;amp;p=&amp;#x2F;courses&amp;#x2F;keith-l-christner-family-dg-course-kLoh&amp;amp;p=&amp;#x2F;courses&amp;#x2F;lazys-links-1S2k&amp;amp;p=&amp;#x2F;courses&amp;#x2F;liberty-links-disc-golf-course-uwbs&amp;amp;p=&amp;#x2F;courses&amp;#x2F;linear-park-0KnA&amp;amp;p=&amp;#x2F;courses&amp;#x2F;llama-loop-qBK4&amp;amp;p=&amp;#x2F;courses&amp;#x2F;maize-south-dgc-0eco&amp;amp;p=&amp;#x2F;courses&amp;#x2F;newman-university-9Fmt&amp;amp;p=&amp;#x2F;courses&amp;#x2F;orchard-disc-golf-course-7F1E&amp;amp;p=&amp;#x2F;courses&amp;#x2F;prairie-winds-disk-golf-course-LiKg&amp;amp;p=&amp;#x2F;courses&amp;#x2F;quail-run-gFli&amp;amp;p=&amp;#x2F;courses&amp;#x2F;west-millbrook-dgc-tDG5&amp;amp;version=149b0717
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;While this does tell us a lot, it doesn&#x27;t give us any real data. If we
try changing filters, we see another useful call.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;GET https:&#x2F;&#x2F;myfrisbee.com&#x2F;courses.data?sortOrder=rating&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Fore! Data incoming. While it&#x27;s very, &lt;strong&gt;very&lt;&#x2F;strong&gt; obfuscated, you can see some real
data in there.&lt;&#x2F;p&gt;
&lt;p&gt;We also can confirm our suspicions by looking at the response headers:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;x-remix-response yes
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Most definitely a Remix based app.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;parsing-the-data&quot;&gt;Parsing The Data&lt;&#x2F;h3&gt;
&lt;p&gt;Looking at the data itself, it looks JSON-y, but when trying to parse it, we find
it&#x27;s corrupt and it fails. There are some foreign chars between two valid JSON
arrays. This seems intentional as it&#x27;s not breaking their site so they must
know about it? Regardless, let&#x27;s scrub that out.&lt;&#x2F;p&gt;
&lt;p&gt;This function takes our raw string and iterates over it creating a stack and
checking for valid opening JSON brackets. When it finds an opening bracket, it
pushes it to the stack, and when it finds a closing bracket, it pops one off.
When the stack is empty, we know the JSON structure is complete.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;export function extractJsonChunks(raw: string): any[] {
  const chunks: any[] = [];
  let i = 0;

  while (i &amp;lt; raw.length) {
    if (raw[i] === &amp;#x27;[&amp;#x27; || raw[i] === &amp;#x27;{&amp;#x27;) {
      const start = i;
      const stack = [raw[i]];
      i++;

      while (i &amp;lt; raw.length &amp;amp;&amp;amp; stack.length &amp;gt; 0) {
        if (raw[i] === &amp;#x27;[&amp;#x27; || raw[i] === &amp;#x27;{&amp;#x27;) {
          stack.push(raw[i]);
        } else if (raw[i] === &amp;#x27;]&amp;#x27; || raw[i] === &amp;#x27;}&amp;#x27;) {
          stack.pop();
        }
        i++;
      }

      const chunk = raw.slice(start, i);
      try {
        chunks.push(JSON.parse(chunk));
      } catch {
        &amp;#x2F;&amp;#x2F; ignore invalid chunks
      }
    } else {
      i++;
    }
  }

  return chunks;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h4 id=&quot;inspecting-the-data&quot;&gt;Inspecting The Data&lt;&#x2F;h4&gt;
&lt;p&gt;After successfully parsing the data, and there is &lt;strong&gt;a lot&lt;&#x2F;strong&gt; of it, we can take
a better look at two very large arrays.&lt;&#x2F;p&gt;
&lt;p&gt;The first part of the first array is mostly config looking stuff - API tokens for
various services and basic user settings like locale.&lt;&#x2F;p&gt;
&lt;p&gt;My interest is mostly in the actual data, which you can kind of see in the snippet
below. These are items in the array, so you can tell the data is there but it&#x27;s
very confusing to read and code against. It would be easy to assume a key then
a value but then you see things like &lt;code&gt;price&lt;&#x2F;code&gt;, followed by &lt;code&gt;status&lt;&#x2F;code&gt; and can tell
&lt;code&gt;price&lt;&#x2F;code&gt; is obviously missing a value.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;_id&amp;quot;,
&amp;quot;cxYvYpWXX66MoAG5f&amp;quot;,
&amp;quot;name&amp;quot;,
&amp;quot;Orchard Disc Golf Course&amp;quot;,
&amp;quot;headline&amp;quot;,
&amp;quot;Moderately wooded technical course, Par 54 unless hole 5 is in long., making that a par 4 hole. (course 55)&amp;quot;,
&amp;quot;longDescription&amp;quot;,
&amp;quot;18 holes, 1 tee for each hole, 2 basket locations for each hole, rotated regularly.  &amp;quot;,
&amp;quot;holeCount&amp;quot;,
18,
&amp;quot;price&amp;quot;,
&amp;quot;status&amp;quot;,
&amp;quot;Active&amp;quot;,
&amp;quot;website&amp;quot;,
&amp;quot;https:&amp;#x2F;&amp;#x2F;www.facebook.com&amp;#x2F;groups&amp;#x2F;270758746431057&amp;quot;,
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Before any of that actual data, there is an array which didn&#x27;t make much sense
at first.&lt;&#x2F;p&gt;
&lt;p&gt;After some time digging and debugging (and a little help from ChatGPT), it
clicked into place.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;&amp;quot;courseResults&amp;quot;,
  [
    73,
    253,
    305,
    382,
    451,
    538,
    598,
    661,
    718,
    774,
    821,
    864,
    919,
    979,
    1026,
    1074,
    1123,
    1174,
    1224,
    1285
  ],
  {
    &amp;quot;_74&amp;quot;: 75,
    &amp;quot;_76&amp;quot;: 77,
    &amp;quot;_78&amp;quot;: 79,
    &amp;quot;_80&amp;quot;: 81,
    &amp;quot;_82&amp;quot;: 83,
    &amp;quot;_84&amp;quot;: 21,
    &amp;quot;_85&amp;quot;: 86,
    &amp;quot;_87&amp;quot;: 88,
    &amp;quot;_89&amp;quot;: 90,
    &amp;quot;_91&amp;quot;: 92,
    &amp;quot;_93&amp;quot;: 94,
    &amp;quot;_95&amp;quot;: 96,
    &amp;quot;_97&amp;quot;: 98,
    &amp;quot;_99&amp;quot;: 100,
    &amp;quot;_101&amp;quot;: 102,
    &amp;quot;_103&amp;quot;: 104,
    &amp;quot;_105&amp;quot;: 106,
    &amp;quot;_107&amp;quot;: 108,
    &amp;quot;_109&amp;quot;: 110,
    &amp;quot;_111&amp;quot;: 112,
    &amp;quot;_113&amp;quot;: 114,
    &amp;quot;_115&amp;quot;: 116,
    &amp;quot;_117&amp;quot;: 118,
    &amp;quot;_120&amp;quot;: 121,
    &amp;quot;_122&amp;quot;: 123,
    &amp;quot;_124&amp;quot;: 125,
    &amp;quot;_126&amp;quot;: 7,
    &amp;quot;_127&amp;quot;: 128,
    &amp;quot;_129&amp;quot;: 130,
    &amp;quot;_138&amp;quot;: 139,
    &amp;quot;_143&amp;quot;: 144,
    &amp;quot;_145&amp;quot;: 146,
    &amp;quot;_162&amp;quot;: 108,
    &amp;quot;_163&amp;quot;: 164,
    &amp;quot;_165&amp;quot;: 166,
    &amp;quot;_167&amp;quot;: 168,
    &amp;quot;_56&amp;quot;: 170,
    &amp;quot;_171&amp;quot;: 172,
    &amp;quot;_221&amp;quot;: 222,
    &amp;quot;_223&amp;quot;: 125,
    &amp;quot;_224&amp;quot;: 125,
    &amp;quot;_225&amp;quot;: -5,
    &amp;quot;_226&amp;quot;: 125,
    &amp;quot;_227&amp;quot;: 228,
    &amp;quot;_230&amp;quot;: 231,
    &amp;quot;_235&amp;quot;: 21,
    &amp;quot;_236&amp;quot;: 21,
    &amp;quot;_237&amp;quot;: 7,
    &amp;quot;_238&amp;quot;: 125,
    &amp;quot;_239&amp;quot;: 7,
    &amp;quot;_240&amp;quot;: 7,
    &amp;quot;_241&amp;quot;: 7,
    &amp;quot;_242&amp;quot;: 21,
    &amp;quot;_243&amp;quot;: 7,
    &amp;quot;_244&amp;quot;: 245,
    &amp;quot;_246&amp;quot;: 247,
    &amp;quot;_252&amp;quot;: -7
  },
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The first array, &lt;code&gt;courseResults&lt;&#x2F;code&gt; is an index map telling us where in this giant
array each result is. If could you look at the site where we found this URL, you
would notice 20 courses in the UI - it just so happens that there are 20 items
in that &lt;code&gt;courseResults&lt;&#x2F;code&gt; array.&lt;&#x2F;p&gt;
&lt;p&gt;If you happen to check &lt;code&gt;array[73]&lt;&#x2F;code&gt;, it&#x27;s the following object from above and that
turns out to be a schema map - saying that the field name at &lt;code&gt;array[74]&lt;&#x2F;code&gt;
is equal to whatever is at &lt;code&gt;array[75]&lt;&#x2F;code&gt; and so on.&lt;&#x2F;p&gt;
&lt;p&gt;So, taking this further, the first result will have values in this giant array
from &lt;code&gt;74&lt;&#x2F;code&gt; to &lt;code&gt;252&lt;&#x2F;code&gt;. If you notice in our index map array above, the second
result will start at &lt;code&gt;253&lt;&#x2F;code&gt; so that makes perfect sense.&lt;&#x2F;p&gt;
&lt;p&gt;If you look at &lt;code&gt;array[253]&lt;&#x2F;code&gt; you will find another schema index to indicate
where to can find all the key value pairs for the 2nd result, which will actually
start at &lt;code&gt;array[254]&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;To summarize a bit on how this data is encoded:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The file contains multiple top-level JSON blocks, pasted together without separators&lt;&#x2F;li&gt;
&lt;li&gt;The structure includes:
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;top-level&lt;&#x2F;strong&gt; array of values (strings, numbers, objects)&lt;&#x2F;li&gt;
&lt;li&gt;An array named &lt;code&gt;courseResults&lt;&#x2F;code&gt; that lists indices into that top-level array&lt;&#x2F;li&gt;
&lt;li&gt;A set of &lt;strong&gt;object maps&lt;&#x2F;strong&gt; (e.g. at index 73, 253, etc.) that map &quot;key index&quot; → &quot;value index&quot;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The file is basically a compressed CSV, but:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Field names and field values are &lt;strong&gt;stored in different locations&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Object schema = &lt;code&gt;{ &quot;_76&quot;: 77, &quot;_78&quot;: 79 }&lt;&#x2F;code&gt; → &lt;code&gt;&quot;name&quot;&lt;&#x2F;code&gt; is at 76, value is at 77&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;putting-it-all-together&quot;&gt;Putting It All Together&lt;&#x2F;h4&gt;
&lt;p&gt;After deciphering how to read the data manually, it was time to do it
programmatically.&lt;&#x2F;p&gt;
&lt;p&gt;With the help of ChatGPT, I wrote a couple functions that would
take our original data and format it down to something much more useable.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;function findCourseResultIndices(data: any[]): number[] {
  for (let i = 0; i &amp;lt; data.length; i++) {
    if (
      Array.isArray(data[i]) &amp;amp;&amp;amp;
      data[i].every((v: unknown) =&amp;gt; typeof v === &amp;#x27;number&amp;#x27;) &amp;amp;&amp;amp;
      data[i - 1] === &amp;#x27;courseResults&amp;#x27;
    ) {
      return data[i];
    }
  }
  throw new Error(&amp;#x27;Could not find courseResults array&amp;#x27;);
}

export function extractCourses(data: any[]): Record&amp;lt;string, any&amp;gt;[] {
  const schemaIndices = findCourseResultIndices(data);
  const courses: Record&amp;lt;string, any&amp;gt;[] = [];

  for (const schemaIndex of schemaIndices) {
    const schema = data[schemaIndex] as Record&amp;lt;string, number&amp;gt;;
    if (typeof schema !== &amp;#x27;object&amp;#x27; || Array.isArray(schema)) continue;

    const course: Record&amp;lt;string, any&amp;gt; = {};

    for (const [rawFieldKey, valueIndex] of Object.entries(schema)) {
      const fieldKeyIndex = parseInt(rawFieldKey.replace(&amp;#x2F;^_&amp;#x2F;, &amp;#x27;&amp;#x27;), 10);
      const fieldName = data[fieldKeyIndex];
      const value = data[valueIndex];

      if (typeof fieldName === &amp;#x27;string&amp;#x27;) {
        course[fieldName] = value;
      }
    }

    courses.push(course);
  }

  return courses;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;extractCourses&lt;&#x2F;code&gt; courses is a function that takes an array of any types.&lt;&#x2F;p&gt;
&lt;p&gt;First, it calls out to another function, &lt;code&gt;findCourseResultIndices&lt;&#x2F;code&gt;. That function
is iterating over the array, looking for another array of all numbers that is
preceded by a string named &lt;code&gt;courseResults&lt;&#x2F;code&gt; and returns it.&lt;&#x2F;p&gt;
&lt;p&gt;Then we take that list of indices back and iterate over it. For every index
in that list, we build a schema of key value pairs and rebuild down into a simple
&lt;code&gt;Record&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;The result is something like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
    _id: &amp;#x27;cxYvYpWXX66MoAG5f&amp;#x27;,
    name: &amp;#x27;Orchard Disc Golf Course&amp;#x27;,
    headline: &amp;#x27;Moderately wooded technical course, Par 54 unless hole 5 is in long., making that a par 4 hole. (course 55)&amp;#x27;,
    longDescription: &amp;#x27;18 holes, 1 tee for each hole, 2 basket locations for each hole, rotated regularly.  &amp;#x27;,
    holeCount: 18,
    price: &amp;#x27;&amp;#x27;,
    status: &amp;#x27;Active&amp;#x27;,
    website: &amp;#x27;https:&amp;#x2F;&amp;#x2F;www.facebook.com&amp;#x2F;groups&amp;#x2F;270758746431057&amp;#x27;,
    latitude: 38.0998054149767,
    longitude: -97.87738949287784,
    locationText: &amp;#x27;Hutchinson, KS&amp;#x27;,
    yearEstablished: 2018,
    courseId: 20619,
    ratingCount: 755,
    ratingAverage: 4.385343483628396,
    country: &amp;#x27;United States&amp;#x27;,
    countryCode: &amp;#x27;US&amp;#x27;,
    state: &amp;#x27;Kansas&amp;#x27;,
    city: &amp;#x27;Hutchinson&amp;#x27;,
    accessType: &amp;#x27;everyone&amp;#x27;,
    availabilityStatus: &amp;#x27;available&amp;#x27;,
    availabilityType: &amp;#x27;yearRound&amp;#x27;,
    landType: [ 226 ],
    targetType: &amp;#x27;basket&amp;#x27;,
    targetTypeDescription: &amp;#x27;DISCatcher Pro (28 chains)&amp;#x27;,
    hasBathroom: true,
    hasDrinkingWater: false,
    propertyType: &amp;#x27;mixedUse&amp;#x27;,
    conditions: { _146: 195, _196: 21, _197: 342, _199: 343 },
    location: { _146: 166, _167: 345 },
    shortId: &amp;#x27;7F1E&amp;#x27;,
    topPhoto: {
      _74: 348,
      _242: 349,
      _244: 350,
      _246: 351,
      _248: 352,
      _250: 353,
      _252: 354,
      _254: 355
    },
    admin1Name: &amp;#x27;Kansas&amp;#x27;,
    worldRankingsRating: 86.11626662182661,
    activeTargetTypes: [ 340 ],
    activeTeeTypes: [ 259 ],
    timezone: &amp;#x27;America&amp;#x2F;Chicago&amp;#x27;,
    layoutConfiguration: { _127: 360, _134: 363, _139: 374, _155: 380, _162: 383 },
    playFeeType: &amp;#x27;free&amp;#x27;,
    isCartFriendly: true,
    isDogFriendly: true,
    limitedAccessReason: undefined,
    isSmartLayoutEnabled: true,
    difficultyBins: [ 203 ],
    contactInfo: { _387: 388, _389: 388 },
    accessTypeDescription: &amp;#x27;&amp;#x27;,
    availabilityTypeDescription: &amp;#x27;&amp;#x27;,
    byob: false,
    dedicatedTargets: true,
    hasAvailabilityRestrictions: false,
    isLocationPrivate: false,
    isStrollerFriendly: false,
    otherFees: &amp;#x27;&amp;#x27;,
    underConstruction: false,
    accessibility: &amp;#x27;notAccessible&amp;#x27;,
    distanceFromSelectedPlace: { _206: 397, _208: 398 },
    badgesInfo: undefined
  },
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Voila! We have a well formed &lt;code&gt;Course&lt;&#x2F;code&gt; object. If you look at fields like
&lt;code&gt;topPhoto&lt;&#x2F;code&gt;, it looks like the schema map field reference rabbit hole goes even
deeper but that&#x27;s for another post.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h4&gt;
&lt;p&gt;In this post, we have:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Found URL for &lt;code&gt;Courses&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Scrubbed and extracted (broken) JSON from said URL&lt;&#x2F;li&gt;
&lt;li&gt;Discovered data included index and schema maps&lt;&#x2F;li&gt;
&lt;li&gt;Wrote functions to format data into useable object&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;plan-for-next-time&quot;&gt;Plan For Next Time&lt;&#x2F;h4&gt;
&lt;p&gt;Enable the user to search for a &lt;code&gt;Place&lt;&#x2F;code&gt; and &lt;code&gt;Courses&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Contributing to the AWS Ruby SDK: When Documentation and Implementation Don&#x27;t Match</title>
          <pubDate>Sat, 23 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby/</link>
          <guid>https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/contributing-to-aws-sdk-ruby/">&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h3&gt;
&lt;p&gt;I was working on some IAM role configuration at work when our Discourse
deployment started failing with AWS credential errors. The error was
frustrating because according to the AWS documentation, everything should have
been working perfectly.&lt;&#x2F;p&gt;
&lt;p&gt;The deployment was trying to use &lt;code&gt;credential_source = Environment&lt;&#x2F;code&gt; in the AWS
config, which is a standard approach documented in the AWS CLI guide. But for
some reason, the Ruby SDK wasn&#x27;t picking up the credentials.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-investigation&quot;&gt;The Investigation&lt;&#x2F;h3&gt;
&lt;p&gt;Like any good debugging session, I started by tracing through the credential
chain. The AWS Ruby SDK has a pretty clear credential resolution process, so I
dug into the source code to see what was happening.&lt;&#x2F;p&gt;
&lt;p&gt;That&#x27;s when I found something interesting: the AWS CLI documentation clearly
shows &lt;code&gt;Environment&lt;&#x2F;code&gt; as a supported credential source, but when I looked at the
Ruby SDK implementation, that option was completely missing.&lt;&#x2F;p&gt;
&lt;p&gt;Here are the &lt;a href=&quot;https:&#x2F;&#x2F;docs.aws.amazon.com&#x2F;cli&#x2F;latest&#x2F;userguide&#x2F;cli-configure-role.html&quot;&gt;docs&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s what I found in the &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;blob&#x2F;version-3&#x2F;gems&#x2F;aws-sdk-core&#x2F;lib&#x2F;aws-sdk-core&#x2F;shared_config.rb#L362&quot;&gt;SDK&lt;&#x2F;a&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;case credential_source
  when &amp;#x27;Ec2InstanceMetadata&amp;#x27;
    # ... implementation exists
  when &amp;#x27;EcsContainer&amp;#x27;
    # ... implementation exists
  when &amp;#x27;Environment&amp;#x27;
    # ... nothing here! end
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The CLI supports it, the documentation says it should work, but the Ruby SDK
just... doesn&#x27;t implement it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-solution&quot;&gt;The Solution&lt;&#x2F;h3&gt;
&lt;p&gt;This felt like a classic case where implementation lagged behind documentation.
Since I needed this working for our deployment, I decided to contribute the
missing functionality back to the project.&lt;&#x2F;p&gt;
&lt;p&gt;The fix was actually pretty straightforward once I understood the pattern:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;when &amp;#x27;Environment&amp;#x27;
  Aws::Credentials.new(
    ENV[&amp;#x27;AWS_ACCESS_KEY_ID&amp;#x27;],
    ENV[&amp;#x27;AWS_SECRET_ACCESS_KEY&amp;#x27;],
    ENV[&amp;#x27;AWS_SESSION_TOKEN&amp;#x27;]
  )
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;I added comprehensive tests to make sure it worked correctly and that I wasn&#x27;t
breaking any of the existing 20,000+ tests in the project.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-contribution&quot;&gt;The Contribution&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;pull&#x2F;3283&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;pull&#x2F;3283&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s the thing - I don&#x27;t really know Ruby that well. But the codebase was
well-organized and the patterns were clear enough that I could understand what
needed to happen.&lt;&#x2F;p&gt;
&lt;p&gt;I submitted the PR with:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The missing implementation&lt;&#x2F;li&gt;
&lt;li&gt;Proper test coverage&lt;&#x2F;li&gt;
&lt;li&gt;Clear documentation of what was being added&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;the-response&quot;&gt;The Response&lt;&#x2F;h3&gt;
&lt;p&gt;The response from the AWS team was exactly what you hope for in open source:&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;Hey, thanks for opening a PR. I agree that we should support this and I may
make some changes to this PR as well.&quot;&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;That&#x27;s maintainer gold right there - acknowledgment that the gap was real and
willingness to collaborate on getting it merged.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;&#x2F;h3&gt;
&lt;p&gt;This whole experience reinforced a few things for me:&lt;&#x2F;p&gt;
&lt;p&gt;Documentation gaps happen: Even in well-maintained projects like the AWS SDKs,
there can be mismatches between what&#x27;s documented and what&#x27;s implemented.&lt;&#x2F;p&gt;
&lt;p&gt;Don&#x27;t let unfamiliar languages stop you: I was able to contribute meaningfully
to a Ruby project despite not being a Ruby developer. Good code organization
and clear patterns make this possible.&lt;&#x2F;p&gt;
&lt;p&gt;Small changes can have big impact: This was maybe 10 lines of actual code, but
it unblocked our deployment and will help other developers who hit the same
issue.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-bigger-picture&quot;&gt;The Bigger Picture&lt;&#x2F;h3&gt;
&lt;p&gt;This kind of contribution is exactly why I love working as a generalist.
Instead of saying &quot;that&#x27;s not my language&quot; or &quot;someone else should fix the AWS
SDK,&quot; I could trace the problem to its source and ship a fix.&lt;&#x2F;p&gt;
&lt;p&gt;The best part? Our Discourse deployment should work now, and hopefully other
developers won&#x27;t hit this same roadblock in the future.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;update&quot;&gt;Update&lt;&#x2F;h3&gt;
&lt;p&gt;Since writing this, the change has been expanded with additional tests and
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;aws&#x2F;aws-sdk-ruby&#x2F;pull&#x2F;3283&quot;&gt;approved by the maintainers&lt;&#x2F;a&gt;.
It&#x27;s really rewarding to see a small contribution grow into something that&#x27;s
now part of the SDK itself. This is a great reminder that even small fixes
matter -- they can make life easier for the next developer who comes along.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-saga-continues&quot;&gt;The Saga Continues&lt;&#x2F;h3&gt;
&lt;p&gt;This turned out to be just the beginning. After getting the feature merged, I
discovered it didn&#x27;t actually work due to credential chain precedence issues.
Read more in &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;contributing-to-aws-sdk-ruby-pt2&#x2F;&quot;&gt;Part
2&lt;&#x2F;a&gt;
and &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;contributing-to-aws-sdk-ruby-pt3&#x2F;&quot;&gt;Part
3&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 34&#x2F;52</title>
          <pubDate>Fri, 22 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-34/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-34/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-34/">&lt;p&gt;This week balanced travel research, disc golf writing edits, technical
debugging, and creative design work. On the travel side, questions came up
about flying with  money internationally and how ATM fees
might look in the Czech Republic with an OnPoint credit union card. You also
researched the best European power adapters, checking Wirecutter&#x27;s
recommendations for reliable options.&lt;&#x2F;p&gt;
&lt;p&gt;In Shelltrax, you continued to refine autoplay. The &lt;code&gt;play_next_track&lt;&#x2F;code&gt; function
was revisited to keep the footer showing artist and song info, just as before.
The main issue left was that the progress bar didn&#x27;t carry over correctly
between tracks when autoplay advanced.&lt;&#x2F;p&gt;
&lt;p&gt;On the creative side, you explored what the Discourse logo might look like if
it had aged 100 years, and separately, how it could be reimagined if the
company had started as a penpal or letter-writing business. Both ideas fit into
your broader exploration of branding experiments.&lt;&#x2F;p&gt;
&lt;p&gt;The Swedish (and some German) study streak has been extended to 239 days in a
row.&lt;&#x2F;p&gt;
&lt;p&gt;Overall, the week tied together preparations for international travel, cleanup
work on ongoing projects, and a mix of technical and creative problem-solving.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 32+33&#x2F;52</title>
          <pubDate>Sun, 17 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-32-33/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-32-33/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-32-33/">&lt;p&gt;&lt;strong&gt;Early August Projects&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The first week of August was centered on your Node-postgres-exporter work.
You finished the conversion to ESM and TypeScript and started thinking about
next steps. Around the same time, you were also looking into writing a Nix
package for Discourse, asking how to handle complex setups that involve
databases and services.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;System and Config Troubles&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A fair amount of time went into NixOS. You ran into an error with
&lt;code&gt;nixos-option&lt;&#x2F;code&gt; and flakes not being found in &lt;code&gt;.config&#x2F;nix&lt;&#x2F;code&gt;, and later were
debugging Git commit signing issues (wrong email in the commit vs author
fields). You also asked about forcing Git to use a certain SSH key or email
per repo. Other NixOS topics included setting up Wi-Fi on your Framework 16,
writing custom modules (like for Kolide), installing packages for Python and
Ruby in nix-shell, and configuring Starship with Nushell.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Hardware and Devices&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You were troubleshooting Librewolf not showing your Bluetooth headset mic,
plus issues with Wi-Fi and Ethernet modules on the Framework. There was also
a question about pinentry not working with GPG on NixOS. On the hardware
front, you asked about pixel misalignment on your monitor and learned about
TCON as a possible cause.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Lifestyle and Planning&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You asked about FSAs and their use. Travel came up too, with your upcoming
Europe trip and looking  for the best power adapters (especially for the Czech
Republic).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Blogging and Writing&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Your NixOS daily driver series continued, with a post on getting Wi-Fi
working on the Framework 16. You considered what direction to take the next
entry. You also asked for an intro&#x2F;about section to describe yourself,
condensing it down to 5 sentences. In parallel, you&#x27;ve kept up with weekly
summaries and changelog-style posts.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Miscellaneous&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Other threads included practicing Terraform and Nomad, CSS layout questions,
and making Hyprland keybinds prompt for confirmation before closing a window.
On the wearable side, your PineTime step counter broke and you asked about
firmware, Gadgetbridge, and even writing your own step-tracking app in Rust
or Kotlin.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 31&#x2F;52</title>
          <pubDate>Fri, 01 Aug 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-31/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-31/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-31/">&lt;p&gt;&lt;strong&gt;Nix package development&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We discussed writing a Nix package for a complex project with multiple
resources, using the Discourse install-cloud guide as a template to structure
derivations and manage dependencies cleanly.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;NixOS Wi-Fi on Framework 16&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We troubleshooted nmcli reporting the Intel AX210 as DOWN, enabled wireless in
configuration.nix, and resolved common pitfalls to bring up the wireless
interface successfully.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Git commit signing&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We covered forcing Git to use a specific SSH key for a given repository, how to
resign commits, and how to update the commit email so that signatures appear
valid.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Hyprland keybinds&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We added a custom Hyprland keybind to close the active window with a
confirmation dialog, and refined the yes&#x2F;no prompts and dialog appearance for
better usability.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Terraform and Nomad learning&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We explored practical resources--sample projects, tutorials, and sandbox
environments--to build hands-on experience with Terraform and Nomad
orchestration.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Bluetooth audio input&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We investigated why LibreWolf failed to detect a Bluetooth headset mic,
including how to identify the board, check firmware versions, and verify
driver support.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Email server on NixOS&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We outlined setting up a mail server on NixOS and using Caddy to automate TLS
certificate issuance for secure SMTP and IMAP provisioning.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Privacy-focused smartwatches&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We reviewed open-source options like the PineTime, diagnosed step-counter
failures, checked return policies, and sketched a plan for a Rust-based long-
term step tracker integrated with Gadgetbridge.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Swedish Studies&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;221 days in a row on Babbel. Listened to audio books as well this week.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Hyprland Tricks - Close Window Keybind With Confirmation Window</title>
          <pubDate>Mon, 28 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/hyprland-tricks-close-window-confirmation/</link>
          <guid>https://jakegoldsborough.com/blog/2025/hyprland-tricks-close-window-confirmation/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/hyprland-tricks-close-window-confirmation/">&lt;p&gt;By default, &lt;code&gt;hyprctl dispatch killactive&lt;&#x2F;code&gt; will instantly close your focused
window. If you&#x27;re like me and have ever yanked a terminal or browser window
when you weren&#x27;t ready, this tiny script adds a &quot;Yes&#x2F;No&quot; confirmation window
using Wofi.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;wofi-close-window-dialog.png&quot; alt=&quot;A dialog showing a yes or no confirmation selection&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;create-the-script&quot;&gt;Create the Script&lt;&#x2F;h3&gt;
&lt;p&gt;For me, I will be adding this to &lt;code&gt;~&#x2F;dotfiles&#x2F;hypr&#x2F;scripts&#x2F;hypr-confirm-close&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;#!&amp;#x2F;usr&amp;#x2F;bin&amp;#x2F;env bash
# prompt &amp;quot;Close window?&amp;quot; with Yes&amp;#x2F;No via wofi
choice=$(printf &amp;quot;Yes\nNo&amp;quot; \
  | wofi --dmenu --prompt=&amp;quot;Close window?&amp;quot; --lines=5)
[ &amp;quot;$choice&amp;quot; = &amp;quot;Yes&amp;quot; ] &amp;amp;&amp;amp; hyprctl dispatch killactive
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Remember to make it executable.
&lt;code&gt;chmod +x ~&#x2F;dotfiles&#x2F;hypr&#x2F;scripts&#x2F;hypr-confirm-close&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;bind-the-key&quot;&gt;Bind the Key&lt;&#x2F;h3&gt;
&lt;p&gt;In your &lt;code&gt;hyprland.conf&lt;&#x2F;code&gt;:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;bind = $mod, q, exec, bash, ~&amp;#x2F;.config&amp;#x2F;hypr&amp;#x2F;scripts&amp;#x2F;hypr-confirm-close
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The filepath is different here because it will get copied from our &lt;code&gt;~&#x2F;dotfiles&lt;&#x2F;code&gt;
dir to the normal &lt;code&gt;~&#x2F;.config&lt;&#x2F;code&gt; directory.&lt;&#x2F;p&gt;
&lt;p&gt;Reload:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;hyprctl reload&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h3&gt;
&lt;p&gt;Now &lt;code&gt;MOD+q&lt;&#x2F;code&gt; pops up a two-line menu and only when you select &quot;Yes&quot; will your
window be closed.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver or Zero to Nixty, part 9&#x2F;? - Enabling Wi-Fi</title>
          <pubDate>Sun, 27 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-9/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-9/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-9/">&lt;p&gt;I recently got a new
&lt;a href=&quot;https:&#x2F;&#x2F;frame.work&#x2F;products&#x2F;laptop16-diy-amd-7040&quot;&gt;Framework 16&lt;&#x2F;a&gt; (thanks
Discourse!). In the previous post, we talked about encrypting the machine and
refactoring our config into a modular layout. You can read about that and more
in the &lt;a href=&quot;&#x2F;tags&#x2F;nixos&quot;&gt;NixOS As A Daily Driver&lt;&#x2F;a&gt; series.&lt;&#x2F;p&gt;
&lt;p&gt;Today I will be showing how I got Wi-Fi working because it did not work on NixOS
by default. The Framework 16 ships with an Intel AX210 Wi-Fi module.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-symptoms&quot;&gt;The Symptoms&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nmcli device status&lt;&#x2F;code&gt; shows my &lt;code&gt;wlp2s0&lt;&#x2F;code&gt; as &quot;DOWN&quot;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;ip link set wlp2s0 up&lt;&#x2F;code&gt; was not effective&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;nmtui&lt;&#x2F;code&gt; would show SSIDs but refused to connect&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;steps-to-fix&quot;&gt;Steps To Fix&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;Load the Intel driver&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The AX210 needs the &lt;code&gt;iwlwifi&lt;&#x2F;code&gt; kernel module and firmware:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;boot.kernelModules = [ &amp;quot;iwlwifi&amp;quot; ];
hardware.enableRedistributableFirmware = true;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This ensures the driver is in my &lt;code&gt;init-ramfs&lt;&#x2F;code&gt; and the firmware is present.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Explicitly define Wi-Fi backend&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;networking.networkmanager.enable = true;
networking.networkmanager.wifi.backend = &amp;quot;wpa_supplicant&amp;quot;;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;full-minimal-config-snippet&quot;&gt;Full Minimal Config Snippet&lt;&#x2F;h3&gt;
&lt;p&gt;All in all, this is what I added:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# Load the Intel AX210 driver and firmware
boot.kernelModules               = [ &amp;quot;iwlwifi&amp;quot; ];
hardware.enableRedistributableFirmware = true;

# Enable the wireless module and NM
networking.networkmanager.enable = true;
networking.networkmanager.wifi.backend = &amp;quot;wpa_supplicant&amp;quot;;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;rebuild-reboot&quot;&gt;Rebuild &amp;amp; Reboot&lt;&#x2F;h3&gt;
&lt;p&gt;Make sure all the new config will take hold by rebuilding and rebooting.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;sudo nixos-rebuild switch
sudo reboot now
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You can confirm everything worked by running:
&lt;code&gt;nmcli device status | grep wlp2s0&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;victory&quot;&gt;Victory!&lt;&#x2F;h3&gt;
&lt;p&gt;After rebooting, my &lt;code&gt;iwlwifi&lt;&#x2F;code&gt; driver loaded, &lt;code&gt;wlp2s0&lt;&#x2F;code&gt; came up, and &lt;code&gt;nmtui&lt;&#x2F;code&gt; was
able to both see and connect to the SSIDs.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 30&#x2F;52</title>
          <pubDate>Fri, 25 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-30/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-30/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-30/">&lt;h3 id=&quot;tech&quot;&gt;Tech&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;Waybar network module&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Discussions on adding an ethernet or wifi status module to Waybar
configuration for Hyprland in NixOS.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Nixvim branch specification&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Explored how to pin a specific branch in nixvim and how imports resolve
relative paths in modular NixOS configs.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Kolide nix-agent on NixOS&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Reviewed non-flake-based Kolide agent setup, module imports, time zone
settings, and troubleshooting import errors.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Responsive CSS patterns&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Covered CSS techniques for responsive background images and use of the
&lt;code&gt;:before&lt;&#x2F;code&gt; pseudo-element to style boxes.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;TUI music player pause fix&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Debugged keybinding logic in the Rust TUI music player, refining
toggle_pause behavior to eliminate extra keystrokes.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Arch Linux vs NixOS installation&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Compared archinstall features, encryption support, and filesystem choices
(btrfs vs ext4); discussed backup and fallback strategies.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Wi-Fi and GPG in NixOS&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Troubleshooted wifi connections via NetworkManager, git commit email
issues, and GPG pinentry failures in a NixOS environment.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;SSH key management&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Advised on forcing Git to use specific SSH keys per repository and
evaluated ed25519 as the default key type.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Starship prompt in NixOS and Nushell&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Installed and configured Starship in NixOS, and explored Nushell integration.
Need to fix path in Nushell.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Python and Ruby in nix-shell&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Set up nix-shell with Python packages, managed Ruby gems, and fixed
pinentry and gemdir errors within shellHook workarounds.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;personal&quot;&gt;Personal&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;strong&gt;First Week at Discourse!&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;The onboarding process has been very smooth and everyone is very welcoming. I am
excited about my future there.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Swedish&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;215 days in a row.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Today is my first day at Discourse!</title>
          <pubDate>Mon, 21 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/joining-discourse/</link>
          <guid>https://jakegoldsborough.com/blog/2025/joining-discourse/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/joining-discourse/">&lt;p&gt;Today I am joining &lt;a href=&quot;https:&#x2F;&#x2F;discourse.org&quot;&gt;Discourse&lt;&#x2F;a&gt; as a software engineer on
the infrastructure team.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;who-is-discourse&quot;&gt;Who is Discourse?&lt;&#x2F;h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Discourse is the 100% open source discussion platform built for the next
decade of the Internet. We make it easy to have meaningful conversations and
collaborate anytime, anywhere.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;We are the home of ~22k communities across 23 countries and 13 timezones.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-discourse&quot;&gt;Why Discourse?&lt;&#x2F;h3&gt;
&lt;p&gt;I&#x27;m excited about working at Discourse for a number of reasons really.&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Most of my tech and programming focused life has revolved around open source
software and it&#x27;s always been a dream to get paid to work on an open source
software project.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;It&#x27;s a software engineer role on the infrastructure team. I was introduced
to DevOps work at my last job and fell down the rabbit hole. I love config
based workflows so things like IaC and NixOS have really clicked.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;The company as a whole. This kind of goes with number 1 but I feel very
aligned with Discourse as a company. We work in an async format while being
completely remote. The company is very transparent, inclusive, and let&#x27;s be
real, the benefits are great.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;I think this can be a very important and fulfilling chapter of my professional
and personal life and I&#x27;m looking forward to making the internet a better place
through open source software.&lt;&#x2F;p&gt;
&lt;p&gt;This is the second time now I&#x27;ve lost a job then found a much, much better one.
I think this is a valuable lesson to try and remember if I&#x27;m in that situation
again.&lt;&#x2F;p&gt;
&lt;p&gt;If Discourse sounds like the place for you, see if we&#x27;re hiring:
&lt;a href=&quot;https:&#x2F;&#x2F;discourse.org&#x2F;jobs&quot;&gt;https:&#x2F;&#x2F;discourse.org&#x2F;jobs&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver or Zero to Nixty, part 8&#x2F;? - Modular Config</title>
          <pubDate>Sun, 20 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-8/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-8/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-8/">&lt;p&gt;Check out the whole series here:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;tags&#x2F;nixos&quot;&gt;NixOS as a Daily Driver - Zero to Nixty&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;intro&quot;&gt;Intro&lt;&#x2F;h3&gt;
&lt;p&gt;Today is all about configuration and setting up our new encrypted machine. As
I stated at the end of the previous post, this didn&#x27;t go as smoothly as I was
hoping.&lt;&#x2F;p&gt;
&lt;p&gt;My plan was to just use labels when setting up the filesystems so my same exact
configuration could be copied around. This plan was short-sighted for a number
of reasons but it was definitely not going to work after seeing what the basic
configuration looked like after installing with encryption enabled. The LUKS
device setup is more complex than the normal, unencrypted filesystem
config. There may be a way to use labels here but I don&#x27;t really want to mess
with that right now. I have an encrypted machine that boots so I&#x27;m going to
leave it that way.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;breaking-config-into-modules&quot;&gt;Breaking Config Into Modules&lt;&#x2F;h3&gt;
&lt;p&gt;After some thinking and research, I decided to refactor the way I store the
config. I had been wanting to modularize it a bit anyway, so what better time
than now.&lt;&#x2F;p&gt;
&lt;p&gt;First, I broke my config up into some modules in a &lt;code&gt;common&lt;&#x2F;code&gt; directory.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;nixos&amp;#x2F;
├── common
│   ├── base.nix
│   ├── desktop.nix
│   ├── packages.nix
│   ├── services.nix
│   ├── uefi.nix
│   └── users.nix
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then, I created a &lt;code&gt;hosts&lt;&#x2F;code&gt; directory that includes a file for each machine.
That file imports these basic modules and adds anything host specific like
hostname.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;nixos&amp;#x2F;
├── hosts
│   ├── framework-personal.nix
│   ├── framework-work.nix
│   └── macbook.nix
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;One of those files looks like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{ ... }:

{
  imports = [
    ..&amp;#x2F;common&amp;#x2F;base.nix
    ..&amp;#x2F;common&amp;#x2F;users.nix
    ..&amp;#x2F;common&amp;#x2F;desktop.nix
    ..&amp;#x2F;common&amp;#x2F;packages.nix
    ..&amp;#x2F;common&amp;#x2F;services.nix
  ];

  networking.hostName = &amp;quot;macbook&amp;quot;;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then I just update the &lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt; that was created for
me and import that one module. One of those files looks like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{ config, pkgs, ... }:

{
  imports = [
    .&amp;#x2F;hardware-configuration.nix
    .&amp;#x2F;hosts&amp;#x2F;macbook.nix
  ];

  # Replace actual UUID below
  boot.initrd.luks.devices.&amp;quot;$UUID&amp;quot;.device = &amp;quot;&amp;#x2F;dev&amp;#x2F;disk&amp;#x2F;by-uuid&amp;#x2F;$UUID&amp;quot;;

  # Do not change this value unless you know what you are doing!
  # This value does *not* affect the Nixpkgs version. It is used to determine
  # whether to enable software and configuration options that require backwards
  # compatibility with older NixOS releases. Changing it may result in a system
  # that cannot be upgraded or has other surprising consequences.
  system.stateVersion = &amp;quot;25.05&amp;quot;;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;complete-new-machine-flow&quot;&gt;Complete New Machine Flow&lt;&#x2F;h3&gt;
&lt;p&gt;Now, my new machine flow looks something like this:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Install NixOS&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Boot up and open a dev shell:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;nix-shell -p git rustup neovim&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;(We use git to check out config, rustup to install rust&#x2F;dotter, and neovim
for easier editing.)&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Check out my dotfiles through https for now. This way I don&#x27;t need to create
an SSH key and add it to Github yet:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;git clone https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;dotfiles.git&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Install latest Rust then dotter:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;rustup install stable; cargo install dotter&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Checkout a new branch in my dotfiles dir and add a new host file&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Add a new local config for Dotter at&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;dotfiles&#x2F;.dotter&#x2F;local.toml&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Use the installed Dotter to deploy my dotfiles:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;~&#x2F;.cargo&#x2F;bin&#x2F;dotter deploy&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Rebuild NixOS:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;sudo nixos-rebuild switch&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Start Hypr:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;Hyprland&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Celebrate! :tada:&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;If everything went well, I am now sitting behind a duplicate of my other machines.
This workflow might seem long but it really only takes 10-30 mins to do all
this. It can obviously vary on your machine and build size. It&#x27;s far easier than
any other new machine flow I&#x27;ve tried. I&#x27;ve read about people using ansible
to do this kind of thing but this just feels so clean and easy once you get
around the rough edges.&lt;&#x2F;p&gt;
&lt;p&gt;I have executed this flow on 3 machines now and it&#x27;s worked well. I had to make
a few small changes like adding a &lt;code&gt;ref&lt;&#x2F;code&gt; to a branch for NixVim, and I&#x27;m
honestly not sure why that is. I don&#x27;t think it has anything to do with my
config as it was building before.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h3&gt;
&lt;p&gt;In this post, I showed how I broke my NixOS config down into modules so they can
be easily imported per host. I think this is a clean and reproducible way
to store and share my NixOS config. I have all my base config in easy to use
modules, then I can simply add any host specific setup to that single file.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;next-time&quot;&gt;Next Time&lt;&#x2F;h3&gt;
&lt;p&gt;At this point, things are feeling pretty good for my setup personally. I keep
things pretty minimal so as long as I can navigate around with the correct
keybinds and have basic tools like Nix&#x2F;Neovim and Librewolf installed and
setup, I&#x27;m mostly good to go. There are definitely some non-NixOS specific
things I could pivot to to stick with the &quot;Daily Driver&quot; theme.&lt;&#x2F;p&gt;
&lt;p&gt;If you&#x27;re reading this and have suggestions or you&#x27;re curious about something,
let me know: &lt;a href=&quot;mailto:nixos@jakegoldsborough.com&quot;&gt;nixos@jakegoldsborough.com&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 29&#x2F;52</title>
          <pubDate>Fri, 18 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-29/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-29/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-29/">&lt;h3 id=&quot;nixos-daily-driver-and-dotfiles&quot;&gt;NixOS Daily Driver and Dotfiles&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Continued refining NixOS setup and daily driver blog series&lt;&#x2F;li&gt;
&lt;li&gt;Modularized dotfiles using Dotter with template support&lt;&#x2F;li&gt;
&lt;li&gt;Adopted approach of letting NixOS generate base config, layering custom on top&lt;&#x2F;li&gt;
&lt;li&gt;Explored idea of a &quot;LazyNix&quot; dev toolkit using importable modules&lt;&#x2F;li&gt;
&lt;li&gt;Troubleshot and resolved GRUB and UEFI encryption setup issues&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;framework-laptop-and-qmk-customization&quot;&gt;Framework Laptop and QMK Customization&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;New laptop! Framework 16&lt;&#x2F;li&gt;
&lt;li&gt;Investigated Framework RGB keyboard and LED matrix customization&lt;&#x2F;li&gt;
&lt;li&gt;Confirmed QMK support and existence of Rust libraries (most tooling is in C)&lt;&#x2F;li&gt;
&lt;li&gt;Searched for examples and guides to interact with Framework&#x27;s LED module&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;task-management-and-productivity-tools&quot;&gt;Task Management and Productivity Tools&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Looked into Rust-based TUI task managers like Taskwarrior&lt;&#x2F;li&gt;
&lt;li&gt;Reviewed Taskwarrior features like due dates, tags, and urgency scoring&lt;&#x2F;li&gt;
&lt;li&gt;Practiced adding and updating tasks with CLI syntax&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;git-and-hosting&quot;&gt;Git and Hosting&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Managing personal site using two remotes, pushing to both for now&lt;&#x2F;li&gt;
&lt;li&gt;Long-term plan is to self-host once everything is working reliably&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;nixcon-2025-planning&quot;&gt;NixCon 2025 Planning&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Would like to attend NixCon in Rapperswil-Jona, Sept 5 to 7&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;avatars-and-stickers&quot;&gt;Avatars and Stickers&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Tweaked Tux-human avatar&lt;&#x2F;li&gt;
&lt;li&gt;Looked for places to buy open source stickers that support the projects&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;neovim-fonts-and-waybar&quot;&gt;Neovim, Fonts, and Waybar&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Set up Nixvim to remove trailing whitespace on save&lt;&#x2F;li&gt;
&lt;li&gt;Troubleshot font issue in WezTerm by refreshing font cache and restarting&lt;&#x2F;li&gt;
&lt;li&gt;Created toggleable Waybar for system stats using manual launch approach&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;language-learning&quot;&gt;Language Learning&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;210 days of Swedish in a row&lt;&#x2F;li&gt;
&lt;li&gt;Trying to pick some German back up for trip to Prague&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver or Zero to Nixty, part 7.5&#x2F;? - Trial by Encrypted Fire</title>
          <pubDate>Sun, 13 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-7-5/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-7-5/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-7-5/">&lt;h3 id=&quot;intro&quot;&gt;Intro&lt;&#x2F;h3&gt;
&lt;p&gt;So far in this series, we have:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;installed NixOS&lt;&#x2F;li&gt;
&lt;li&gt;enabled a desktop environment&lt;&#x2F;li&gt;
&lt;li&gt;setup a user and some basic daily driving tools&lt;&#x2F;li&gt;
&lt;li&gt;added our config to version control&lt;&#x2F;li&gt;
&lt;li&gt;introduced NixVim, a declarative way to configure Neovim&lt;&#x2F;li&gt;
&lt;li&gt;learned about NixOS dev environments&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Catch up here:
&lt;a href=&quot;&#x2F;tags&#x2F;nixos&quot;&gt;NixOS Daily Driver series list&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This post is a bit of a detour as we&#x27;ll be completing a fresh reinstall
to enable disk encryption. We will be getting back to our configuration soon
though. The next post will show how to take our version controlled config
and apply it to our new install.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;encryption&quot;&gt;Encryption&lt;&#x2F;h3&gt;
&lt;p&gt;But first, let&#x27;s talk encryption. So far I have not been installing NixOS with
the drive encrypted. I recently learned that my new job will require the machine
used for work to have an encrypted drive. This is a good thing and I should
have been defaulting to encrypting my drives. Even though it&#x27;s good, it does
add some complexity to our NixOS configuration. Because I was doing this for
the first time, I decided to first test the encrypted installation on an old
System76 netbook I have.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;manual-attempts-with-luks-and-lvm&quot;&gt;Manual Attempts with LUKS and LVM&lt;&#x2F;h3&gt;
&lt;p&gt;Initially I planned to just do this manually. I have installed NixOS manually
every time so far, so I was hoping it would just be a few more steps. For the
most part this was true, but I still ran into some issues.&lt;&#x2F;p&gt;
&lt;p&gt;To start, I used &lt;code&gt;gparted&lt;&#x2F;code&gt;, &lt;code&gt;mkfs&lt;&#x2F;code&gt;, and &lt;code&gt;cryptsetup&lt;&#x2F;code&gt; to format the disk
and create some &lt;code&gt;LUKS&lt;&#x2F;code&gt; partitions.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;partitioning-and-luks-encryption&quot;&gt;Partitioning and LUKS encryption&lt;&#x2F;h4&gt;
&lt;pre&gt;&lt;code&gt;# Partition disk (example: &amp;#x2F;dev&amp;#x2F;sda)
parted &amp;#x2F;dev&amp;#x2F;sda mklabel gpt
parted &amp;#x2F;dev&amp;#x2F;sda mkpart ESP fat32 1MiB 512MiB
parted &amp;#x2F;dev&amp;#x2F;sda set 1 boot on
parted &amp;#x2F;dev&amp;#x2F;sda mkpart primary 512MiB 100%

# Format boot partition
mkfs.vfat -F32 &amp;#x2F;dev&amp;#x2F;sda1

# Encrypt root partition with LUKS
cryptsetup luksFormat &amp;#x2F;dev&amp;#x2F;sda2
cryptsetup open &amp;#x2F;dev&amp;#x2F;sda2 cryptroot

&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This all worked fine and I was able to set a password for the encrypted drive
and then unlock it right after.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;lvm&quot;&gt;LVM&lt;&#x2F;h4&gt;
&lt;p&gt;Now use LVM to allow flexibility inside the encrypted container:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# Initialize LVM
pvcreate &amp;#x2F;dev&amp;#x2F;mapper&amp;#x2F;cryptroot
vgcreate vg &amp;#x2F;dev&amp;#x2F;mapper&amp;#x2F;cryptroot

# Create logical volumes
lvcreate -L 16G -n swap vg
lvcreate -l 100%FREE -n root vg

# Format filesystems
mkfs.ext4 &amp;#x2F;dev&amp;#x2F;vg&amp;#x2F;root
mkswap &amp;#x2F;dev&amp;#x2F;vg&amp;#x2F;swap
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h4 id=&quot;mounting-for-installation&quot;&gt;Mounting for Installation&lt;&#x2F;h4&gt;
&lt;p&gt;Next was to mount our partitions for installation.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# Mount the root volume
mount &amp;#x2F;dev&amp;#x2F;vg&amp;#x2F;root &amp;#x2F;mnt

# Mount the EFI partition
mkdir -p &amp;#x2F;mnt&amp;#x2F;boot
mount &amp;#x2F;dev&amp;#x2F;sda1 &amp;#x2F;mnt&amp;#x2F;boot

# Enable swap
swapon &amp;#x2F;dev&amp;#x2F;vg&amp;#x2F;swap
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h4 id=&quot;configuration-and-installation&quot;&gt;Configuration and Installation&lt;&#x2F;h4&gt;
&lt;p&gt;The next step was to generate the NixOS configuration for our mounted filesystem:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;nixos-generate-config --root &#x2F;mnt&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;When running this, NixOS will generate a basic configuration for you based on
the disks you create and mount. This works well when creating an unencrypted
drive and the boot, root, and swap partitions are all configured correctly. For
whatever reason, the &lt;code&gt;LUKS&lt;&#x2F;code&gt; devices do not get picked up by this. This means
we&#x27;ll have to manually update our configuration to include the &lt;code&gt;LUKS&lt;&#x2F;code&gt; devices.&lt;&#x2F;p&gt;
&lt;p&gt;This is fine because from what I read in the docs, this isn&#x27;t too complex. It
should look something like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# Attempted configuration
boot.initrd.luks.devices.&amp;quot;cryptroot&amp;quot; = {
  device = &amp;quot;&amp;#x2F;dev&amp;#x2F;disk&amp;#x2F;by-uuid&amp;#x2F;XXXX&amp;quot;;
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The docs also mention adding a kernel module:
&lt;code&gt;boot.initrd.kernelModules = [ &quot;cryptd&quot; ];&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;After that, it was time to rebuild then reboot.&lt;&#x2F;p&gt;
&lt;p&gt;And, at first, success! During boot, I get asked for a password to open my
encrypted drive.&lt;&#x2F;p&gt;
&lt;p&gt;But then... failure! The passphrase will not work. My first instinct was I fat
fingered it but I tried multiple times and it would just not work.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;recovery&quot;&gt;Recovery&lt;&#x2F;h4&gt;
&lt;p&gt;Welp, time to boot into the live ISO again. I wanted to doublecheck all the
config and make sure I didn&#x27;t mess something up.&lt;&#x2F;p&gt;
&lt;p&gt;First, I needed to open the encrypted root partition:
&lt;code&gt;cryptsetup open &#x2F;dev&#x2F;sda2 cryptroot&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This creates &lt;code&gt;&#x2F;dev&#x2F;mapper&#x2F;cryptroot&lt;&#x2F;code&gt; and gives access to what&#x27;s inside.&lt;&#x2F;p&gt;
&lt;p&gt;Next, the volume groups needed activated:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;vgscan
vgchange -ay
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then we mount some important directories:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;mount &amp;#x2F;dev&amp;#x2F;vg&amp;#x2F;root &amp;#x2F;mnt
mkdir -p &amp;#x2F;mnt&amp;#x2F;boot
mount &amp;#x2F;dev&amp;#x2F;sda1 &amp;#x2F;mnt&amp;#x2F;boot
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now, we&#x27;ll use &lt;code&gt;nixos-enter&lt;&#x2F;code&gt;. This gave me a shell inside the broken system --
same environment as if it had booted normally.&lt;&#x2F;p&gt;
&lt;p&gt;We can check our config files to make sure nothing looks off:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;nano &amp;#x2F;etc&amp;#x2F;nixos&amp;#x2F;configuration.nix
nano &amp;#x2F;etc&amp;#x2F;nixos&amp;#x2F;hardware-configuration.nix
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Unfortunately for me, I could not find anything wrong. In fact, I tried 3 fresh
installs and could never get it working manually.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;graphical-installer&quot;&gt;Graphical Installer&lt;&#x2F;h3&gt;
&lt;p&gt;After all that, I decided to just give up on the manual install and try the
graphical installer. I should have probably started here but I didn&#x27;t see a
certain sentence in the wiki in time.&lt;&#x2F;p&gt;
&lt;blockquote&gt;
&lt;p&gt;There are a few options for full disk encryption. The easiest way is to use
the graphical installer and choose &quot;encrypt&quot; while doing the installation.&lt;&#x2F;p&gt;
&lt;&#x2F;blockquote&gt;
&lt;p&gt;And ya know what, it was easy. So easy in fact, it worked the first time and I
could instantly power up, enter my encrypted drive password, and be taken to
the login console.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;outro&quot;&gt;Outro&lt;&#x2F;h3&gt;
&lt;p&gt;This post was kind of a detour from the normal series as we reinstalled
so we could encrypt the drive. In the next post, I will be going over how
we can take the configuration we added to git in the previous posts and apply
it to our new, freshly installed and encrypted system.&lt;&#x2F;p&gt;
&lt;p&gt;Spoiler: it does not go as smoothly as I had hoped.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT assisted Changelog - 28&#x2F;52</title>
          <pubDate>Fri, 11 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-28/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-28/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-28/">&lt;h3 id=&quot;20250704-20250711&quot;&gt;20250704 - 20250711&lt;&#x2F;h3&gt;
&lt;h3 id=&quot;cyberspace&quot;&gt;Cyberspace&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;nixos&quot;&gt;NixOS&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Published two posts in Daily Driver series. My late 2011 Macbook Pro running
NixOs is already feeling ready for daily use really:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;nixos-daily-driver-5&#x2F;&quot;&gt;Part 5&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;nixos-daily-driver-6&#x2F;&quot;&gt;Part 6&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Fighting encrypted disk installation. Installation seems fine but then I can&#x27;t
unlock drive to boot up.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;meatspace&quot;&gt;Meatspace&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;disc-golf&quot;&gt;Disc Golf&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Played Pier Park&lt;&#x2F;li&gt;
&lt;li&gt;Played Memorial Park&lt;&#x2F;li&gt;
&lt;li&gt;Published post about experience with UDisc regarding my open source library:
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;fairway-client-not-fair-enough&#x2F;&quot;&gt;Fairway Client Not Fair Enough&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;language-studies&quot;&gt;Language Studies&lt;&#x2F;h4&gt;
&lt;p&gt;&lt;strong&gt;Swedish&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;204 day streak on Babbel. Did a few more lessons this week and not just
review. Listened to some Radio Sweden på lätt Svenska (Radio Sweden in easy
Swedish)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;strong&gt;Czech&#x2F;German&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;I found out I&#x27;ll be going to Prague in Sept so I may try to pick up a little
Czech and brush up on German as many speak it there apparently.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;garden&quot;&gt;Garden&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Harvested 3 squash&lt;&#x2F;li&gt;
&lt;li&gt;Blueberry bush is fruiting&lt;&#x2F;li&gt;
&lt;li&gt;Raspberry bush is flowering&lt;&#x2F;li&gt;
&lt;li&gt;Tomatoes are flowering&#x2F;growing&lt;&#x2F;li&gt;
&lt;li&gt;New tomato and pepper starts are doing well&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;music&quot;&gt;Music&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Listened to a lot of Top Dawg Entertainment this week - Kendrick Lamar,
Ab-Soul, Doechii, Schoolboy-Q&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver or Zero to Nixty, part 6&#x2F;? - Dev environment</title>
          <pubDate>Tue, 08 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-6/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-6/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-6/">&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;&#x2F;h3&gt;
&lt;p&gt;In the last post, we added some quality of life enhancements. This included
key remapping and getting hypridle and hyprlock set up.&lt;&#x2F;p&gt;
&lt;p&gt;See that post &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;nixos-daily-driver-5&quot;&gt;here&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;intro&quot;&gt;Intro&lt;&#x2F;h3&gt;
&lt;p&gt;Today, I will be introducing &lt;code&gt;NixVim&lt;&#x2F;code&gt; and show you how to set up some
per-project development shells. The latter is a very powerful part of NixOS as
you&#x27;ll see.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nixvim&quot;&gt;NixVim&lt;&#x2F;h3&gt;
&lt;p&gt;NixVim is a project that lets you declaratively configure Neovim in Nix. No
&lt;code&gt;init.lua&lt;&#x2F;code&gt;, &lt;code&gt;vimrc&lt;&#x2F;code&gt;, or plugin managers like Lazy or Packer.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s part of a broader Nix-native ecosystem where tools, config, and
dependencies are defined in one declarative, reproducible system.&lt;&#x2F;p&gt;
&lt;p&gt;I use and suggest this for a few reasons:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Fully Declarative Neovim Config&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;This means all your plugins, keymaps, etc
are explicity defined. This will allow you to easily version it, share it, and
reproduce it on another system.&lt;&#x2F;p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Automatic Plugin Management&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;No need to install anything with a plugin manager or run &lt;code&gt;:PackerInstall&lt;&#x2F;code&gt;,
&lt;code&gt;:LazySync&lt;&#x2F;code&gt; etc. When you build Nix, NixVim builds Neovim with all your plugins
downloaded and ready.&lt;&#x2F;p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Tight Integration with Nix packages&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;You can inherit pkgs to bring in dependencies like &lt;code&gt;ripgrep&lt;&#x2F;code&gt;,
&lt;code&gt;lua-language-server&lt;&#x2F;code&gt;, or &lt;code&gt;stylua&lt;&#x2F;code&gt; directly. No external &lt;code&gt;npm&lt;&#x2F;code&gt;, &lt;code&gt;cargo&lt;&#x2F;code&gt;, &lt;code&gt;pip&lt;&#x2F;code&gt;,
or language-specific managers needed.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;initial-config&quot;&gt;Initial Config&lt;&#x2F;h4&gt;
&lt;p&gt;We are going to start small with our config and extend it further later.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{ pkgs, ... }: {
  programs.nixvim = {
    enable = true;

    # Enable basic plugins
    plugins = {
      lualine.enable = true;           # Statusline
      telescope.enable = true;         # Fuzzy finder
      treesitter.enable = true;        # Better syntax highlighting
    };

    # Use a Gruvbox theme (optional)
    colorschemes.gruvbox.enable = true;

    # LSP support
    plugins.lsp = {
      enable = true;

      # Example LSP: Lua
      servers = {
        lua-ls.enable = true;
      }
    }

    # Keymap example
    keymaps = [
      {
        mode = &amp;quot;n&amp;quot;;
        key = &amp;quot;&amp;lt;leader&amp;gt;ff&amp;quot;;
        action = &amp;quot;&amp;lt;cmd&amp;gt;Telescope find_files&amp;lt;CR&amp;gt;&amp;quot;;
        options = {
          desc = &amp;quot;Find files&amp;quot;;
        };
      }
    ];
  };
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Let&#x27;s break this config down.&lt;&#x2F;p&gt;
&lt;p&gt;First, and most importantly, we enable Nixvim by setting
&lt;code&gt;programs.nixvim.enable&lt;&#x2F;code&gt; to &lt;code&gt;true&lt;&#x2F;code&gt;. Then, we enable a few basic plugins through
&lt;code&gt;programs.nixvim.plugins&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;After that we enable the &lt;code&gt;gruvbox&lt;&#x2F;code&gt; color scheme and setup some basic LSP support.&lt;&#x2F;p&gt;
&lt;p&gt;Finally, we setup a keymap for &lt;code&gt;&amp;lt;leader&amp;gt;ff&lt;&#x2F;code&gt; that uses &lt;code&gt;telescope&lt;&#x2F;code&gt; to find files.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nix-shell-what-is-it&quot;&gt;&lt;code&gt;nix-shell&lt;&#x2F;code&gt; - What is it?&lt;&#x2F;h3&gt;
&lt;p&gt;Now onto &lt;code&gt;nix-shell&lt;&#x2F;code&gt;. &lt;code&gt;nix-shell&lt;&#x2F;code&gt; is a tool that lets you temporarily enter a
development environment defined by a Nix expression. Think of it as a
lightweight, project-specific sandbox with all the tools and dependencies you
need -- and nothing you don&#x27;t. They are like Python virtual environments, but
for any tool or language.&lt;&#x2F;p&gt;
&lt;p&gt;You can use &lt;code&gt;nix-shell&lt;&#x2F;code&gt; to:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Spin up a shell with specific packages available&lt;&#x2F;li&gt;
&lt;li&gt;Test tools or languages without installing them system-wide&lt;&#x2F;li&gt;
&lt;li&gt;Set up consistent dev environments across machines or teams&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;why-use-it&quot;&gt;Why Use It?&lt;&#x2F;h4&gt;
&lt;p&gt;If you&#x27;ve ever run into &quot;works on my machine&quot; issues, &lt;code&gt;nix-shell&lt;&#x2F;code&gt; is the
antidote. By declaring your environment as code, you get repeatable,
deterministic setups every time -- no more dependency drift or missing tools.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s also fantastic for trying out a new language or toolchain. Want to test
something in Ruby, Rust, or Go? You can be up and running with just a few
lines.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;example&quot;&gt;Example&lt;&#x2F;h4&gt;
&lt;p&gt;Here’s what a simple &lt;code&gt;shell.nix&lt;&#x2F;code&gt; might look like for Rust development:&lt;&#x2F;p&gt;
&lt;pre data-lang=&quot;nix&quot; class=&quot;language-nix &quot;&gt;&lt;code class=&quot;language-nix&quot; data-lang=&quot;nix&quot;&gt;{ pkgs ? import &amp;lt;nixpkgs&amp;gt; {} }:

pkgs.mkShell {
  buildInputs = [
    pkgs.rustc
    pkgs.cargo
  ];
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Run &lt;code&gt;nix-shell&lt;&#x2F;code&gt; in that directory, and boom, you&#x27;ll be dropped into a shell
environment with &lt;code&gt;rustc&lt;&#x2F;code&gt; and &lt;code&gt;cargo&lt;&#x2F;code&gt; available but without being installed
globally.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;one-off-shells&quot;&gt;One-Off Shells&lt;&#x2F;h4&gt;
&lt;p&gt;In fact, you don&#x27;t even need a &lt;code&gt;shell.nix&lt;&#x2F;code&gt; file. You could run
&lt;code&gt;nix-shell -p nodejs&lt;&#x2F;code&gt; and you would be dropped into a shell with &lt;code&gt;nodejs&lt;&#x2F;code&gt;
installed locally, meaning it&#x27;s not installed globally.&lt;&#x2F;p&gt;
&lt;p&gt;Overall, &lt;code&gt;nix-shell&lt;&#x2F;code&gt; is one of the most powerful parts of Nix&#x2F;NixOS.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h3&gt;
&lt;p&gt;In this post, we have:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Enabled NixVim and set up a simple, usable Neovim config&lt;&#x2F;li&gt;
&lt;li&gt;Introduced &lt;code&gt;nix-shell&lt;&#x2F;code&gt; and showed how it can be used to create temporary,
repeatable dev environments&lt;&#x2F;li&gt;
&lt;li&gt;Explained how to launch one-off shells and why &lt;code&gt;nix-shell&lt;&#x2F;code&gt; is so useful for
development workflows&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver or Zero to Nixty, part 5&#x2F;? - QoL enhancements</title>
          <pubDate>Mon, 07 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-5/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-5/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-5/">&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;&#x2F;h3&gt;
&lt;p&gt;In the last post, we successfully got our configuration stored in version control.
This will make it very simple to pass our config around if needed. We also
learned about Dotter, a dotfile configuration management tool, which we will
be using again today.&lt;&#x2F;p&gt;
&lt;p&gt;See that post &lt;a href=&quot;&#x2F;blog&#x2F;content&#x2F;nixos-daily-driver-4&quot;&gt;here&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;intro&quot;&gt;Intro&lt;&#x2F;h3&gt;
&lt;p&gt;Today, we will be covering a few quality of life enhancements. Those will
include keyboard remapping and locking our screen after a certain amount of
idle time.&lt;&#x2F;p&gt;
&lt;p&gt;Quality of life enhancements are things that while not strictly necessary,
they make life much better.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;keyboard-remapping&quot;&gt;Keyboard Remapping&lt;&#x2F;h3&gt;
&lt;p&gt;First, I&#x27;ll show you how to remap keys. For me personally, this will include:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Caps Lock -&amp;gt; Esc when pressed&lt;&#x2F;li&gt;
&lt;li&gt;Caps Lock -&amp;gt; Control when held (and another key is pressed)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Caps Lock as Control when being held is a new change for me but I&#x27;m really
enjoying it. It has opened up many more keybind options for me.&lt;&#x2F;p&gt;
&lt;p&gt;This remapping will be done using &quot;Interception Tools&quot;&lt;&#x2F;p&gt;
&lt;h4 id=&quot;interception-tools&quot;&gt;Interception Tools&lt;&#x2F;h4&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;wiki.archlinux.org&#x2F;title&#x2F;Interception-tools&quot;&gt;Interception Tools&lt;&#x2F;a&gt; is a
set of utilities to control and customize the behavior of keyboard input
mappings.&lt;&#x2F;p&gt;
&lt;p&gt;It works at a lower level than tools like &lt;code&gt;xcape&lt;&#x2F;code&gt; or &lt;code&gt;xmodmap&lt;&#x2F;code&gt; which makes it
one of the only options available for customizing keyboard behavior across
X11, Wayland, and the Linux console.&lt;&#x2F;p&gt;
&lt;p&gt;Below is an example of mine. It&#x27;s stored in
&lt;code&gt;~&#x2F;dotfiles&#x2F;nixos&#x2F;modules&#x2F;interception-caps.nix&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;This config:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;enables the &lt;code&gt;services.interception-tools&lt;&#x2F;code&gt; module&lt;&#x2F;li&gt;
&lt;li&gt;adds a plugin called caps2esc&lt;&#x2F;li&gt;
&lt;li&gt;adds a &lt;code&gt;udevmonConfig&lt;&#x2F;code&gt; file&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;pre&gt;&lt;code&gt;{ config, pkgs, ...}:

let
  intercept = &amp;quot;${pkgs.interception-tools}&amp;#x2F;bin&amp;#x2F;intercept&amp;quot;;
  uinput = &amp;quot;${pkgs.interception-tools}&amp;#x2F;bin&amp;#x2F;uinput&amp;quot;;
in {
  services.interception-tools = {
    enable = true;
    plugins = with pkgs; [
      interception-tools-plugins.caps2esc
    ];
    udevmonConfig = &amp;#x27;&amp;#x27;
      - JOB: &amp;quot;${intercept} -g $DEVNODE |
              ${pkgs.interception-tools-plugins.caps2esc}&amp;#x2F;bin&amp;#x2F;caps2esc -m 1 |
              ${uinput} -d $DEVNODE&amp;quot;
        DEVICE:
          EVENTS:
            EV_KEY: [KEY_CAPSLOCK, KEY_ESC]
    &amp;#x27;&amp;#x27;;
  };
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Let&#x27;s breakdown what&#x27;s happening here.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;JOB&lt;&#x2F;code&gt; defines a processing pipeline for input events. Each matching device runs
this chain of commands.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;${intercept} -g $DEVNODE&lt;&#x2F;code&gt; reads events from the input device&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;intercept&lt;&#x2F;code&gt; is the main input capture tool&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;-g&lt;&#x2F;code&gt; ensures the device stays grabbed&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;caps2esc -m 1&lt;&#x2F;code&gt; is what actually transform the Caps Lock input into our dual
behavior&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Tap&#x2F;Press = Escape&lt;&#x2F;li&gt;
&lt;li&gt;Hold = Control&lt;&#x2F;li&gt;
&lt;li&gt;Note - Control is only emitted if another key is pressed. holding Caps Lock
will not simply emit Control&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;${input} -d $DEVNODE&lt;&#x2F;code&gt; writes the transformed input back into the system using
&lt;code&gt;uinput&lt;&#x2F;code&gt; kernel device&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;DEVICE&lt;&#x2F;code&gt; block filters which devices this pipelines applies to.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;EV_KEY: [KEY_CAPSLOCK, KEY_ESC]&lt;&#x2F;code&gt; means this only runs for devices that emit
Caps Locks or Escape key events&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In short, this config captures raw key events for Caps Lock and Escape,
transforms them using the caps2esc plugin, and injects the new behavior back
into the system -- all at a low level that works across Wayland, X11, and even
TTYs.&lt;&#x2F;p&gt;
&lt;p&gt;The above is stored in a separate NixOS module and then imported in my main
configuration:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;imports =
  [ # Include the results of the hardware scan.
    &amp;#x2F;etc&amp;#x2F;nixos&amp;#x2F;hardware-configuration.nix
    &amp;#x2F;etc&amp;#x2F;nixos&amp;#x2F;modules&amp;#x2F;interception-caps.nix
  ];
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h3 id=&quot;idle-and-lock&quot;&gt;Idle and Lock&lt;&#x2F;h3&gt;
&lt;p&gt;Next, we will set our machine up to first lock, then power the screen down after
a certain amount of idle time.&lt;&#x2F;p&gt;
&lt;p&gt;This is done using Hyprland companion packages, &lt;code&gt;hyprlock&lt;&#x2F;code&gt; and &lt;code&gt;hypridle&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You can install those by adding them to your package list:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;environment.systemPackages = with pkgs; [
  hypridle
  hyprlock
];
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h4 id=&quot;hyperidle&quot;&gt;Hyperidle&lt;&#x2F;h4&gt;
&lt;p&gt;Save this config as:
&lt;code&gt;~&#x2F;dotfiles&#x2F;hypr&#x2F;hypridle.conf&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Here is my current setup:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;general {
    lock_cmd = hyprlock
    before_sleep_cmd = hyprlock
    after_sleep_cmd = hyprctl dispatch dpms on
}

listener {
    timeout = 300
    on-timeout = hyprlock
}

listener {
    timeout = 330
    on-timeout = hyprctl dispatch dpms off
    on-resume = hyprctl dispatch dpms on
}

&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is telling &lt;code&gt;hypridle&lt;&#x2F;code&gt; to run &lt;code&gt;hyprlock&lt;&#x2F;code&gt; as the lock_cmd and right before sleep.
Then after we wake, use hyprctl to turn the monitor back on.&lt;&#x2F;p&gt;
&lt;p&gt;Then we set up a few simple listeners. The first one is for 300 seconds (5
minutes) and simply runs &lt;code&gt;hyprlock&lt;&#x2F;code&gt; on-timeout. Then, I have another for 330
seconds (5 minutes 30 seconds) that will shut my monitor off on-timeout, and turn
it back on after resuming.&lt;&#x2F;p&gt;
&lt;p&gt;Reload hypr conf with &lt;code&gt;hyprctl reload&lt;&#x2F;code&gt; and you should now have a functioning
idle and lock screen.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;hyperlock&quot;&gt;Hyperlock&lt;&#x2F;h4&gt;
&lt;p&gt;Note: Hyprlock does not automatically create a config, and without one,
&lt;code&gt;hyprlock&lt;&#x2F;code&gt; will not render anything, meaning you will just see your screen, but
it will be locked -- you will not be able to interact with it again until you
enter your password and hit Enter.&lt;&#x2F;p&gt;
&lt;p&gt;We are also able to customize our lock screen some. This config will also
live in the same directory as the other hyprland config files.
&lt;code&gt;~&#x2F;dotfiles&#x2F;hypr&#x2F;hyprlock.conf&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Below is an example of mine. It looks similar to css styling but not exactly.&lt;&#x2F;p&gt;
&lt;p&gt;Here, I&#x27;m setting up a simple background color, an input field with a border,
and a label that just outputs the time. I like to keep mine simple but you can
add a background image, move the input box around, and do a lot more than I am.&lt;&#x2F;p&gt;
&lt;p&gt;You can find more in the docs
&lt;a href=&quot;https:&#x2F;&#x2F;wiki.hypr.land&#x2F;Hypr-Ecosystem&#x2F;hyprlock&#x2F;&quot;&gt;here&lt;&#x2F;a&gt;. It covers all the
available elements you can use to style your Hyprlock screen.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;background {
    color = rgb(282828)  # Gruvbox dark background
    blur_passes = 0
}

input-field {
    font_color = rgb(ebdbb2)
    inner_color = rgb(282828) # Slightly lighter than background
    outer_color = rgb(928374)
    outline_thickness = 3
    size = 500, 50
    position = 0, -33%
}

label {
    text = cmd[update:1000] echo &amp;quot;&amp;lt;b&amp;gt;&amp;lt;big&amp;gt; $(date +&amp;quot;%H:%M:%S&amp;quot;) &amp;lt;&amp;#x2F;big&amp;gt;&amp;lt;&amp;#x2F;b&amp;gt;&amp;quot;
    color = rgb(ebdbb2)
    font_size = 32
    position = 0, 0
    valign = center
    halign = center
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;After adding both files, remember to run &lt;code&gt;dotter deploy&lt;&#x2F;code&gt;. This will add symlinks
for our newly added files to our correct Hyprland config directory by default
based on our Dotter config.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;fun-fact&quot;&gt;Fun Fact&lt;&#x2F;h3&gt;
&lt;p&gt;This post was written from NixOS!&lt;&#x2F;p&gt;
&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h3&gt;
&lt;p&gt;In this post, we have:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Configured some muscle memory engraved key remappings including:
&lt;ul&gt;
&lt;li&gt;Caps Lock -&amp;gt; Esc when pressed&lt;&#x2F;li&gt;
&lt;li&gt;Caps Lock -&amp;gt; Control when held (and another key is pressed)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;Setup &lt;code&gt;hypridle&lt;&#x2F;code&gt; to lock out computer after 5 minutes then turn the screen off
30 seconds after that.&lt;&#x2F;li&gt;
&lt;li&gt;Styled a simple &lt;code&gt;hyprlock&lt;&#x2F;code&gt; screen that shows the time and an input box for our
password&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;next-time&quot;&gt;Next Time&lt;&#x2F;h3&gt;
&lt;p&gt;Next time will be all about development work. We will introduce Nixvim and
&lt;code&gt;nix shell&lt;&#x2F;code&gt;. Nix shells are development environments you can set up via config.
They are very powerful and make developing on NixOS much easier.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Fairway Client Not Fair Enough for MyFrisbee</title>
          <pubDate>Sun, 06 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/fairway-client-not-fair-enough/</link>
          <guid>https://jakegoldsborough.com/blog/2025/fairway-client-not-fair-enough/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/fairway-client-not-fair-enough/">&lt;h3 id=&quot;takedown-request&quot;&gt;Takedown Request&lt;&#x2F;h3&gt;
&lt;p&gt;Recently, I&#x27;ve been writing about reverse engineering a unique data
structure I found while investigating the UDisc web app. I also created
a free and open source library called Fairway Client. It&#x27;s a fair way for
accessing UDisc&#x27;s &lt;code&gt;.data&lt;&#x2F;code&gt; endpoints - the same ones their own web app uses.
These are public, unauthenticated endpoints available to any browser.&lt;&#x2F;p&gt;
&lt;p&gt;The only intent was curiosity and a belief in open, user-accessible data
and software. People have asked for this kind of access before, and
UDisc has repeatedly said no. So I decided to see for myself. The data
was right there - how hard could it be?&lt;&#x2F;p&gt;
&lt;p&gt;Turns out, not very.&lt;&#x2F;p&gt;
&lt;p&gt;Much of this data is &lt;strong&gt;user-submitted&lt;&#x2F;strong&gt;, and in some cases, &lt;strong&gt;user-paid&lt;&#x2F;strong&gt;.
UDisc does offer a CSV export, but come on - programming is about
automation, not downloading spreadsheets.&lt;&#x2F;p&gt;
&lt;p&gt;I posted about my findings and tools on the UDisc forum. That didn&#x27;t go
well. First, I didn&#x27;t realize posts were moderated so my comment never got
posted and that&#x27;s on me. Second, they weren&#x27;t pleased. I received a sternly
worded email asking me to remove both the blog series and the codebase.&lt;&#x2F;p&gt;
&lt;p&gt;I wasn&#x27;t sure what to expect - but I was still disappointed.
I was hoping for curiosity, maybe even appreciation.
Instead, I got a takedown request.&lt;&#x2F;p&gt;
&lt;p&gt;They cited their ToS, but if offering a clean, read-only interface to user-owned
public data violates the ToS, maybe it&#x27;s the ToS that is wrong.&lt;&#x2F;p&gt;
&lt;p&gt;The the code is down. I will be obscuring the posts to not reveal details but I
have spent time and effort on them so they will be staying up in some form.&lt;&#x2F;p&gt;
&lt;p&gt;It might be time to give disc golf metrix a shot:
&lt;a href=&quot;https:&#x2F;&#x2F;discgolfmetrix.com&#x2F;&quot;&gt;https:&#x2F;&#x2F;discgolfmetrix.com&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT Changelog - 27&#x2F;52</title>
          <pubDate>Fri, 04 Jul 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-27/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-27/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-27/">&lt;h3 id=&quot;nixos-nixvim-work&quot;&gt;NixOS &amp;amp; NixVim Work&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Fixed issues with &lt;code&gt;interception&lt;&#x2F;code&gt; and remapping caps lock to esc on tap
and ctrl on hold&lt;&#x2F;li&gt;
&lt;li&gt;Investigated how to migrate from LazyVim to NixVim in a non-flake setup and
how to modularize configuration.&lt;&#x2F;li&gt;
&lt;li&gt;Installed Starship and worked through integration with Nushell on NixOS,
resolving autocomplete issues related to Carapace and Fish.&lt;&#x2F;li&gt;
&lt;li&gt;PipeWire was installed and CLI methods for inspecting its settings were
explored.&lt;&#x2F;li&gt;
&lt;li&gt;Requested a developer-friendly NixVim config with completions and LSP support
for both Node.js and Rust.&lt;&#x2F;li&gt;
&lt;li&gt;Compared nix-shell and flakes, with interest in setting up per-project
environments for tools like Zola, Node, and Rust.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;udisc-reverse-engineering&quot;&gt;UDisc Reverse Engineering&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Public GitHub repo and blog posts were removed following a takedown request
from UDisc. Redacted versions will be reposted later.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;etsy-and-woodworking&quot;&gt;Etsy and Woodworking&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Added chunky maple cooking utensil&lt;&#x2F;li&gt;
&lt;li&gt;Added 4 piece mixed hardwood coaster set&lt;&#x2F;li&gt;
&lt;li&gt;Working on utensil rest&lt;&#x2F;li&gt;
&lt;li&gt;Working on guitar body&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;personal&quot;&gt;Personal&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Swedish learning streak hit 198 days&lt;&#x2F;li&gt;
&lt;li&gt;Received an offer letter!&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver or Zero to Nixty, part 4&#x2F;? - dotfile&#x2F;config management</title>
          <pubDate>Fri, 27 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-4/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-4/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-4/">&lt;h3 id=&quot;from-tty-to-tiling&quot;&gt;From TTY to Tiling&lt;&#x2F;h3&gt;
&lt;p&gt;In the last post, we enabled Hyprland and set it to run by default on login.&lt;&#x2F;p&gt;
&lt;p&gt;In this post, we will be taking a slight detour and will be going over how to
manage your NixOS config in version control. This step might be overkill for
some but it makes it so we can easily checkout our config on any machine and
have a working NixOS install fairly quickly.&lt;&#x2F;p&gt;
&lt;p&gt;We will also cover a little Hyprland config at the end.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;introducing-dotter&quot;&gt;Introducing Dotter&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;SuperCuber&#x2F;dotter&quot;&gt;Dotter&lt;&#x2F;a&gt; is a dotfile manager and templater.&lt;&#x2F;p&gt;
&lt;p&gt;You have a repo of dotfiles and two main config files:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;.dotter&#x2F;global.toml&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;global config where you define &quot;modules&quot; and tell Dotter how to handle
copying config.&lt;&#x2F;li&gt;
&lt;li&gt;commit to version control&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;&lt;code&gt;.dotter&#x2F;local.toml&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Per machine file that tells dotter which config to load. The idea being
you may have different config needs for linux, macos, or windows.&lt;&#x2F;li&gt;
&lt;li&gt;This file is not committed to git because local lets you switch configs
per machine without affecting version control&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Add a &lt;code&gt;.gitignore&lt;&#x2F;code&gt; if not already present.
Add a new line:
&lt;code&gt;.dotter&#x2F;local.toml&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;After configuring everything, you simply run &lt;code&gt;dotter deploy&lt;&#x2F;code&gt; and the config
files are copied to the locations set in &lt;code&gt;global.toml&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s a great project and I highly recommend it.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;moving-nixos-config-to-dotfiles-repo&quot;&gt;Moving NixOS Config to Dotfiles Repo&lt;&#x2F;h3&gt;
&lt;p&gt;The first thing we&#x27;ll do is add a new directory in our dotfile repo. Then
we&#x27;ll need to open up our &lt;code&gt;.dotter&#x2F;global.toml&lt;&#x2F;code&gt; file and add our NixOS config
paths. That looks like this for me:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;[nixos.files]
nixos = &amp;#x27;&amp;#x2F;etc&amp;#x2F;nixos&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;In Dotter talk, this is setting up a &quot;module&quot; named &lt;code&gt;nixos&lt;&#x2F;code&gt;. We are telling Dotter
that this module&#x27;s files will be located in the &lt;code&gt;nixos&lt;&#x2F;code&gt; folder and need to be
copied to &lt;code&gt;&#x2F;etc&#x2F;nixos&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Now you will need to move the current NixOS config from
&lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt; to your new &lt;code&gt;nixos&lt;&#x2F;code&gt; folder.&lt;&#x2F;p&gt;
&lt;p&gt;One other step will be to allow writes to your &lt;code&gt;&#x2F;etc&#x2F;nixos&lt;&#x2F;code&gt; dir by running
&lt;code&gt;sudo chmod o+w &#x2F;etc&#x2F;nixos&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;One important configuration option to mention for Dotter is the
&lt;code&gt;default_target_type&lt;&#x2F;code&gt;. Because of the template functionality of Dotter,
it can either physically copy your config, or simply create a symlink.&lt;&#x2F;p&gt;
&lt;p&gt;I prefer symlinks by default so I use this config option. In &lt;code&gt;global.toml&lt;&#x2F;code&gt;, I
set the following:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;[settings]
default_target_type = &amp;#x27;symbolic&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;More info about that can be found in the &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;SuperCuber&#x2F;dotter&#x2F;wiki&#x2F;5.-Built%E2%80%90ins,-Helpers,-and-Settings#settings&quot;&gt;Dotter wiki&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;If you plan to use symlinks with Dotter, you will need to open up
&lt;code&gt;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt; and make the &lt;code&gt;hardware-configuration.nix&lt;&#x2F;code&gt; import
an absolute path instead of relative path.&lt;&#x2F;p&gt;
&lt;p&gt;Example:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;# nixos&amp;#x2F;configuration.nix
{ config, pkgs, ... }:

{
  imports =
    [ &amp;#x2F;etc&amp;#x2F;nixos&amp;#x2F;hardware-configuration.nix ];
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You will also need to add your &lt;code&gt;.dotter&#x2F;local.toml&lt;&#x2F;code&gt; file.&lt;&#x2F;p&gt;
&lt;p&gt;That will look something like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;packages = [
  &amp;#x27;cmus&amp;#x27;,
  &amp;#x27;git&amp;#x27;,
  &amp;#x27;hypr&amp;#x27;,
  &amp;#x27;nvim&amp;#x27;,
  &amp;#x27;nushell&amp;#x27;,
  &amp;#x27;starship&amp;#x27;,
  &amp;#x27;waybar&amp;#x27;,
  &amp;#x27;wezterm&amp;#x27;,
  &amp;#x27;wofi&amp;#x27;
]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This is telling Dotter which of the &quot;packages&quot; in your &lt;code&gt;global.toml&lt;&#x2F;code&gt; should be
copied. This helps allow us to define our complete config in one repo even if
every host doesn&#x27;t use every package. Each host will have a &lt;code&gt;local.toml&lt;&#x2F;code&gt; that
copies specific packages.&lt;&#x2F;p&gt;
&lt;p&gt;Now it&#x27;s finally time to run &lt;code&gt;dotter deploy&lt;&#x2F;code&gt;. This will do the important step
of taking our local dotfiles and copying (or symlinking) to the dirs we
specified. After running that command, you will see
&lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt; is present again.&lt;&#x2F;p&gt;
&lt;p&gt;The moment of truth, time to rebuild. You can start by doing a dry run
&lt;code&gt;sudo nixos-rebuild dry-activate&lt;&#x2F;code&gt;. This will build the system and tell you what
would happen. If that goes well, run &lt;code&gt;sudo nixos-rebuild switch&lt;&#x2F;code&gt;. NixOS gives a lot
of protection around broken config so it&#x27;s unlikely anything should break here
and if it does, there will be an error that tells you why.&lt;&#x2F;p&gt;
&lt;p&gt;Commit your configuration file and merge your branch back to &lt;code&gt;main&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;adding-hyprland-config-to-dotfiles-repo&quot;&gt;Adding Hyprland Config to Dotfiles Repo&lt;&#x2F;h3&gt;
&lt;p&gt;We are now going to do the same with our &lt;code&gt;Hyprland&lt;&#x2F;code&gt; config. The steps are pretty
much the same minus some &lt;code&gt;chmod&lt;&#x2F;code&gt; and file path updates.&lt;&#x2F;p&gt;
&lt;p&gt;First, create a new branch and check it out. Then we create a new directory that
I simply called &lt;code&gt;hypr&lt;&#x2F;code&gt;. Next, copy the current &lt;code&gt;Hyprland&lt;&#x2F;code&gt; config from
&lt;code&gt;~&#x2F;.config&#x2F;hypr&lt;&#x2F;code&gt; into this new dir.&lt;&#x2F;p&gt;
&lt;p&gt;Now we will need to add a little Dotter config:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;[hypr.files]
hypr = &amp;#x27;~&amp;#x2F;.config&amp;#x2F;hypr&amp;#x27;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Again, in Dotter talk, we are creating a &lt;code&gt;hypr&lt;&#x2F;code&gt; module. Then we tell Dotter that
any files in our local &lt;code&gt;hypr&lt;&#x2F;code&gt; dir need to be copied to &lt;code&gt;~&#x2F;.config&#x2F;hypr&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Now it&#x27;s time to run &lt;code&gt;dotter deploy&lt;&#x2F;code&gt;. This will do the step of copying our
local config to the correct path in &lt;code&gt;~&#x2F;.config&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Confirm that file got copied and then run &lt;code&gt;hyprctl reload&lt;&#x2F;code&gt; to reload the &lt;code&gt;hypr&lt;&#x2F;code&gt;
config.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;congrats-on-version-controlled-config&quot;&gt;Congrats On Version Controlled Config&lt;&#x2F;h3&gt;
&lt;p&gt;Your dotfile repo should now look something like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;dotfiles&amp;#x2F;
├── .dotter&amp;#x2F;
│   ├── global.toml
│   └── local.toml
├── hypr&amp;#x2F;
│   └── hyprland.conf
└── nixos&amp;#x2F;
    └── configuration.nix
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;We have now added two sets of our config into version control. This will allow
us to easily pass around our config to a new machine if needed, especially with
NixOS and it&#x27;s declarative config.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;next-time&quot;&gt;Next Time&lt;&#x2F;h3&gt;
&lt;p&gt;In the next post, I plan to get back to actually setting up NixOS for daily driver
use. This will probably include some &lt;code&gt;hypr&lt;&#x2F;code&gt; config and introducing Nixvim. Nixvim
is a way to manage your neovim plugins in a NixOS-y way.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT Changelog (fka Weekly Summary) - 26&#x2F;52</title>
          <pubDate>Fri, 27 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-26/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-26/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-26/">&lt;h2 id=&quot;nixos-workflow-improvements&quot;&gt;NixOS Workflow &amp;amp; Improvements&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Refined NixOS development workflow by exploring the use of &lt;code&gt;nix-shell&lt;&#x2F;code&gt;
and Flakes for environment isolation and reproducibility. Discussed how to
structure shell files for separate projects (e.g. Rust&#x2F;Zola vs. Node).&lt;&#x2F;li&gt;
&lt;li&gt;Discovered how tools like &lt;code&gt;rustup&lt;&#x2F;code&gt; and &lt;code&gt;volta&lt;&#x2F;code&gt; behave in Nix environments.
You acknowledged that &lt;code&gt;nix-shell&lt;&#x2F;code&gt; helps contain them safely, but the
Nix-native approach may still be preferable long term.&lt;&#x2F;li&gt;
&lt;li&gt;Cleaned up a bug with &lt;code&gt;nixvim&lt;&#x2F;code&gt; setup by updating to the latest
version and resolving a plugin rename (&lt;code&gt;coedium-vim&lt;&#x2F;code&gt; ➝ &lt;code&gt;windsurf&lt;&#x2F;code&gt;).&lt;&#x2F;li&gt;
&lt;li&gt;Added Blueman via Nix, and experimented with different ways to start Waybar
without tying up terminal (eventually using &lt;code&gt;hyprctl dispatch exec&lt;&#x2F;code&gt;).&lt;&#x2F;li&gt;
&lt;li&gt;Successfully reversed trackpad scroll direction in Hyprland using overrides,
and this worked well across setup.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;gitvote-gpg-work&quot;&gt;GitVote &amp;amp; GPG Work&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Progressed &lt;code&gt;gitvote&lt;&#x2F;code&gt; CLI and backend significantly:
&lt;ul&gt;
&lt;li&gt;Implemented registration and GPG signature support.&lt;&#x2F;li&gt;
&lt;li&gt;Verified that voting model and simulation strategy still worked.&lt;&#x2F;li&gt;
&lt;li&gt;Explored commit signing, storing keys in-repo, and ensuring modular CLI
support for different user flows.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;Began planning for how to handle edge cases like simultaneous votes and
eventually tallying.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;blogging-writing&quot;&gt;Blogging &amp;amp; Writing&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Continued work on NixOS daily driver series, planning to document how to
set up Dotter and Hyprland cleanly from scratch before layering more config.&lt;&#x2F;li&gt;
&lt;li&gt;Reflected on privacy-focused analytics post, considering how to improve
tracking and discussing stance on respecting blockers.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;udisc-api-wrapper-library&quot;&gt;UDisc API &amp;amp; Wrapper Library&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Worked on orchestration pattern for the TypeScript &lt;code&gt;UDiscAPI&lt;&#x2F;code&gt; class,
ensuring everything flows through a clean and testable interface.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Removed unnecessary &lt;code&gt;loadFromFile&lt;&#x2F;code&gt; logic and moved toward using raw JSON
inputs with explicit route extraction.&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;
&lt;p&gt;Streamlined the construction flow:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;const udisc = new UDisc();
const schemaMap = udisc.extractSchemaMap(raw);
const eventListings = udisc.resolveKeyValuePairs(&amp;#x27;eventListing&amp;#x27;);
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;personal-progress&quot;&gt;Personal Progress&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;Maintained streak of learning Swedish — now at 193 days in a row. Mostly
review. Need to listen to more podcasts&#x2F;books again.&lt;&#x2F;li&gt;
&lt;li&gt;Only played one round of disc golf this week&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>How I accidentally wrote my first Neovim plugin</title>
          <pubDate>Wed, 25 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/releasing-a-neovim-plugin/</link>
          <guid>https://jakegoldsborough.com/blog/2025/releasing-a-neovim-plugin/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/releasing-a-neovim-plugin/">&lt;h3 id=&quot;just-show-me-the-code&quot;&gt;Just Show Me The Code&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;nvim-vandelay&quot;&gt;repo&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;&#x2F;h3&gt;
&lt;p&gt;There is a simple motion I use in Neovim often and I finally decided to do
something about it.&lt;&#x2F;p&gt;
&lt;p&gt;That motion is breaking up single line imports into multiline imports.&lt;&#x2F;p&gt;
&lt;p&gt;Example:&lt;&#x2F;p&gt;
&lt;p&gt;This:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;import { foo, bar, baz } from &#x27;.&#x2F;utils&#x27;;&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Should become:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;import {
  foo,
  bar,
  baz,
} from &amp;#x27;.&amp;#x2F;utils&amp;#x27;;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;It&#x27;s a tiny thing but something I do all the time in javascript, typescript, and
rust. Even if it&#x27;s less than 80 characters wide, I really like breaking multiple
modules imports down to separate lines.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-first-approach-regex-and-why-it-worked&quot;&gt;The First Approach: Regex (And Why It Worked)&lt;&#x2F;h3&gt;
&lt;p&gt;The very first version of Vandelay used simple Lua string matching:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;local pattern = [[import%s*{%s*(.-)%s*}%s*from%s*(.*);]]
local names, from = string.match(line, pattern)
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This captured two groups:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;names&lt;&#x2F;code&gt; - the full list inside &lt;code&gt;{ ... }&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;from&lt;&#x2F;code&gt; - the source string&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;After capturing, it simply:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Split &lt;code&gt;names&lt;&#x2F;code&gt; on commas into a table of individual imports&lt;&#x2F;li&gt;
&lt;li&gt;Trimmed whitespace&lt;&#x2F;li&gt;
&lt;li&gt;Reconstructed the formatted import line with newlines and indentation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;why-it-worked&quot;&gt;Why It Worked&lt;&#x2F;h4&gt;
&lt;p&gt;The format of imports I wanted to modify was extremely predictable. I only cared
about one specific form and I was only operating on one line at a time. Lua
strings were simple and fast for this very specific case. This worked extremely
well from the start and passed some simple tests that tests import structure.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;adding-treesitter-and-why-it-didn-t-work&quot;&gt;Adding Treesitter (And Why It Didn&#x27;t Work)&lt;&#x2F;h3&gt;
&lt;p&gt;I thought using Treesitter would make the plugin more robust. It sounded like a
perfect fit: parse the full AST, extract imports properly, and avoid relying on
fragile string patterns.&lt;&#x2F;p&gt;
&lt;p&gt;In practice, I quickly ran into problems:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Complex Treesitter queries for what seemed like simple tasks&lt;&#x2F;li&gt;
&lt;li&gt;Accidental full-line and full-file rewrites when I only wanted to touch one line&lt;&#x2F;li&gt;
&lt;li&gt;Query capture ordering issues that merged unrelated imports together&lt;&#x2F;li&gt;
&lt;li&gt;Inconsistent grammar across different Treesitter parser versions&lt;&#x2F;li&gt;
&lt;li&gt;Parser installation requirements that added more dependencies&lt;&#x2F;li&gt;
&lt;li&gt;Much more boilerplate code for very minimal benefit&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Treesitter excels for full-file refactors, linters, or analysis tools.
But for surgical, highly structured, one-line rewrites? It was overkill.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-i-learned&quot;&gt;What I Learned&lt;&#x2F;h3&gt;
&lt;p&gt;Treesitter is powerful. But &lt;strong&gt;power doesn’t always equal simplicity&lt;&#x2F;strong&gt; —
especially for narrowly scoped formatting tasks.&lt;&#x2F;p&gt;
&lt;p&gt;For this plugin, regex provided:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Faster iteration&lt;&#x2F;li&gt;
&lt;li&gt;Predictable behavior&lt;&#x2F;li&gt;
&lt;li&gt;No parser dependencies&lt;&#x2F;li&gt;
&lt;li&gt;Simple implementation&lt;&#x2F;li&gt;
&lt;li&gt;Easier testing&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Sometimes simple string matching really is enough -- especially when your input
format is highly constrained.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nvim-vandelay-1-0&quot;&gt;nvim-vandelay 1.0&lt;&#x2F;h3&gt;
&lt;p&gt;The end result became &lt;code&gt;nvim-vandelay&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A micro-plugin for formatting imports&lt;&#x2F;li&gt;
&lt;li&gt;Fully manual keybind control&lt;&#x2F;li&gt;
&lt;li&gt;Regex-powered single-line rewriting&lt;&#x2F;li&gt;
&lt;li&gt;Rust and JavaScript support out of the box&lt;&#x2F;li&gt;
&lt;li&gt;Fully tested via Plenary + CI&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Here is another link to the repo:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;nvim-vandelay&quot;&gt;nvim-vandelay&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Introducing Shelltrax - Or how I vibe programmed my way to a TUI music player</title>
          <pubDate>Tue, 24 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/introducing-shelltrax/</link>
          <guid>https://jakegoldsborough.com/blog/2025/introducing-shelltrax/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/introducing-shelltrax/">&lt;h3 id=&quot;shelltrax&quot;&gt;Shelltrax&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;shelltrax&quot;&gt;github&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;shelltrax-1.png&quot; alt=&quot;A screenshot showing a libray view of a CLI music player&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;I love the terminal and have dabbled in Rust since the very early days. I&#x27;ve
been to 5 RustConfs, but my actual knowledge of Rust never really went past the
basics. I come from a scripted language background, so while I &lt;em&gt;understood&lt;&#x2F;em&gt; the
ideas, I didn&#x27;t really understand them in practice -- mostly because I hadn&#x27;t
personally suffered the pain points Rust tries to solve.&lt;&#x2F;p&gt;
&lt;p&gt;The only way for me to truly understand was to build something real. Not
another tutorial. Not another toy example. Something I&#x27;d actually use.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;cmus&quot;&gt;cmus&lt;&#x2F;h3&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;cmus.github.io&#x2F;&quot;&gt;cmus&lt;&#x2F;a&gt; is a small, &lt;em&gt;fast&lt;&#x2F;em&gt;, and powerful console
music player for Unix-like operating systems and it&#x27;s easily one of the apps
I use most.&lt;&#x2F;p&gt;
&lt;p&gt;And like I mentioned above, I learn best by writing real tools so
why not try to recreate one of my daily drivers? It seemed like the perfect mix
of things to learn: input handling, TUI design, file scanning, metadata parsing,
and real-time playback.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;stack-and-structure&quot;&gt;Stack and Structure&lt;&#x2F;h3&gt;
&lt;p&gt;I tried to keep the stack small, modern, and as &quot;Rust-native&quot; as possible:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ratatui&lt;&#x2F;code&gt; - TUI layout and rendering&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;crossterm&lt;&#x2F;code&gt; - keyboard input handling and terminal backend&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;walkdir&lt;&#x2F;code&gt; - recursive file scanning and directory walking&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;id3v2&lt;&#x2F;code&gt; and &lt;code&gt;symphonia&lt;&#x2F;code&gt; - parsing audio metadata (ID3, Vorbis, FLAC, etc)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;rodio&lt;&#x2F;code&gt; - audio playback&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Together, these gave me a good spread of &quot;real&quot; Rust problems to tackle:
filesystems, decoding binary formats, audio streaming, real-time UI state, and
concurrency.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;interface-design&quot;&gt;Interface Design&lt;&#x2F;h3&gt;
&lt;p&gt;From the start, I wanted it to feel very simple.&lt;&#x2F;p&gt;
&lt;p&gt;The interface is split into a few key views:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;library view&lt;&#x2F;strong&gt;, where I can browse artists, albums, and tracks&lt;&#x2F;li&gt;
&lt;li&gt;A &lt;strong&gt;file browser&lt;&#x2F;strong&gt;, which lets me scan new folders and import music to the
library&lt;&#x2F;li&gt;
&lt;li&gt;A persistent &lt;strong&gt;footer&lt;&#x2F;strong&gt;, showing the current track, album, artist and a
real-time progress bar while the song plays&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Even building that much required learning how to manage state in a long-running
TUI app, wire up input handling with &lt;code&gt;crossterm&lt;&#x2F;code&gt;, and keep the UI responsive
while streaming audio in the background.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-s-working&quot;&gt;What&#x27;s Working&lt;&#x2F;h3&gt;
&lt;p&gt;Despite starting this as mostly an experiment to learn Rust, I now have a music player that:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Scans my local library for FLAC and MP3 files&lt;&#x2F;li&gt;
&lt;li&gt;Parses metadata (title, album, artist, album artist, etc) correctly using Symphonia&lt;&#x2F;li&gt;
&lt;li&gt;Sorts and groups tracks by album artist for proper browsing&lt;&#x2F;li&gt;
&lt;li&gt;Supports autoplay, automatically advancing to the next track&lt;&#x2F;li&gt;
&lt;li&gt;Displays a footer with current song info and a live progress bar&lt;&#x2F;li&gt;
&lt;li&gt;Supports pause and resume functionality with synchronized playback state&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;It&#x27;s already become my daily music player, replacing cmus for my listening.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;shelltrax-2.png&quot; alt=&quot;A screenshot showing a libray view of a CLI music player&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;reflections&quot;&gt;Reflections&lt;&#x2F;h3&gt;
&lt;p&gt;The most valuable part of this project was finally experiencing what Rust is
really trying to protect you from.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shared state&lt;&#x2F;strong&gt;: Coordinating real-time playback state across multiple
threads (UI thread, playback thread, decode thread) forced me to actually
deal with ownership, borrowing, and synchronization directly.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Lifetimes&lt;&#x2F;strong&gt;: I finally had to understand where lifetimes mattered when
dealing with borrowed references and streaming APIs.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Real-world edge cases&lt;&#x2F;strong&gt;: Dealing with weird metadata formats, malformed
files, and partial decode failures showed me how fragile real-world data can
be.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;I&#x27;ve read about these things for years, but building Shelltrax forced me to
feel the problems directly. That&#x27;s what finally made the learning click.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;vibe-programming&quot;&gt;Vibe programming&lt;&#x2F;h3&gt;
&lt;p&gt;There&#x27;s a lot of discourse around &quot;vibe programming&quot; -- the idea of just writing
code by intuition, following your gut, and iteratively figuring it out as you
go. It sometimes gets a bad rap: &quot;You&#x27;re just hacking around without fully
understanding the language or design.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;But for me, vibe programming doesn&#x27;t mean ignoring fundamentals. It means
building something real, fast enough to stay motivated, but being careful
enough to stop and actually understand why something works (or why it doesn&#x27;t).&lt;&#x2F;p&gt;
&lt;p&gt;I could have read Rust books or done more exercises, but none of that would
have taught me what happens when a real audio stream crosses threads and you
forget to reset a state flag at just the right time. And I&#x27;ll be honest, I
didn&#x27;t magically learn Rust completely and know how to handle every situation
but I certainly have a much better idea than a few weeks ago.&lt;&#x2F;p&gt;
&lt;p&gt;Vibe programming can force you into real problems. If you stay curious and
careful, those problems become extremely effective teachers.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Building a Fully Decentralized Voting System Using Just Git and Pull Requests</title>
          <pubDate>Mon, 23 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-a-voting-system-with-git/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-a-voting-system-with-git/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-a-voting-system-with-git/">&lt;h3 id=&quot;the-premise&quot;&gt;The Premise&lt;&#x2F;h3&gt;
&lt;p&gt;What if we could build a fully transparent, auditable, and tamper-evident
voting system -- without any servers, centralized backend, or traditional
databases?&lt;&#x2F;p&gt;
&lt;p&gt;The result is &lt;strong&gt;GitVote&lt;&#x2F;strong&gt; - a simple but powerful decentralized voting system
that uses nothing but Git, pull requests, and a little bit of Rust.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why-git&quot;&gt;Why Git?&lt;&#x2F;h3&gt;
&lt;p&gt;Git already gives us:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;A fully distributed ledger&lt;&#x2F;li&gt;
&lt;li&gt;Immutable commit history&lt;&#x2F;li&gt;
&lt;li&gt;Branching and merging workflows&lt;&#x2F;li&gt;
&lt;li&gt;Forking for isolated participant actions&lt;&#x2F;li&gt;
&lt;li&gt;Cryptographic integrity through hashes and signatures&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;With all that, we&#x27;re part of the way to a blockchain.&lt;&#x2F;p&gt;
&lt;p&gt;I wondered if I could build a voting system where Git itself is the storage
layer, the consensus layer, and the audit trail.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-design&quot;&gt;The Design&lt;&#x2F;h3&gt;
&lt;p&gt;At a high level, GitVote works like this:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Proposals&lt;&#x2F;strong&gt; are created as dedicated Git branches&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Votes&lt;&#x2F;strong&gt; are submitted as files inside pull requests.&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Voter ID&lt;&#x2F;strong&gt; is tied to each voter&#x27;s Git configuration (name and email)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Duplicate Voting&lt;&#x2F;strong&gt; is automatically prevented via CI checks&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Immutable blocks&lt;&#x2F;strong&gt; are built from merged votes using deterministic hashing&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Tally results&lt;&#x2F;strong&gt; can be generated entirely offline from the final ledger&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;There&#x27;s no central database, API server, or backend. Everything happens inside
of Git.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-voting-flow&quot;&gt;The Voting Flow&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;proposal-creation&quot;&gt;Proposal Creation&lt;&#x2F;h4&gt;
&lt;p&gt;Each new proposal is created as a new Git branch, for example:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;proposal&#x2F;001-color-vote&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;A simple &lt;code&gt;schema.json&lt;&#x2F;code&gt; file defines the allowed choices for that proposal:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;allowed&amp;quot;: [&amp;quot;blue&amp;quot;, &amp;quot;purple&amp;quot;, &amp;quot;green&amp;quot;]
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h4 id=&quot;voter-submission&quot;&gt;Voter Submission&lt;&#x2F;h4&gt;
&lt;p&gt;Voters follow this flow:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Fork the governance repo&lt;&#x2F;li&gt;
&lt;li&gt;Clone their fork locally&lt;&#x2F;li&gt;
&lt;li&gt;Checkout the correct proposal branch&lt;&#x2F;li&gt;
&lt;li&gt;Run GitVote CLI tool to cast their vote:
&lt;code&gt;gitvote cast --choice purple&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;This will:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Write a new vote file into &lt;code&gt;votes&#x2F;&lt;&#x2F;code&gt; (one file per voter)&lt;&#x2F;li&gt;
&lt;li&gt;Commits the vote using their Git identity&lt;&#x2F;li&gt;
&lt;li&gt;Signs the commit (GPG coming soon)&lt;&#x2F;li&gt;
&lt;li&gt;Prepares the branch for submission&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;Push their branch back to their fork&lt;&#x2F;li&gt;
&lt;li&gt;Open a pull request into the upstream proposal branch&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h4 id=&quot;vote-validation&quot;&gt;Vote Validation&lt;&#x2F;h4&gt;
&lt;p&gt;Every pull request triggers CI which runs:
&lt;code&gt;gitvote validate&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This will validate:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The vote file format&lt;&#x2F;li&gt;
&lt;li&gt;Compliance with the allowed schema&lt;&#x2F;li&gt;
&lt;li&gt;No duplicate voters (one voter, one vote)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Invalid votes fail the CI and will not be merged&lt;&#x2F;p&gt;
&lt;h4 id=&quot;merging-chain-building&quot;&gt;Merging &amp;amp; Chain Building&lt;&#x2F;h4&gt;
&lt;p&gt;Once a valid PR is merged, CI will automatically run:
&lt;code&gt;gitvote build-chain&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This scans all merged vote files and creates an immutable hash-linked chain
of blocks stored as plain JSON:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;blocks&amp;#x2F;
  block-0000.json
  block-0001.json
  ...
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Each block includes:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;The vote choice&lt;&#x2F;li&gt;
&lt;li&gt;The voter identity&lt;&#x2F;li&gt;
&lt;li&gt;The original timestamp of the vote&lt;&#x2F;li&gt;
&lt;li&gt;The Cryptographic hash linking it to the previous block&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;tallying-votes&quot;&gt;Tallying Votes&lt;&#x2F;h4&gt;
&lt;p&gt;At any time, anyone can run:
&lt;code&gt;gitvote tally&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This reads the &lt;code&gt;blocks&#x2F;&lt;&#x2F;code&gt; directory and generates a full tally of the current vote
state:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;Vote Tally:
  purple votes: 3
  red votes: 2
Total unique voters: 5
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;No external system is needed to calculate the results — everything lives
entirely inside Git.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-tech-stack&quot;&gt;The Tech Stack&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Rust for the core CLI, &lt;code&gt;gitvote&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Github Actions for CI validation and chain building&lt;&#x2F;li&gt;
&lt;li&gt;Git itself as the distributed backend&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;security&quot;&gt;Security&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Voter ID is tied to each user&#x27;s Git config (&lt;code&gt;user.name&lt;&#x2F;code&gt; and &lt;code&gt;user.email&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;CI fully enforces schema validation and prevents voter duplication&lt;&#x2F;li&gt;
&lt;li&gt;All votes are auditable forever via immutable commit history&lt;&#x2F;li&gt;
&lt;li&gt;The chain is fully deterministic and reproducible offline&lt;&#x2F;li&gt;
&lt;li&gt;The ledger can be archived back into &lt;code&gt;main&lt;&#x2F;code&gt; for permanent recordkeeping&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;the-benefits&quot;&gt;The Benefits&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Simplicity:&lt;&#x2F;strong&gt; No central server or complex infrastructure&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Transparency:&lt;&#x2F;strong&gt; Every vote and rule is visible to all voters&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Auditability:&lt;&#x2F;strong&gt; Anyone can verify the ledger at any time&lt;&#x2F;li&gt;
&lt;li&gt;&lt;strong&gt;Offline verifiability:&lt;&#x2F;strong&gt; The full vote chain is just a Git repo&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;future-plans&quot;&gt;Future Plans&lt;&#x2F;h3&gt;
&lt;p&gt;There are a number of interesting enhancements that could be made here I think:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;GPG signature enforcement&lt;&#x2F;li&gt;
&lt;li&gt;Anonymous but verifiable voting via zero-knowledge proofs&lt;&#x2F;li&gt;
&lt;li&gt;Weighted or ranked ballots&lt;&#x2F;li&gt;
&lt;li&gt;Multi-proposal governance workflow&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;For now, GitVote is a minimal, functioning, fully decentralized voting system.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;the-code-demo&quot;&gt;The Code&#x2F;Demo&lt;&#x2F;h3&gt;
&lt;p&gt;You can find the CLI here:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;gitvote&quot;&gt;GitVote CLI&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You can find a test governance repo here:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;gitvote-test&quot;&gt;gitvote-test&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;By going to &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;gitvote-test&#x2F;actions&quot;&gt;actions&lt;&#x2F;a&gt;, you can
see the various CI workflows that run during the voting process.&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;First, I submit a valid vote for &lt;strong&gt;purple&lt;&#x2F;strong&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Then, I attempt to vote again for &lt;strong&gt;green&lt;&#x2F;strong&gt;, which correctly fails
due to duplicate voter prevention&lt;&#x2F;li&gt;
&lt;li&gt;Finally, I simulate a new voter by spoofing a different Git identity (via
&lt;code&gt;git config&lt;&#x2F;code&gt;), submit a vote for &lt;strong&gt;blue&lt;&#x2F;strong&gt;, and the vote passes validation&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;closing-thoughts&quot;&gt;Closing Thoughts&lt;&#x2F;h3&gt;
&lt;p&gt;What originally started as an idea for &quot;Gitcoin&quot; and wanting to learn more
about blockchains turned into a fully functional, fully auditable governance
platform -- all built entirely on top of Git (and Rust).&lt;&#x2F;p&gt;
&lt;p&gt;It was pretty satisfying to turn pull requests, branch
protections, and hash-linked commits into a simple, verifiable voting process.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>The Accidental Complexity of Doing The Right Thing or The Pain of Setting Up Privacy-Focused Analytics (2025 Edition)</title>
          <pubDate>Sun, 22 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/the-pain-of-privacy-focused-analytics/</link>
          <guid>https://jakegoldsborough.com/blog/2025/the-pain-of-privacy-focused-analytics/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/the-pain-of-privacy-focused-analytics/">&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;&#x2F;h3&gt;
&lt;p&gt;As I&#x27;ve mentioned in the past, I’ve been writing and blogging more, and I
became curious if anyone was actually reading my posts.&lt;&#x2F;p&gt;
&lt;p&gt;Before I go any further, I need to make it clear that I am very
privacy-focused. I hate logging, tracking, fingerprinting, or any kind of
unnecessary data collection. A user’s data is theirs -- I have no interest in
storing anything about them. I just want a basic count of which pages are being
visited.&lt;&#x2F;p&gt;
&lt;p&gt;Naturally, I asked ChatGPT for recommendations based on my current setup.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m already using Caddy as my web server and TLS manager, so the first
suggestion was straightforward: just enable HTTP access logs and analyze them.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;attempt-1-server-side-access-logs-caddy&quot;&gt;Attempt 1: Server-side access logs (Caddy)&lt;&#x2F;h3&gt;
&lt;p&gt;The basic flow here would be:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;enable http logs in Caddy&lt;&#x2F;li&gt;
&lt;li&gt;scrape log file&lt;&#x2F;li&gt;
&lt;li&gt;count requests as rough pageviews&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In theory, this sounded perfect. I wasn’t looking for fancy dashboards or
detailed metrics -- just simple counts of which pages were being hit. Plain log
files should be more than enough.&lt;&#x2F;p&gt;
&lt;p&gt;Caddy, by default, doesn&#x27;t actually log HTTP requests unless you configure it
to. More surprisingly, in many builds of Caddy (especially those provided by
package managers), the &lt;code&gt;http.handlers.log&lt;&#x2F;code&gt; module isn’t even included.&lt;&#x2F;p&gt;
&lt;p&gt;At first I thought I was on the wrong version, but after some digging, I
realized that full HTTP access logging in Caddy requires building your own
custom binary with &lt;a href=&quot;https:&#x2F;&#x2F;caddyserver.com&#x2F;docs&#x2F;build#xcaddy&quot;&gt;&lt;code&gt;xcaddy&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; to
enable the logging module. This felt like massive overkill for something as
basic as HTTP request logging.&lt;&#x2F;p&gt;
&lt;p&gt;After trying multiple versions, failing to get access logs working, and
realizing that Caddy’s modular architecture was actively getting in my way, I
gave up on this approach.&lt;&#x2F;p&gt;
&lt;p&gt;I asked ChatGPT for other options.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;attempt-2-goatcounter-via-docker&quot;&gt;Attempt 2: Goatcounter via Docker&lt;&#x2F;h3&gt;
&lt;p&gt;The next recommendation was to use a dedicated privacy-first analytics tool.
Several good open-source options exist:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;arp242&#x2F;goatcounter&quot;&gt;GoatCounter&lt;&#x2F;a&gt;, Plausible, and Umami.&lt;&#x2F;p&gt;
&lt;p&gt;After doing a bit of research, GoatCounter stood out. It’s fully open-source,
extremely privacy-focused, lightweight, and seems purpose-built for people like
me who just want simple pageview counts without any tracking nonsense.&lt;&#x2F;p&gt;
&lt;p&gt;Even better, GoatCounter has Docker images available.&lt;&#x2F;p&gt;
&lt;p&gt;But of course — the Docker image that used to be hosted on Docker Hub was no
longer available. The project had moved its images to GitHub Container Registry
(GHCR), and GHCR was returning permission errors when I tried to pull the image
anonymously. Apparently GHCR increasingly requires authentication even for
public images, depending on Docker version, client configuration, and random
GitHub API mood swings.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;attempt-3-building-my-own-goatcounter-docker-image&quot;&gt;Attempt 3: Building my own Goatcounter Docker image&lt;&#x2F;h3&gt;
&lt;p&gt;After that, I decided to just build the image myself.&lt;&#x2F;p&gt;
&lt;p&gt;GoatCounter publishes a Dockerfile in the repo, so this should be
straightforward:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;git clone https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;arp242&amp;#x2F;goatcounter.git
docker build -t goatcounter:local .
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;The Dockerfile requires Docker BuildKit support, and uses newer Dockerfile
features like &lt;code&gt;--exclude&lt;&#x2F;code&gt;, which aren&#x27;t supported by Docker’s legacy builder. I
had to enable BuildKit, but enabling BuildKit required &lt;code&gt;docker-buildx&lt;&#x2F;code&gt;, which
wasn’t installed by default on my Arch system.&lt;&#x2F;p&gt;
&lt;p&gt;Once I installed &lt;code&gt;docker-buildx&lt;&#x2F;code&gt;, I ran the build again. This time, it started
pulling Go modules but failed with timeouts halfway through the build.&lt;&#x2F;p&gt;
&lt;p&gt;I retried. And retried. BuildKit timeouts, network flakiness, CDN rate limiting
-- you name it. What should have been a 30-second build turned into multiple
rounds of fighting with Docker’s build system and Go’s module proxy ecosystem.&lt;&#x2F;p&gt;
&lt;p&gt;At this point, Docker was no longer simplifying anything — it was actively
making everything worse.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;attempt-4-running-goatcounter-directly&quot;&gt;Attempt 4: Running GoatCounter directly&lt;&#x2F;h3&gt;
&lt;p&gt;By this point I was fully in &quot;I don’t even care anymore&quot; mode.&lt;&#x2F;p&gt;
&lt;p&gt;GoatCounter is a Go program that ships prebuilt binaries. I could just download
the standalone Linux binary and run it behind Caddy directly, without Docker at
all.&lt;&#x2F;p&gt;
&lt;p&gt;Except even that wasn&#x27;t as straightforward as it should have been.&lt;&#x2F;p&gt;
&lt;p&gt;The download URL on GitHub releases points to a gzip-compressed file. I
accidentally downloaded it and tried running it directly without decompressing
it first, leading to confusing shell errors (&quot;command not found&quot; as it tried to
parse the binary as text). Once I properly decompressed it, I finally had a
functioning ELF binary.&lt;&#x2F;p&gt;
&lt;p&gt;I ran &lt;code&gt;goatcounter serve&lt;&#x2F;code&gt;, initialized the SQLite database, and got it fully
running behind Caddy with one reverse proxy entry. TLS worked automatically,
and finally, my simple analytics system was live.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;observations&quot;&gt;Observations&lt;&#x2F;h3&gt;
&lt;p&gt;Docker was supposed to make self-hosting trivial. But broken registries,
permission issues, changing build standards, and fragmented tooling made things
much harder. Self-hosted projects often don&#x27;t have the resources to maintain
registry hosting, Docker images, and packaging across multiple platforms.
The friction involved in doing things ethically discourages people from even
trying privacy-respecting solutions.&lt;&#x2F;p&gt;
&lt;p&gt;It&#x27;s no surprise so many people just give up and paste Google Analytics into
their site -- it&#x27;s not better technology, it&#x27;s easier deployment.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;conclusions&quot;&gt;Conclusions&lt;&#x2F;h3&gt;
&lt;p&gt;Privacy-first analytics tools absolutely exist — GoatCounter is an excellent
project, and I’m very happy with it now that it’s running.&lt;&#x2F;p&gt;
&lt;p&gt;But the tooling friction creates artificial barriers that discourage adoption.
Ironically, it&#x27;s often easier to deploy privacy-invasive analytics than
privacy-respecting ones.&lt;&#x2F;p&gt;
&lt;p&gt;There’s a huge opportunity here to improve the ecosystem:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Smarter build tooling&lt;&#x2F;li&gt;
&lt;li&gt;Better Docker automation&lt;&#x2F;li&gt;
&lt;li&gt;Easier packaging of self-hostable apps&lt;&#x2F;li&gt;
&lt;li&gt;(Maybe even a tool that turns install instructions into Dockerfiles
automatically — but that’s a post for another day...)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;If we want ethical software to be the default, we need to make it easier, not
harder.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;edit-20250526&quot;&gt;Edit (20250526)&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;there-is-a-docker-image&quot;&gt;There &lt;strong&gt;IS&lt;&#x2F;strong&gt; a Docker Image&lt;&#x2F;h4&gt;
&lt;p&gt;Welp, naturally, after all of this — the registry issues, the broken builds, the
manual binary install — I found out &lt;em&gt;there is&lt;&#x2F;em&gt; a Docker image for GoatCounter:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;hub.docker.com&#x2F;r&#x2F;arp242&#x2F;goatcounter&quot;&gt;arp242&#x2F;goatcounter&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;em&gt;(Yes, maintained by the actual author.)&lt;&#x2F;em&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Apparently I was just one &lt;code&gt;docker pull&lt;&#x2F;code&gt; away the whole time.&lt;&#x2F;p&gt;
&lt;p&gt;:facepalm:&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>node-postgres-exporter — A Lightweight, Configurable PostgreSQL Prometheus Exporter</title>
          <pubDate>Fri, 20 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/building-node-postgres-exporter/</link>
          <guid>https://jakegoldsborough.com/blog/2025/building-node-postgres-exporter/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/building-node-postgres-exporter/">&lt;p&gt;I’m releasing a small project I’ve been building:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;node-postgres-exporter&lt;&#x2F;code&gt;, is a lightweight Prometheus exporter for PostgreSQL,
written in Node.js.&lt;&#x2F;p&gt;
&lt;p&gt;The goal: build a fully configurable exporter that supports multiple databases,
dynamic custom metrics, and solid production fault tolerance — while keeping
the design modular and simple to operate.&lt;&#x2F;p&gt;
&lt;p&gt;There are excellent existing exporters in the ecosystem — but many of them
require full privilege access, tightly coupled SQL views, or lack flexible
multi-database support.&lt;&#x2F;p&gt;
&lt;p&gt;This exporter aims to solve a more targeted problem:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Support multiple independent PostgreSQL instances&lt;&#x2F;li&gt;
&lt;li&gt;Expose core database metrics (connections, size, etc.)&lt;&#x2F;li&gt;
&lt;li&gt;Allow fully configurable, per-database custom metrics via JSON&lt;&#x2F;li&gt;
&lt;li&gt;Provide basic fault isolation so partial database failures don&#x27;t block full scrapes&lt;&#x2F;li&gt;
&lt;li&gt;Expose Prometheus-friendly endpoints with modern HTTP APIs&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;key-features&quot;&gt;Key Features&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;Node.js + Express based architecture&lt;&#x2F;li&gt;
&lt;li&gt;Uses &lt;a href=&quot;https:&#x2F;&#x2F;node-postgres.com&#x2F;&quot;&gt;&lt;code&gt;pg&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; for database access via dedicated connection pools per database&lt;&#x2F;li&gt;
&lt;li&gt;Uses &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;siimon&#x2F;prom-client&quot;&gt;&lt;code&gt;prom-client&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; for full Prometheus metric management&lt;&#x2F;li&gt;
&lt;li&gt;Simple configuration via JSON files (&lt;code&gt;databases.json&lt;&#x2F;code&gt; and &lt;code&gt;queries.json&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;API key authentication (Bearer token) for securing metrics endpoint&lt;&#x2F;li&gt;
&lt;li&gt;Graceful shutdown handling for safe database pool cleanup&lt;&#x2F;li&gt;
&lt;li&gt;Fully Dockerized with ready-to-run &lt;code&gt;docker-compose&lt;&#x2F;code&gt; setup for local testing&lt;&#x2F;li&gt;
&lt;li&gt;Includes health endpoints: &lt;code&gt;&#x2F;healthz&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;readyz&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;livez&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;configz&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;metric-types-supported&quot;&gt;Metric Types Supported&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;PostgreSQL connection counts&lt;&#x2F;li&gt;
&lt;li&gt;Per-database size metrics&lt;&#x2F;li&gt;
&lt;li&gt;Custom query metrics with support for &lt;code&gt;Gauge&lt;&#x2F;code&gt; and &lt;code&gt;Counter&lt;&#x2F;code&gt; types&lt;&#x2F;li&gt;
&lt;li&gt;Exporter self-metrics: scrape duration, error tracking, scrape lockouts, etc.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;dynamic-query-configuration&quot;&gt;Dynamic Query Configuration&lt;&#x2F;h3&gt;
&lt;p&gt;One of the core design goals for &lt;code&gt;node-postgres-exporter&lt;&#x2F;code&gt; was flexibility
without requiring code changes. To achieve this, all custom metric definitions
are fully externalized via configuration files.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;queries.json&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Custom metrics are defined in a simple &lt;code&gt;queries.json&lt;&#x2F;code&gt; file, allowing operators
to add new metrics by writing plain SQL queries without modifying or
redeploying the exporter. Each query entry includes:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;name&lt;&#x2F;code&gt; – the Prometheus metric name&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;help&lt;&#x2F;code&gt; – description for the metric&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;type&lt;&#x2F;code&gt; – gauge or counter&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;labels&lt;&#x2F;code&gt; – array of columns to extract as metric labels&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;query&lt;&#x2F;code&gt; – the raw SQL statement to run against the target database&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;[
  {
    &amp;quot;name&amp;quot;: &amp;quot;active_users&amp;quot;,
    &amp;quot;help&amp;quot;: &amp;quot;Number of active users&amp;quot;,
    &amp;quot;type&amp;quot;: &amp;quot;gauge&amp;quot;,
    &amp;quot;labels&amp;quot;: [&amp;quot;status&amp;quot;],
    &amp;quot;query&amp;quot;: &amp;quot;SELECT status, COUNT(*)::int FROM users GROUP BY status&amp;quot;
  }
]
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;On each scrape, the exporter executes the configured queries, extracts label
values from the row fields, and populates the Prometheus metric accordingly.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;fault-tolerance-and-isolation&quot;&gt;Fault Tolerance and Isolation&lt;&#x2F;h3&gt;
&lt;p&gt;Another design goal was to handle database failures gracefully. If one database
becomes unavailable (network issue, restart, maintenance), the exporter:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Continues scraping all healthy databases&lt;&#x2F;li&gt;
&lt;li&gt;Exposes scrape success&#x2F;failure per database as dedicated metrics
(&lt;code&gt;pg_scrape_success&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Never fails the entire scrape due to single database issues&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Internally, this is implemented using:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Promise.allSettled()&lt;&#x2F;code&gt; to concurrently scrape databases while isolating
failures&lt;&#x2F;li&gt;
&lt;li&gt;Explicit error metric tracking&lt;&#x2F;li&gt;
&lt;li&gt;Per-database scrape duration timing&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;example-use-case&quot;&gt;Example Use Case&lt;&#x2F;h3&gt;
&lt;p&gt;This design fits environments where:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;You manage multiple distinct PostgreSQL instances&lt;&#x2F;li&gt;
&lt;li&gt;You have limited privilege access on some databases&lt;&#x2F;li&gt;
&lt;li&gt;You want metrics to be purely driven by SQL queries without deeper system
integration&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;what-it-s-not-trying-to-be&quot;&gt;What it’s not trying to be&lt;&#x2F;h3&gt;
&lt;ul&gt;
&lt;li&gt;A full replacement for highly privileged exporters like the canonical
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;prometheus-community&#x2F;postgres_exporter&quot;&gt;&lt;code&gt;postgres_exporter&lt;&#x2F;code&gt;&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;A deep SQL monitoring agent requiring superuser roles or heavy introspection&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;This exporter is intentionally &lt;strong&gt;simple, safe, and scoped&lt;&#x2F;strong&gt; — easy to audit and
deploy.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;roadmap-future-ideas&quot;&gt;Roadmap &#x2F; Future Ideas&lt;&#x2F;h3&gt;
&lt;p&gt;There’s plenty of room for future enhancement:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Hot-reload support for queries and DB configs&lt;&#x2F;li&gt;
&lt;li&gt;JSON schema validation on configuration files&lt;&#x2F;li&gt;
&lt;li&gt;Cardinality protection on dynamic label sets&lt;&#x2F;li&gt;
&lt;li&gt;Additional metric types (&lt;code&gt;Histogram&lt;&#x2F;code&gt;, &lt;code&gt;Summary&lt;&#x2F;code&gt;)&lt;&#x2F;li&gt;
&lt;li&gt;Publish Docker images to container registries&lt;&#x2F;li&gt;
&lt;li&gt;Integration with secret management for DB credentials&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;source-code&quot;&gt;Source Code&lt;&#x2F;h3&gt;
&lt;p&gt;Repo available here:
&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;node-postgres-exporter&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;node-postgres-exporter&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;a-side-benefit&quot;&gt;A Side Benefit&lt;&#x2F;h3&gt;
&lt;p&gt;Although this started as part of an audition project, I ended up building
something I’d absolutely consider productionizing for real-world use cases —
and more importantly, something I can showcase as a clean systems-level
engineering project.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>ChatGPT Changelog (fka Weekly Summary) - 25&#x2F;52</title>
          <pubDate>Fri, 20 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-25/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-25/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-25/">&lt;h2 id=&quot;weekly-summary-2025-06-13-to-2025-06-20&quot;&gt;Weekly Summary — 2025-06-13 to 2025-06-20&lt;&#x2F;h2&gt;
&lt;h3 id=&quot;accomplishments&quot;&gt;Accomplishments&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;shelltrax-tui&quot;&gt;Shelltrax TUI&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Continued refinement of Shelltrax (TUI music player).&lt;&#x2F;li&gt;
&lt;li&gt;Autoplay behavior adjustments: added logic to handle album track advancement.&lt;&#x2F;li&gt;
&lt;li&gt;Made progress on Symphonia integration for FLAC and seeking support.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;open-udisc-api&quot;&gt;open-udisc-api&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Further decoding work on Remix-style &lt;code&gt;.data&lt;&#x2F;code&gt; format.&lt;&#x2F;li&gt;
&lt;li&gt;Continued schema-indexed record handling.&lt;&#x2F;li&gt;
&lt;li&gt;Published part 4 of your UDisc reverse engineering blog series.&lt;&#x2F;li&gt;
&lt;li&gt;Meta post idea evolving for later publication.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;node-postgres-exporter&quot;&gt;node-postgres-exporter&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Built a lightweight &lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ducks&#x2F;node-postgres-exporter&quot;&gt;&lt;code&gt;node-postgres-exporter&lt;&#x2F;code&gt;&lt;&#x2F;a&gt; for Prometheus.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;nixos&quot;&gt;NixOS&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Ongoing modular NixOS configuration improvements.&lt;&#x2F;li&gt;
&lt;li&gt;TTY-first daily driver series is slowly building up. New post soon.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;swedish-studies&quot;&gt;Swedish studies&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Hit 186 days in a row on Babbel.&lt;&#x2F;li&gt;
&lt;li&gt;Published “Typing Swedish with US Keyboard” blog post.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;todos&quot;&gt;TODOs&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;shelltrax-tui-1&quot;&gt;ShellTrax TUI&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Fix footer (currently doesn&#x27;t update with next song, and progress is not perfect)&lt;&#x2F;li&gt;
&lt;li&gt;Fix broken metadata parsing&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;open-udisc-api-1&quot;&gt;open-udisc-api&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Figure out what post 5 should be about&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;nixos-1&quot;&gt;NixOS&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Write next post about hyprland and other config updates&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;swedish-studies-1&quot;&gt;Swedish studies&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Listen to more audiobooks this week&lt;&#x2F;li&gt;
&lt;li&gt;Do more than bare minimum reviews&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>ChatGPT Changelog (fka Weekly Summary) - 24&#x2F;52</title>
          <pubDate>Fri, 13 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-24/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-24/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-24/">&lt;h3 id=&quot;happy-friday-the-13th&quot;&gt;Happy Friday the 13th!&lt;&#x2F;h3&gt;
&lt;p&gt;I have decided to slightly change up my personal weekly summary posts.&lt;&#x2F;p&gt;
&lt;p&gt;I have been using ChatGPT more and more and really find it to be an important
tool. It saves me so much time with debugging error messages and digging
through docs. It can also keep very good track of what I&#x27;ve been working on
and thinking about. Because of this, I have decided to update the way I do
the weekly summary. I am going to let ChatGPT generate a personal changelog
of everything I have been working on. It&#x27;s not perfect so I do have to edit
it some but it still does a better job than I could ever do.&lt;&#x2F;p&gt;
&lt;p&gt;So with that, here is my weekly ChatGPT changelog:&lt;&#x2F;p&gt;
&lt;h3 id=&quot;accomplishments-since-june-7&quot;&gt;Accomplishments (Since June 7)&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;udisc-api-blog-series&quot;&gt;UDisc API &#x2F; Blog Series&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Published Part 4 of the blog series: deep dive into &lt;code&gt;smartLayouts&lt;&#x2F;code&gt; and &lt;code&gt;holes&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Improved &lt;code&gt;deepHydrate&lt;&#x2F;code&gt;, began resolving issues with arrays of unresolved IDs.&lt;&#x2F;li&gt;
&lt;li&gt;Continued resolving schema-indexed keys (e.g. &lt;code&gt;_254: 270&lt;&#x2F;code&gt;) for clarity.&lt;&#x2F;li&gt;
&lt;li&gt;Added blog link to &lt;code&gt;open-udisc-api&lt;&#x2F;code&gt; README.&lt;&#x2F;li&gt;
&lt;li&gt;Cleaned up TypeScript types and debugged schema hydration logic.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;infra-devops&quot;&gt;Infra &amp;amp; DevOps&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Set up Gitea using a Linux service.&lt;&#x2F;li&gt;
&lt;li&gt;Implemented a Git pre-push hook to filter Zola drafts before publishing.&lt;&#x2F;li&gt;
&lt;li&gt;Decided to drop OpenAPI support for the current API project.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;postgresql-exporter&quot;&gt;PostgreSQL Exporter&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Deployed a general-purpose PostgreSQL -&amp;gt; Prometheus exporter.&lt;&#x2F;li&gt;
&lt;li&gt;Added &lt;code&gt;&#x2F;metrics&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;livez&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;readyz&lt;&#x2F;code&gt;, and &lt;code&gt;&#x2F;configz&lt;&#x2F;code&gt; endpoints.&lt;&#x2F;li&gt;
&lt;li&gt;Configured basic token authentication.&lt;&#x2F;li&gt;
&lt;li&gt;Documented endpoints and auth in the README.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;nixos-hyprland&quot;&gt;NixOS &#x2F; Hyprland&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Diagnosed Hyprland reload issues involving &lt;code&gt;exec-once&lt;&#x2F;code&gt; behavior.&lt;&#x2F;li&gt;
&lt;li&gt;Installed and confirmed use of:
&lt;ul&gt;
&lt;li&gt;wezterm&lt;&#x2F;li&gt;
&lt;li&gt;dunst&lt;&#x2F;li&gt;
&lt;li&gt;hypridle&lt;&#x2F;li&gt;
&lt;li&gt;bibata-cursor-theme&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;Explored xcursor previews and how to apply cursor themes via Nix.&lt;&#x2F;li&gt;
&lt;li&gt;Set up Waybar with Gruvbox-inspired CSS.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;miscellaneous&quot;&gt;Miscellaneous&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Continued Swedish learning streak (currently 179+ days).&lt;&#x2F;li&gt;
&lt;li&gt;Expanded concept for a first-person disc golf course viewer.&lt;&#x2F;li&gt;
&lt;li&gt;Researched 360-degree and panoramic photo capture on Android&#x2F;GrapheneOS.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;hr &#x2F;&gt;
&lt;h3 id=&quot;to-do-improvements&quot;&gt;To-Do &#x2F; Improvements&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;udisc-api-blog&quot;&gt;UDisc API + Blog&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Fix unresolved arrays in &lt;code&gt;deepHydrate&lt;&#x2F;code&gt;, such as &lt;code&gt;targetPositionLabels&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Begin drafting a &quot;meta&quot; post summarizing the UDisc reverse-engineering series.&lt;&#x2F;li&gt;
&lt;li&gt;Write additional parts exploring fields like &lt;code&gt;dogleg&lt;&#x2F;code&gt; or &lt;code&gt;obLines&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Explore creating a visualizer for hydrated course data.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;infra-self-hosting&quot;&gt;Infra &#x2F; Self-Hosting&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Document the Gitea systemd service setup.&lt;&#x2F;li&gt;
&lt;li&gt;Write a blog post about using Git hooks to manage Zola drafts.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;nixos-hyprland-1&quot;&gt;NixOS &#x2F; Hyprland&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Investigate why &lt;code&gt;exec-once&lt;&#x2F;code&gt; commands don&#x27;t rerun on &lt;code&gt;hyprctl reload&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Continue refining modular &lt;code&gt;environment.systemPackages&lt;&#x2F;code&gt; layout.&lt;&#x2F;li&gt;
&lt;li&gt;Extend daily-driver blog series for NixOS on MacBook hardware.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;portfolio-sharing&quot;&gt;Portfolio + Sharing&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Add README links or badges to published blog posts.&lt;&#x2F;li&gt;
&lt;li&gt;Write a post about decoding custom schema formats in APIs.&lt;&#x2F;li&gt;
&lt;li&gt;Share blog content on Hacker News, Lobsters, or relevant communities.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Typing åäö in Hyprland: A US, Mac-style Keyboard Guide</title>
          <pubDate>Thu, 12 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/typing-swedish-us-keyboard/</link>
          <guid>https://jakegoldsborough.com/blog/2025/typing-swedish-us-keyboard/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/typing-swedish-us-keyboard/">&lt;h3 id=&quot;intro&quot;&gt;Intro&lt;&#x2F;h3&gt;
&lt;p&gt;As I&#x27;ve mentioned in the previous GPT Changelog nee Weekly Summary, I have been
learning Swedish for over 6 months now. I&#x27;m getting pretty good at listening and
reading, but I have been doing very little speaking and typing practice. I would
like to change that by building muscle memory through proper Swedish typing.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;enabling-in-hyprland&quot;&gt;Enabling in Hyprland&lt;&#x2F;h3&gt;
&lt;p&gt;Like many things in the Linux world, there is more than one way to do this. The
easiest way would just be to use the GUI menu and add the keyboard. I would
prefer to keep this declarative and inside some config files so it&#x27;s more easily
reproducible.&lt;&#x2F;p&gt;
&lt;p&gt;Instead, I will add some config to Hyprland to enable this. This config will:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;enable the Swedish &lt;code&gt;kb_layout&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;add the US variant for the Swedish layout&lt;&#x2F;li&gt;
&lt;li&gt;sets &lt;code&gt;rctrl&lt;&#x2F;code&gt; as the key that toggles between layouts&lt;&#x2F;li&gt;
&lt;li&gt;sets &lt;code&gt;ralt&lt;&#x2F;code&gt; as the modifier key for Swedish keys&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;pre&gt;&lt;code&gt;input {
  kb_layout = us,se
  kb_variant = ,us
  kb_options = grp:rctrl_toggle,lv3:ralt_switch
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Save the config and reload the hypr config:
&lt;code&gt;hyprctl reload&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;what-is-the-us-variant&quot;&gt;What is the US variant?&lt;&#x2F;h3&gt;
&lt;p&gt;Before testing it out, I should explain what the US variant is.&lt;&#x2F;p&gt;
&lt;p&gt;The &lt;strong&gt;Swedish (US)&lt;&#x2F;strong&gt; layout — sometimes referred to as &quot;Swedish for US keyboards&quot; —
is a variant of the traditional Swedish keyboard layout that’s specifically
designed for people using &lt;strong&gt;US physical keyboards&lt;&#x2F;strong&gt;. It allows you to type
Swedish characters like &lt;code&gt;å&lt;&#x2F;code&gt;, &lt;code&gt;ä&lt;&#x2F;code&gt;, and &lt;code&gt;ö&lt;&#x2F;code&gt; without rearranging the rest of your
familiar QWERTY key positions.&lt;&#x2F;p&gt;
&lt;p&gt;This layout keeps the standard &lt;strong&gt;US punctuation and symbol positions intact&lt;&#x2F;strong&gt; —
like &lt;code&gt;&#x2F;&lt;&#x2F;code&gt;, &lt;code&gt;@&lt;&#x2F;code&gt;, &lt;code&gt;;&lt;&#x2F;code&gt;, and &lt;code&gt;&quot;&lt;&#x2F;code&gt; — while introducing the necessary Swedish characters
via the &lt;strong&gt;Right Alt key&lt;&#x2F;strong&gt; (also called &lt;code&gt;AltGr&lt;&#x2F;code&gt; in XKB terminology).&lt;&#x2F;p&gt;
&lt;p&gt;Unlike the default &lt;strong&gt;Swedish layout&lt;&#x2F;strong&gt;, which assumes a physical keyboard with
keys labeled for Swedish (and repositions many punctuation marks), the
&lt;strong&gt;Swedish (US)&lt;&#x2F;strong&gt; layout overlays the Swedish characters on a standard US
keyboard. This makes it ideal for:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;Swedish learners or expats using American keyboards&lt;&#x2F;li&gt;
&lt;li&gt;Developers or typists who need occasional access to &lt;code&gt;å&lt;&#x2F;code&gt;, &lt;code&gt;ä&lt;&#x2F;code&gt;, and &lt;code&gt;ö&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;li&gt;Anyone who doesn’t want to remap common keys like &lt;code&gt;@&lt;&#x2F;code&gt;, &lt;code&gt;|&lt;&#x2F;code&gt;, or &lt;code&gt;&#x2F;&lt;&#x2F;code&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;In practice, this means you keep your normal US key behavior, and just use
&lt;strong&gt;Right Alt as a modifier&lt;&#x2F;strong&gt; to access Swedish characters:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Right Alt + w&lt;&#x2F;code&gt; = å&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;Right Alt + &#x27;&lt;&#x2F;code&gt; = ä&lt;&#x2F;li&gt;
&lt;li&gt;&lt;code&gt;Right Alt + ;&lt;&#x2F;code&gt; = ö&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;And uppercase versions with Shift.&lt;&#x2F;p&gt;
&lt;p&gt;If you’re used to US QWERTY and want to type Swedish without sacrificing
developer-friendly symbols, &lt;strong&gt;Swedish (US)&lt;&#x2F;strong&gt; is the best option available — and
it integrates smoothly into Linux environments like Hyprland when configured
correctly.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;se-swedish-us-layout-character-cheat-sheet&quot;&gt;🇸🇪 Swedish (US) Layout – Character Cheat Sheet&lt;&#x2F;h3&gt;
&lt;table class=&quot;swedish-keyboard-cheatsheet&quot;&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Character&lt;&#x2F;th&gt;
      &lt;th&gt;Key Combo&lt;&#x2F;th&gt;
      &lt;th&gt;Notes&lt;&#x2F;th&gt;
    &lt;&#x2F;tr&gt;
  &lt;&#x2F;thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;å&lt;&#x2F;td&gt;
      &lt;td&gt;Right Alt + &lt;kbd&gt;w&lt;&#x2F;kbd&gt;&lt;&#x2F;td&gt;
      &lt;td&gt;Lowercase å&lt;&#x2F;td&gt;
    &lt;&#x2F;tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Å&lt;&#x2F;td&gt;
      &lt;td&gt;Right Alt + &lt;kbd&gt;Shift&lt;&#x2F;kbd&gt; + &lt;kbd&gt;w&lt;&#x2F;kbd&gt;&lt;&#x2F;td&gt;
      &lt;td&gt;Uppercase Å&lt;&#x2F;td&gt;
    &lt;&#x2F;tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ä&lt;&#x2F;td&gt;
      &lt;td&gt;Right Alt + &lt;kbd&gt;&#x27;&lt;&#x2F;kbd&gt;&lt;&#x2F;td&gt;
      &lt;td&gt;Apostrophe key&lt;&#x2F;td&gt;
    &lt;&#x2F;tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Ä&lt;&#x2F;td&gt;
      &lt;td&gt;Right Alt + &lt;kbd&gt;Shift&lt;&#x2F;kbd&gt; + &lt;kbd&gt;&#x27;&lt;&#x2F;kbd&gt;&lt;&#x2F;td&gt;
      &lt;td&gt;Shift + Apostrophe&lt;&#x2F;td&gt;
    &lt;&#x2F;tr&gt;
    &lt;tr&gt;
      &lt;td&gt;ö&lt;&#x2F;td&gt;
      &lt;td&gt;Right Alt + &lt;kbd&gt;;&lt;&#x2F;kbd&gt;&lt;&#x2F;td&gt;
      &lt;td&gt;Semicolon key&lt;&#x2F;td&gt;
    &lt;&#x2F;tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Ö&lt;&#x2F;td&gt;
      &lt;td&gt;Right Alt + &lt;kbd&gt;Shift&lt;&#x2F;kbd&gt; + &lt;kbd&gt;;&lt;&#x2F;kbd&gt;&lt;&#x2F;td&gt;
      &lt;td&gt;Shift + Semicolon&lt;&#x2F;td&gt;
    &lt;&#x2F;tr&gt;
  &lt;&#x2F;tbody&gt;
&lt;&#x2F;table&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;&#x2F;h3&gt;
&lt;p&gt;You should now have a working Hyprland config that will allow you to type
Swedish on a Mac-style, US variant keyboard.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Weekly Summary - 23&#x2F;52</title>
          <pubDate>Sat, 07 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-23/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-23/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-23/">&lt;h3 id=&quot;accomplishments&quot;&gt;Accomplishments&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;reverse-engineering-udisc-api&quot;&gt;Reverse Engineering UDisc API&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Published Part 3 of blog series on &lt;code&gt;Course&lt;&#x2F;code&gt; and &lt;code&gt;CourseDetails&lt;&#x2F;code&gt; structure.&lt;&#x2F;li&gt;
&lt;li&gt;Drafted outline for Part 4, focused on &lt;code&gt;smartLayouts&lt;&#x2F;code&gt; and &lt;code&gt;hole&lt;&#x2F;code&gt; info.&lt;&#x2F;li&gt;
&lt;li&gt;Renamed &lt;code&gt;resolveSchemaMap&lt;&#x2F;code&gt; to a clearer, more specific function name.&lt;&#x2F;li&gt;
&lt;li&gt;Improved nested hydration using &lt;code&gt;deepHydrate&lt;&#x2F;code&gt; for decoding nested data.&lt;&#x2F;li&gt;
&lt;li&gt;Considered naming conventions like &lt;code&gt;courseWithDetails&lt;&#x2F;code&gt; for client API.&lt;&#x2F;li&gt;
&lt;li&gt;Decided to provide a client library first, rather than a full backend.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;typescript-module-and-publishing&quot;&gt;TypeScript Module and Publishing&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Adopted &lt;code&gt;YYYYMMDD&lt;&#x2F;code&gt; format for versioning with semantic meaning.&lt;&#x2F;li&gt;
&lt;li&gt;Resolved JSON parsing and bundling issues in the TypeScript setup.&lt;&#x2F;li&gt;
&lt;li&gt;Held off on publishing to npm, but prepared module for later release.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;static-site-and-blogging&quot;&gt;Static Site and Blogging&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Fixed insecure site warning by enabling HTTPS on GitHub Pages.&lt;&#x2F;li&gt;
&lt;li&gt;Designed favicons and tried different accent colors and sizes.&lt;&#x2F;li&gt;
&lt;li&gt;Planned a meta-post to tie the blog series together conceptually.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;nixos-daily-driver-series&quot;&gt;NixOS Daily Driver Series&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Published Part 3 of your NixOS daily driver blog series.&lt;&#x2F;li&gt;
&lt;li&gt;Started exploring ideas for better display managers like Lemurs.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;gitea-self-hosting&quot;&gt;Gitea Self-Hosting&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Setup Gitea as Linux service.&lt;&#x2F;li&gt;
&lt;li&gt;Resolved SSH cloning issues by switching to internal SSH service.&lt;&#x2F;li&gt;
&lt;li&gt;Researched CI&#x2F;CD via Woodpecker&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;swedish-language-learning&quot;&gt;Swedish Language Learning&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Reached 179-day streak learning Swedish using Babbel.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h3 id=&quot;to-do-next-steps&quot;&gt;To-Do &#x2F; Next Steps&lt;&#x2F;h3&gt;
&lt;h4 id=&quot;udisc-blog-series&quot;&gt;UDisc Blog Series&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Write and publish Part 4 about &lt;code&gt;smartLayouts&lt;&#x2F;code&gt;, &lt;code&gt;holes&lt;&#x2F;code&gt;, and &lt;code&gt;tees&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;li&gt;Create a meta-post summarizing the series with internal references.&lt;&#x2F;li&gt;
&lt;li&gt;Explore building a schema or data visualizer for reader clarity.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;open-udisc-api-module&quot;&gt;open-udisc-api Module&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Prepare for npm publishing (README, &lt;code&gt;exports&lt;&#x2F;code&gt;, format cleanup).&lt;&#x2F;li&gt;
&lt;li&gt;Write more Vitest-based tests using mock data to avoid real API calls.&lt;&#x2F;li&gt;
&lt;li&gt;Add inline documentation and usage examples to client methods.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;nixos-configuration&quot;&gt;NixOS Configuration&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Start Lemurs setup for display management.&lt;&#x2F;li&gt;
&lt;li&gt;Write Part 4 of the daily driver series covering service setup.&lt;&#x2F;li&gt;
&lt;li&gt;Explore modularizing &lt;code&gt;environment.systemPackages&lt;&#x2F;code&gt; further.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;gitea-enhancements&quot;&gt;Gitea Enhancements&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Consider automating deployment with Git hooks or CI.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;additional-ideas&quot;&gt;Additional Ideas&lt;&#x2F;h4&gt;
&lt;ul&gt;
&lt;li&gt;Write a short post explaining UDisc &lt;code&gt;.data&lt;&#x2F;code&gt; format independently.&lt;&#x2F;li&gt;
&lt;li&gt;Research legality of wrapper libraries for non-public data APIs.&lt;&#x2F;li&gt;
&lt;li&gt;Consider writing a proxy or wrapper API as a hosted endpoint.&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver on a late 2011 Macbook Pro, part 3&#x2F;? - user, WM, tools</title>
          <pubDate>Thu, 05 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-3/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-3/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-3/">&lt;h4 id=&quot;recap&quot;&gt;Recap&lt;&#x2F;h4&gt;
&lt;p&gt;See &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;nixos-daily-driver-2&quot;&gt;Part 2&lt;&#x2F;a&gt; where I cover installation and
basic configuration.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;outline&quot;&gt;Outline&lt;&#x2F;h4&gt;
&lt;p&gt;Things we will be covering in this post:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;adding a non-root user with sudo access&lt;&#x2F;li&gt;
&lt;li&gt;installing a window manager - Hyprland&lt;&#x2F;li&gt;
&lt;li&gt;installing some tools - neovim, git, waybar, wezterm&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;install-a-text-editor&quot;&gt;Install a Text Editor&lt;&#x2F;h4&gt;
&lt;p&gt;First things first, let&#x27;s install a better text editor to make our configuration
updating easier.&lt;&#x2F;p&gt;
&lt;p&gt;You will have to use &lt;code&gt;nano&lt;&#x2F;code&gt; for this. Open &lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt;
and add the following:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;programs.neovim = {
  enable = true;
  defaultEditor = true;
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Use &lt;code&gt;ctrl+o&lt;&#x2F;code&gt; then &lt;code&gt;ctrl+x&lt;&#x2F;code&gt; to write and exit nano.&lt;&#x2F;p&gt;
&lt;p&gt;Then run &lt;code&gt;nixos-rebuild switch&lt;&#x2F;code&gt;. NixOS will rebuild your config and return you
to a terminal. You should now be able to start neovim by running &lt;code&gt;nvim&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;adding-a-user&quot;&gt;Adding a User&lt;&#x2F;h4&gt;
&lt;p&gt;Now that we can edit files easier, let&#x27;s add a non-root user.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;users.users.disco = {
 isNormalUser = true;
 extraGroups = [
   &amp;quot;wheel&amp;quot;             # Allows sudo access
   &amp;quot;networkmanager&amp;quot;    # Allows controlling network
   &amp;quot;audio&amp;quot;             # Audio device access
   &amp;quot;video&amp;quot;             # Video devices (e.g. backlight, GPU)
   &amp;quot;input&amp;quot;             # For input devices like keyboard&amp;#x2F;mouse config
 ];
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Again, save and exit and then rebuild:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;nixos-rebuild switch&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;After successfully rebuilding, you will need to set a password for the new user.
For me that&#x27;s:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;passwd disco&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Then you can &lt;code&gt;exit&lt;&#x2F;code&gt; and login as your new sudo accessible user.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;enable-network-tools&quot;&gt;Enable Network Tools&lt;&#x2F;h4&gt;
&lt;p&gt;This will enable tools like &lt;code&gt;nmtui&lt;&#x2F;code&gt; to allow for easier network setup.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;networking.networkmanager.enable = true;&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;h4 id=&quot;install-a-window-manager-and-daily-driver-tools&quot;&gt;Install a Window Manager and Daily Driver Tools&lt;&#x2F;h4&gt;
&lt;p&gt;I am using &lt;a href=&quot;https:&#x2F;&#x2F;hyprland.org&#x2F;&quot;&gt;Hyprland&lt;&#x2F;a&gt;. Hyprland is a dynamic Wayland
compositor that supports tiling, floating, and hybrid layouts.&lt;&#x2F;p&gt;
&lt;p&gt;Add the following to your &lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt;. This enables Hyprland
along with XDG portals, which are required for features like file pickers,
screen sharing, and communication between sandboxed apps (especially Flatpaks
and browsers).&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;programs.hyprland.enable = true;
xdg.portal = {
  enable = true;
  wlr.enable = true;
  extraPortals = [ pkgs.xdg-desktop-portal-hyprland ];
};
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Before rebuilding this time though, we will also install some daily driver tools.&lt;&#x2F;p&gt;
&lt;p&gt;One reason for this is that &lt;code&gt;Hyprland&lt;&#x2F;code&gt; uses the &lt;code&gt;kitty&lt;&#x2F;code&gt; terminal by default.&lt;&#x2F;p&gt;
&lt;p&gt;Look for this line in your &lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt; file:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;environment.systemPackages = with pkgs; [
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;You will likely find this commented out with &lt;code&gt;wget&lt;&#x2F;code&gt; listed as a package.&lt;&#x2F;p&gt;
&lt;p&gt;Expand on that section so it becomes:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;environment.systemPackages = with pkgs; [
  kitty # needed by Hyprland by default
  wget
  hyprpaper # background utility for Hyprland
  hyprlock # screen lock utility for Hyprland
  librewolf # privacy focused browser based on firefox
  wofi # needed by Hyprland by default
  git
  waybar # status bar for Hyprland
  wezterm
];
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Some tools like &lt;code&gt;neovim&lt;&#x2F;code&gt; and &lt;code&gt;hyprland&lt;&#x2F;code&gt; have official NixOS modules, which let you
enable them declaratively using &lt;code&gt;enable = true;&lt;&#x2F;code&gt;. Other tools (like &lt;code&gt;git&lt;&#x2F;code&gt; or
&lt;code&gt;librewolf&lt;&#x2F;code&gt;) don’t have modules and should be added directly to
&lt;code&gt;environment.systemPackages&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Now it&#x27;s time to rebuild. Again, this is done by:
&lt;code&gt;sudo nixos-rebuild switch&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You can now manually start &lt;code&gt;Hyprland&lt;&#x2F;code&gt; by running:
&lt;code&gt;exec Hyprland&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;You will be greeted with nothing but a random wallpaper supplied by Hyprland.&lt;&#x2F;p&gt;
&lt;p&gt;A couple important default keybinds are:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;SUPER + q - opens terminal&lt;&#x2F;li&gt;
&lt;li&gt;SUPER + r - opens wofi app runner&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;starting-hyprland-on-login&quot;&gt;Starting Hyprland on Login&lt;&#x2F;h4&gt;
&lt;p&gt;Let&#x27;s make &lt;code&gt;Hyprland&lt;&#x2F;code&gt; start on login. Create a &lt;code&gt;~&#x2F;.bash_profile&lt;&#x2F;code&gt; file if it
doesn&#x27;t exist and add this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;if [ -z &amp;quot;$DISPLAY&amp;quot; ] &amp;amp;&amp;amp; [ &amp;quot;$(tty)&amp;quot; = &amp;quot;&amp;#x2F;dev&amp;#x2F;tty1&amp;quot; ]; then
  exec Hyprland
fi
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;h4 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h4&gt;
&lt;p&gt;In this post, we have:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;added a non-root user and set their password&lt;&#x2F;li&gt;
&lt;li&gt;installed a window manager, &lt;code&gt;Hyprland&lt;&#x2F;code&gt;, and set it to start on login&lt;&#x2F;li&gt;
&lt;li&gt;installed multiple important daily driver tools like git and librewolf&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h4 id=&quot;next-time&quot;&gt;Next Time&lt;&#x2F;h4&gt;
&lt;p&gt;In Part 4, we&#x27;ll continue to configure &lt;code&gt;Hyprland&lt;&#x2F;code&gt;: adding keybindings,
wallpapers, waybar, and maybe even a lockscreen.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver on a late 2011 Macbook Pro, part 2&#x2F;? - Installation, Basic Configuration</title>
          <pubDate>Wed, 04 Jun 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-2/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-2/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-2/">&lt;p&gt;See &lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;nixos-daily-driver-1&quot;&gt;Part 1&lt;&#x2F;a&gt; where I cover why you would want
to run NixOS as a daily driver.&lt;&#x2F;p&gt;
&lt;p&gt;In this post, we will cover installation on a personal machine and setting up
a basic configuration file.&lt;&#x2F;p&gt;
&lt;p&gt;Just as a note, for this specific post I am using:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;efi&lt;&#x2F;li&gt;
&lt;li&gt;minimal ISO&lt;&#x2F;li&gt;
&lt;li&gt;dd (tool to write ISO)&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;See &lt;a href=&quot;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;#ch-installation&quot;&gt;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;#ch-installation&lt;&#x2F;a&gt;
for a full installation guide.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;installation&quot;&gt;Installation&lt;&#x2F;h4&gt;
&lt;p&gt;Because Nix and Nixpkgs can be installed on almost any Linux distro, it&#x27;s possible
to install NixOS from inside an existing Linux install. You can even install
it in place on the same partition.
&lt;a href=&quot;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;#sec-installing-from-other-distro&quot;&gt;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;#sec-installing-from-other-distro&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;p&gt;We will keep it simple and focus on using an ISO on a USB drive.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;iso&quot;&gt;ISO&lt;&#x2F;h4&gt;
&lt;p&gt;Go to &lt;a href=&quot;https:&#x2F;&#x2F;nixos.org&#x2F;download&#x2F;#nixos-iso&quot;&gt;https:&#x2F;&#x2F;nixos.org&#x2F;download&#x2F;#nixos-iso&lt;&#x2F;a&gt;
to download the ISO of your choice. If you are a newer user, you should probably
pick graphical as it will help guide you along. I have Linux experience so I
will be using the minimal version.&lt;&#x2F;p&gt;
&lt;p&gt;Next, insert your USB drive and look for it using &lt;code&gt;lsblk&lt;&#x2F;code&gt;.
Make sure it&#x27;s not mounted: &lt;code&gt;sudo umount &#x2F;dev&#x2F;sdX*&lt;&#x2F;code&gt;.
Then use &lt;code&gt;dd&lt;&#x2F;code&gt; to write the ISO to your drive:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;sudo dd bs=4M conv=fsync oflag=direct status=progress if=&amp;lt;path-to-image&amp;gt; of=&#x2F;dev&#x2F;sdX&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Now it&#x27;s time to boot.&lt;&#x2F;p&gt;
&lt;p&gt;You will be greeted with nothing but a terminal. You will be automatically
logged in as the &lt;code&gt;nixos&lt;&#x2F;code&gt; user and have sudo access without a password.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;networking&quot;&gt;Networking&lt;&#x2F;h4&gt;
&lt;p&gt;You will need a connection to the internet to download various things needed
for the install. The easiest way is to just use a wired connection if possible.
If you need wireless, it takes a little setup. The minimal installer does not
ship with the correct firmware for my wireless card so I&#x27;m forced to use a wired
connection. The wireless setup doesn&#x27;t look difficult and instructions can be
found here:
&lt;a href=&quot;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;#sec-installation-manual-networking&quot;&gt;https:&#x2F;&#x2F;nixos.org&#x2F;manual&#x2F;nixos&#x2F;stable&#x2F;#sec-installation-manual-networking&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h4 id=&quot;partitioning-and-formatting&quot;&gt;Partitioning and formatting&lt;&#x2F;h4&gt;
&lt;p&gt;The minimal NixOS install doesn&#x27;t do any partitioning or formatting so that
needs to be done manually.&lt;&#x2F;p&gt;
&lt;p&gt;First, you will need to partition:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;Create a GPT partition table&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;parted &#x2F;dev&#x2F;sda -- mklabel gpt&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Add the root partition. This will fill the disk except for the end part,
where the swap will live, and the space left in front (512MiB) which will be
used by the boot partition.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;parted &#x2F;dev&#x2F;sda -- mkpart root ext4 512MB -8GB&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;Next, add a swap partition. The size required will vary according to needs, here a 8GB one is created.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;parted &#x2F;dev&#x2F;sda -- mkpart swap linux-swap -8GB 100%&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;Finally, the boot partition. NixOS by default uses the ESP (EFI system
partition) as its &lt;code&gt;&#x2F;boot&lt;&#x2F;code&gt; partition. It uses the initially reserved 512MiB at
the start of the disk.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre&gt;&lt;code&gt;parted &amp;#x2F;dev&amp;#x2F;sda -- mkpart ESP fat32 1MB 512MB
parted &amp;#x2F;dev&amp;#x2F;sda -- set 3 esp on
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Now, you need to format those partitions:&lt;&#x2F;p&gt;
&lt;ol&gt;
&lt;li&gt;For initialising &lt;code&gt;Ext4&lt;&#x2F;code&gt; partitions: &lt;code&gt;mkfs.ext4&lt;&#x2F;code&gt;. It is recommended that you
assign a unique symbolic label to the file system using the option &lt;code&gt;-L label&lt;&#x2F;code&gt;,
since this makes the file system configuration independent from device changes.
For example:&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;mkfs.ext4 -L nixos &#x2F;dev&#x2F;sda1&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;For creating swap partitions: mkswap. Again it’s recommended to assign a
label to the swap partition: &lt;code&gt;-L label&lt;&#x2F;code&gt;. For example:&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;mkswap -L swap &#x2F;dev&#x2F;sda2&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;For creating boot partitions: &lt;code&gt;mkfs.fat&lt;&#x2F;code&gt;. Again it’s recommended to assign a
label to the boot partition: &lt;code&gt;-n label&lt;&#x2F;code&gt;. For example:&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;mkfs.fat -F 32 -n boot &#x2F;dev&#x2F;sda3&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;h4 id=&quot;installation-1&quot;&gt;Installation&lt;&#x2F;h4&gt;
&lt;ol&gt;
&lt;li&gt;Mount the target file system on which NixOS should be installed on &lt;code&gt;&#x2F;mnt&lt;&#x2F;code&gt;,
e.g.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;mount &#x2F;dev&#x2F;disk&#x2F;by-label&#x2F;nixos &#x2F;mnt&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;Mount the boot file system on &#x2F;mnt&#x2F;boot, e.g.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p &amp;#x2F;mnt&amp;#x2F;boot
mount -o umask=077 &amp;#x2F;dev&amp;#x2F;disk&amp;#x2F;by-label&amp;#x2F;boot &amp;#x2F;mnt&amp;#x2F;boot
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;(optional) If your machine has a limited amount of memory, you may want to
activate swap devices now (swapon device). The installer (or rather, the
build actions that it may spawn) may need quite a bit of RAM, depending on
your configuration.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;swapon &#x2F;dev&#x2F;sda2&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;
&lt;p&gt;You now need to create a file &lt;code&gt;&#x2F;mnt&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt; that
specifies the intended configuration of the system. This is because NixOS
has a declarative configuration model: you create or edit a description of
the desired configuration of your system, and then NixOS takes care of
making it happen.&lt;&#x2F;p&gt;
&lt;p&gt;The command &lt;code&gt;nixos-generate-config&lt;&#x2F;code&gt; can generate an initial configuration
file for you:&lt;&#x2F;p&gt;
&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;nixos-generate-config --root &#x2F;mnt&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This command will also create a file at &lt;code&gt;&#x2F;mnt&#x2F;etc&#x2F;nixos&#x2F;hardware-configuration.nix&lt;&#x2F;code&gt;.
This includes important config for your filesystems among other things. While
NixOS should handle this, it can&#x27;t hurt to double check just to make sure
things are defined correctly.&lt;&#x2F;p&gt;
&lt;p&gt;Most of the default config will be commented out, but a basic
&lt;code&gt;configuration.nix&lt;&#x2F;code&gt; file will look something like this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{ config, pkgs, ... }:

{
  imports = [ .&amp;#x2F;hardware-configuration.nix ];

  boot.loader.systemd-boot.enable = true;
  boot.loader.efi.canTouchEfiVariables = true;

  # Macbook Pro wireless firmware
  nixpkgs.config = {
    allowUnfree = true;
  };

  services.openssh.enable = true;

  system.stateVersion = &amp;quot;25.05&amp;quot;; # Adjust to match your NixOS version
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Note for UEFI users: You must select a boot-loader, either systemd-boot or
GRUB. The recommended option is systemd-boot: set the option
&lt;code&gt;boot.loader.systemd-boot.enable&lt;&#x2F;code&gt; to true. &lt;code&gt;nixos-generate-config&lt;&#x2F;code&gt; should do
this automatically for new configurations when booted in UEFI mode.&lt;&#x2F;p&gt;
&lt;ol start=&quot;4&quot;&gt;
&lt;li&gt;Install! This step can take some time depending on your machine.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;p&gt;&lt;code&gt;nixos-install&lt;&#x2F;code&gt;&lt;&#x2F;p&gt;
&lt;ol start=&quot;5&quot;&gt;
&lt;li&gt;As the last step, nixos-install will ask you to set the password for the
root user, e.g.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;pre&gt;&lt;code&gt;setting root password...
New password: ***
Retype new password: ***
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;ol start=&quot;6&quot;&gt;
&lt;li&gt;If everything went well, it&#x27;s time to &lt;code&gt;reboot&lt;&#x2F;code&gt;.&lt;&#x2F;li&gt;
&lt;&#x2F;ol&gt;
&lt;h4 id=&quot;summary&quot;&gt;Summary&lt;&#x2F;h4&gt;
&lt;p&gt;That&#x27;s it for this post and the installation and basic configuration of NixOS.&lt;&#x2F;p&gt;
&lt;h4 id=&quot;next-time&quot;&gt;Next Time&lt;&#x2F;h4&gt;
&lt;p&gt;In the next installment, we&#x27;ll create a non-root user, configure the Hyprland
window manager, and install some key daily-driver tools including Neovim, Git,
and LibreWolf.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>How Vulfmon Inspired Me to Learn to Weave</title>
          <pubDate>Sat, 31 May 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/vulfmon-inspired-me-to-learn-to-weave/</link>
          <guid>https://jakegoldsborough.com/blog/2025/vulfmon-inspired-me-to-learn-to-weave/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/vulfmon-inspired-me-to-learn-to-weave/">&lt;h4 id=&quot;who-is-vulfmon&quot;&gt;Who is Vulfmon&lt;&#x2F;h4&gt;
&lt;p&gt;Vulfmon is the alter ego&#x2F;moniker of Vulfpeck leader Jack Stratton. He sometimes
shoots videos in a room with various art on the walls. In the video for
&lt;a href=&quot;https:&#x2F;&#x2F;youtu.be&#x2F;dTnHAITmuYQ&quot;&gt;Alone Again, Naturally&lt;&#x2F;a&gt;, I noticed a weaving
that I loved:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;images&#x2F;vulfmon-weave.png&quot; alt=&quot;Weaving hanging on wall of Vulfmon video&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Lucky for me, I just happened to have access to a loom and yarn. Like any new
skill, it was a bit awkward to start and honestly, my first one was rough. I
stuck with it though and have made many better ones including six inspired by
Vulfmon:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Vulfmon - gold 1&lt;&#x2F;strong&gt;
&lt;img
  alt=&quot;Vulfmon inspired gold weaving&quot;
  src=&quot;&#x2F;images&#x2F;vulfmon-gold-1.jpeg&quot;
  width=&quot;600&quot; &gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Vulfmon - gold, orange, brown 1&lt;&#x2F;strong&gt;
&lt;img
  alt=&quot;Vulfmon inspired gold, orange, brown weaving&quot;
  src=&quot;&#x2F;images&#x2F;vulfmon-gold-orange-brown-1.jpeg&quot;
  width=&quot;600&quot; &gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Vulfmon - green 1&lt;&#x2F;strong&gt;
&lt;img
  alt=&quot;Vulfmon inspired green weaving&quot;
  src=&quot;&#x2F;images&#x2F;vulfmon-green-1.jpeg&quot;
  width=&quot;600&quot; &gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Vulfmon - gold 2&lt;&#x2F;strong&gt;
&lt;img
  alt=&quot;Vulfmon inspired gold weaving&quot;
  src=&quot;&#x2F;images&#x2F;vulfmon-gold-2.jpeg&quot;
  width=&quot;600&quot; &gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Mini Vulfmon - purple 1&lt;&#x2F;strong&gt;
&lt;img
  alt=&quot;Vulfmon inspired mini purple weaving&quot;
  src=&quot;&#x2F;images&#x2F;vulfmon-mini-purple-1.jpeg&quot;
  width=&quot;600&quot; &gt;&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Mini Vulfmon - orange 1&lt;&#x2F;strong&gt;
&lt;img
  alt=&quot;Vulfmon inspired mini orange weaving&quot;
  src=&quot;&#x2F;images&#x2F;vulfmon-mini-orange-1.jpeg&quot;
  width=&quot;600&quot; &gt;&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Weekly Summary - 22&#x2F;52</title>
          <pubDate>Fri, 30 May 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-22/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-22/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-22/">&lt;p&gt;Another pretty decent week. Mostly concentrated on job hunt and API inspection.&lt;&#x2F;p&gt;
&lt;p&gt;Things I did:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;worked more on some API inspection. published two posts on it. working on lib.
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;reverse-engineering-udisc-api-part-1&#x2F;&quot;&gt;Part 1 - Courses&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;blog&#x2F;2025&#x2F;reverse-engineering-udisc-api-part-2&#x2F;&quot;&gt;Part 2 - Search&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;&#x2F;li&gt;
&lt;li&gt;continued Swedish language studies - 172 days in a row&lt;&#x2F;li&gt;
&lt;li&gt;sent out 13 applications&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Things that I didn&#x27;t get to work on much:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;rust&lt;&#x2F;li&gt;
&lt;li&gt;gitea server&lt;&#x2F;li&gt;
&lt;li&gt;nixos setup&lt;&#x2F;li&gt;
&lt;li&gt;rockpro64 setup&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>Deploying a Zola static site to a custom domain with Github Actions</title>
          <pubDate>Sat, 24 May 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/deploying-static-site-zola-github-actions/</link>
          <guid>https://jakegoldsborough.com/blog/2025/deploying-static-site-zola-github-actions/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/deploying-static-site-zola-github-actions/">&lt;p&gt;As I&#x27;ve been searching for a new gig, I&#x27;ve gotten the urge to write a bit more
about some of the stuff I&#x27;m up to to help make myself stand out. I really like
using the fewest tools as needed so I knew a static site generator was what I
wanted. I also like to use Rust based tools when possible.&lt;&#x2F;p&gt;
&lt;p&gt;Searching for Rust static site generators lead me to
&lt;a href=&quot;https:&#x2F;&#x2F;www.getzola.org&#x2F;&quot;&gt;Zola&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Some of the top advertised features are no dependencies, blazingly fast,
and easy to use. Those sound great to me.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;set-up-zola-site&quot;&gt;Set up Zola site&lt;&#x2F;h3&gt;
&lt;p&gt;While this isn&#x27;t a few blown Zola tutorial, I did want to include a few things.&lt;&#x2F;p&gt;
&lt;p&gt;After installing Zola, you can simply run &lt;code&gt;zola init myblog&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;You&#x27;ll be asked a few questions and a base site will be setup for you.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;├── config.toml
├── content
├── sass
├── static
├── templates
└── themes
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Without going too deep, your CommonMark pages will go in &lt;code&gt;content&lt;&#x2F;code&gt;, Tera&#x2F;HTML
templates in &lt;code&gt;templates&lt;&#x2F;code&gt;, and any css&#x2F;js&#x2F;images or other static content will go
in &lt;code&gt;static&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;SASS is enabled by default but can be disabled. I am not currently using SASS
personally.&lt;&#x2F;p&gt;
&lt;p&gt;Running &lt;code&gt;zola build&lt;&#x2F;code&gt; will build the site and output it a directory called &lt;code&gt;public&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Here is a link to a complete overview:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;www.getzola.org&#x2F;documentation&#x2F;getting-started&#x2F;overview&#x2F;&quot;&gt;https:&#x2F;&#x2F;www.getzola.org&#x2F;documentation&#x2F;getting-started&#x2F;overview&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;h3 id=&quot;push-to-github-create-github-actions-workflow&quot;&gt;Push to Github &amp;amp; Create Github Actions workflow&lt;&#x2F;h3&gt;
&lt;p&gt;After getting your content written, site styled, and ready for deployment, it&#x27;s time to
push to Github. Create a repo and push it to a &lt;code&gt;main&lt;&#x2F;code&gt; branch (exclude the &lt;code&gt;public&lt;&#x2F;code&gt;
directory).&lt;&#x2F;p&gt;
&lt;p&gt;Next, we will setup the actual workflow to take our input files, setup zola,
build the site, and commit it to the correct branch.&lt;&#x2F;p&gt;
&lt;p&gt;Create a file at &lt;code&gt;.github&#x2F;workflows&#x2F;deploy.yml&lt;&#x2F;code&gt; and insert this:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;name: Deploy Zola to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  build-deploy:
    runs-on: ubuntu-latest

    steps:
    - name: Check out source
      uses: actions&amp;#x2F;checkout@v4

    - name: Install Zola
      run: |
        curl -L https:&amp;#x2F;&amp;#x2F;github.com&amp;#x2F;getzola&amp;#x2F;zola&amp;#x2F;releases&amp;#x2F;download&amp;#x2F;v0.20.0&amp;#x2F;zola-v0.20.0-x86_64-unknown-linux-gnu.tar.gz -o zola.tar.gz
        tar -xzf zola.tar.gz
        sudo mv zola &amp;#x2F;usr&amp;#x2F;local&amp;#x2F;bin&amp;#x2F;

    - name: Build site
      run: zola build

    - name: Deploy to GitHub Pages
      uses: peaceiris&amp;#x2F;actions-gh-pages@v3
      with:
        github_token: ${{ secrets.GITHUB_TOKEN }}
        publish_dir: .&amp;#x2F;public
        publish_branch: gh-pages
        force_orphan: true
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;This setups up a workflow that:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;watches for pushes to main branch&lt;&#x2F;li&gt;
&lt;li&gt;checks out the zola source and installs it&lt;&#x2F;li&gt;
&lt;li&gt;builds your site using zola build&lt;&#x2F;li&gt;
&lt;li&gt;uses github pages plugin to push &lt;code&gt;public&lt;&#x2F;code&gt; output dir to a &lt;code&gt;gh-pages&lt;&#x2F;code&gt; branch&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;You will also need to go to your repos settings and look for the actions section
to enable write permissions.&lt;&#x2F;p&gt;
&lt;p&gt;Push this up and watch the build by going to your repo and clicking the &quot;Actions&quot;
tab. You will see a workflow that you can click into and see build and deploy
jobs.&lt;&#x2F;p&gt;
&lt;p&gt;If everything goes well, you can now  visit your site by filling in your values:&lt;&#x2F;p&gt;
&lt;p&gt;https:&#x2F;&#x2F;${username}.github.io&#x2F;${repo}&lt;&#x2F;p&gt;
&lt;h3 id=&quot;add-custom-domain-optional&quot;&gt;Add Custom Domain (optional)&lt;&#x2F;h3&gt;
&lt;p&gt;After you have the initial workflow working, you will need to configure some
Github settings for a custom domain.&lt;&#x2F;p&gt;
&lt;p&gt;First, run &lt;code&gt;echo &quot;yourdomain.com&quot; &amp;gt; static&#x2F;CNAME&lt;&#x2F;code&gt; and commit this file.
Next, go to your Github repo&#x27;s &quot;Pages&quot; setting and add the domain name.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;configure-dns-optional&quot;&gt;Configure DNS (optional)&lt;&#x2F;h3&gt;
&lt;p&gt;If using a custom domain, you will also need to setup DNS. Go to your domain
settings and add 4 A records for Github pages:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;A	@	185.199.108.153
A	@	185.199.109.153
A	@	185.199.110.153
A	@	185.199.111.153
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Setup any subdirectories you might want.&lt;&#x2F;p&gt;
&lt;p&gt;You may need to give this step some extra time to update before you can
see your new site at your custom URL. Other times it&#x27;s nearly instant so your
results may vary.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;profit&quot;&gt;Profit&lt;&#x2F;h3&gt;
&lt;p&gt;That&#x27;s basically it for a basic setup. You should now have a static site
setup at a custom domain that gets automatically built by just pushing a branch.&lt;&#x2F;p&gt;
&lt;p&gt;While simple, this is a complete setup that let&#x27;s you create and deploy content
to your own URL with ease.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Weekly Summary - 21&#x2F;52</title>
          <pubDate>Fri, 23 May 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-21/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-21/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-21/">&lt;p&gt;This has been a pretty productive week.&lt;&#x2F;p&gt;
&lt;p&gt;Things that were good:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;RockPro64 boots again! Blog post coming...&lt;&#x2F;li&gt;
&lt;li&gt;continued Swedish language studies - 165 days&lt;&#x2F;li&gt;
&lt;li&gt;started work on some API inspection. Another blog post coming...&lt;&#x2F;li&gt;
&lt;li&gt;figured out nixos&#x2F;hyprland boot issue&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Things that could be better:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;didn&#x27;t work on much rust&lt;&#x2F;li&gt;
&lt;li&gt;can&#x27;t get gitea ssh setup on VPS&lt;&#x2F;li&gt;
&lt;li&gt;broke wood router part so had to buy new one to work on utensil rest&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</description>
      </item>
      <item>
          <title>NixOS as a daily driver on a late 2011 Macbook Pro, part 1&#x2F;? - What and why?</title>
          <pubDate>Sun, 18 May 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-1/</link>
          <guid>https://jakegoldsborough.com/blog/2025/nixos-daily-driver-1/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/nixos-daily-driver-1/">&lt;p&gt;First, it&#x27;s worth explaining what NixOS is and why I or anyone would want to
run it as a daily driver.&lt;&#x2F;p&gt;
&lt;p&gt;But even before diving into NixOS, we have to talk about Nix.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nix&quot;&gt;Nix&lt;&#x2F;h3&gt;
&lt;p&gt;Nix is a package manager that is purely functional and creates reproducible
builds specified in the Nix Expression Language. Nix expressions are functions
that take dependencies as arguments which creates a &lt;em&gt;derivation&lt;&#x2F;em&gt; that specifies
a reproducible build environment. Nix then stores the results of the build at
unique address specified by a hash of the complete dependency tree. This
is known as the Nix store and it&#x27;s immutable which allows atomic upgrades,
rollbacks, and simultaneous installations of packages with different versions.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;nixos&quot;&gt;NixOS&lt;&#x2F;h3&gt;
&lt;p&gt;NixOS is an operating system that is built on top of Nix and the idea of purely
functional package management. Packages are never overwritten once built. If
you change the Nix expression for a package, it will be rebuilt, and stored
under a new address with a new hash, preventing interference with an old version.&lt;&#x2F;p&gt;
&lt;p&gt;NixOS takes this a step further and applies this to configuration. By building
your entire system from a Nix expression, NixOS ensures that your old
configuration is never overwritten which allows for easy rollbacks. One big
caveat of this is the elimination of &quot;global&quot; directores such as &lt;code&gt;&#x2F;bin&lt;&#x2F;code&gt;,
&lt;code&gt;&#x2F;lib&lt;&#x2F;code&gt;, &lt;code&gt;&#x2F;usr&lt;&#x2F;code&gt;, etc. All packages are kept in &lt;code&gt;&#x2F;nix&#x2F;store&lt;&#x2F;code&gt; under a hashed
address. (One exception is a symlink &lt;code&gt;&#x2F;bin&#x2F;sh&lt;&#x2F;code&gt; to Bash in the Nix store). There
is a &lt;code&gt;&#x2F;etc&lt;&#x2F;code&gt; for system-wide config but many of those files are symlinks to files
in the Nix store.&lt;&#x2F;p&gt;
&lt;p&gt;Everything in NixOS is built by the Nix package manager. This includes the
kernel, applications, system packages, and configuration.&lt;&#x2F;p&gt;
&lt;p&gt;To configure NixOS, you have a file at &lt;code&gt;&#x2F;etc&#x2F;nixos&#x2F;configuration.nix&lt;&#x2F;code&gt;.
You will setup everything from your boot devices to what services you want
to run.&lt;&#x2F;p&gt;
&lt;p&gt;Here is a minimal config that enables sshd:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code&gt;{
  boot.loader.grub.device = &amp;quot;&amp;#x2F;dev&amp;#x2F;sda&amp;quot;;
  fileSystems.&amp;quot;&amp;#x2F;&amp;quot;.device = &amp;quot;&amp;#x2F;dev&amp;#x2F;sda1&amp;quot;;
  services.sshd.enable = true;
}
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;More on this later though.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;why&quot;&gt;Why?&lt;&#x2F;h3&gt;
&lt;p&gt;Now the whys. I think many of them speak for themselves but here are mine:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Rollbacks&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;This one is very big especially when learning about a new, drastically different
OS like Nix. Because old config is never overwritten, you can easily cause a
breaking change without being worried about how to fix it (unless maybe it&#x27;s
bootloader related). In fact, old configs are listed in the boot menu.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Reproducible Build Configurations&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Kind of like a rollback but starting from scratch. You can take the
&lt;code&gt;configuration.nix&lt;&#x2F;code&gt; file, copy to another machine, rebuild, and you will have
the same applications, services, and configuration as before.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;Ad-hoc shell environments&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;In a &quot;Nix shell&quot;, you can use any program that is packaged with Nix without
needing to install permanently.&lt;&#x2F;p&gt;
&lt;p&gt;For example, you can run &lt;code&gt;nix-shell -p git neovim node&lt;&#x2F;code&gt; and you will be dropped
into a shell with those applications installed. This may take some time
depending on the applications installed.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;strong&gt;It&#x27;s a new way to think&lt;&#x2F;strong&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Honestly, I just like trying new stuff, especially when it&#x27;s done in a new way.
Linux has mostly been the same for a long time now, so it&#x27;s refreshing to see
a new way of doing it that also improves on an already solid OS. I also
have really fallen in love with DevOps&#x2F;IaC type of work, and NixOS definitely
scratches that itch.&lt;&#x2F;p&gt;
&lt;h3 id=&quot;next-time&quot;&gt;Next time&lt;&#x2F;h3&gt;
&lt;p&gt;In the next post, I will go over how to install NixOS and maybe a bit of the
configuration.&lt;&#x2F;p&gt;
</description>
      </item>
      <item>
          <title>Weekly Summary - 20&#x2F;52</title>
          <pubDate>Fri, 16 May 2025 00:00:00 +0000</pubDate>
          <author>Unknown</author>
          <link>https://jakegoldsborough.com/blog/2025/weekly-summary-20/</link>
          <guid>https://jakegoldsborough.com/blog/2025/weekly-summary-20/</guid>
          <description xml:base="https://jakegoldsborough.com/blog/2025/weekly-summary-20/">&lt;p&gt;I am starting a weekly summary&#x2F;reflection series that will be a high level
view of things I have worked on or fixed or just things I want to track or note.&lt;&#x2F;p&gt;
&lt;p&gt;Things I enjoyed:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;deployed this site&lt;&#x2F;li&gt;
&lt;li&gt;got an old raspberry pi running again&lt;&#x2F;li&gt;
&lt;li&gt;started back into some rust learnings with blockchain, TUI, and MQTT hacking
&lt;sup class=&quot;footnote-reference&quot;&gt;&lt;a href=&quot;#1&quot;&gt;1&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt; &lt;sup class=&quot;footnote-reference&quot;&gt;&lt;a href=&quot;#2&quot;&gt;2&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt; &lt;sup class=&quot;footnote-reference&quot;&gt;&lt;a href=&quot;#3&quot;&gt;3&lt;&#x2F;a&gt;&lt;&#x2F;sup&gt;&lt;&#x2F;li&gt;
&lt;li&gt;started working on a walnut utensil rest&lt;&#x2F;li&gt;
&lt;li&gt;applied to 10 jobs&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;Things I did not enjoy:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;seemingly bricked my RockPro64 as it won&#x27;t boot past a black screen. ordered
an adapater to help debug at a lower level.&lt;&#x2F;li&gt;
&lt;li&gt;rejected from multiple jobs&lt;&#x2F;li&gt;
&lt;li&gt;felt very confused by some of the rust learnings. makes sense in pieces but
hard to see as the whole picture just yet&lt;&#x2F;li&gt;
&lt;li&gt;confirmed old fitbit can&#x27;t be flashed with any open source&#x2F;non-fitbit software&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;div class=&quot;footnote-definition&quot; id=&quot;1&quot;&gt;&lt;sup class=&quot;footnote-definition-label&quot;&gt;1&lt;&#x2F;sup&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;ratatui.rs&#x2F;concepts&#x2F;application-patterns&#x2F;the-elm-architecture&#x2F;&quot;&gt;https:&#x2F;&#x2F;ratatui.rs&#x2F;concepts&#x2F;application-patterns&#x2F;the-elm-architecture&#x2F;&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;div class=&quot;footnote-definition&quot; id=&quot;2&quot;&gt;&lt;sup class=&quot;footnote-definition-label&quot;&gt;2&lt;&#x2F;sup&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;veeso&#x2F;tui-realm&#x2F;tree&#x2F;main&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;veeso&#x2F;tui-realm&#x2F;tree&#x2F;main&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
&lt;div class=&quot;footnote-definition&quot; id=&quot;3&quot;&gt;&lt;sup class=&quot;footnote-definition-label&quot;&gt;3&lt;&#x2F;sup&gt;
&lt;p&gt;&lt;a href=&quot;https:&#x2F;&#x2F;github.com&#x2F;bytebeamio&#x2F;rumqtt&quot;&gt;https:&#x2F;&#x2F;github.com&#x2F;bytebeamio&#x2F;rumqtt&lt;&#x2F;a&gt;&lt;&#x2F;p&gt;
&lt;&#x2F;div&gt;
</description>
      </item>
    </channel>
</rss>
