<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Ramblings of an aging IT geek</title>
  <id>https://blog.i0.pm</id>
  <updated>2026-08-01T00:00:00Z</updated>
  <link href="https://blog.i0.pm" rel="alternate"></link>
  <link href="https://blog.i0.pm/atom.xml" rel="self" type="application/atom+xml"></link>
  <link href="https://pubsubhubbub.appspot.com/" rel="hub"></link>
  <author>
    <name>John Mylchreest</name>
  </author>
  <entry>
    <title>Decisions, Weighted</title>
    <id>https://blog.i0.pm/posts/decisions-weighted/</id>
    <link href="https://blog.i0.pm/posts/decisions-weighted/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-decisions-weighted-df23ddaa22d6.wav" rel="enclosure" type="audio/wav" length="10007314"></link>
    <updated>2026-08-01T00:00:00Z</updated>
    <published>2026-08-01T00:00:00Z</published>
    <summary type="text">A blueprint for working in existing codebases needed a way to outrank modern best practice, so aide decisions now carry a precedence weight.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;aide&lt;/a&gt; keeps a project&#39;s architectural decisions in the repo and injects them into every session, so agents are taught the rules rather than guessing at them. You can think of the decision store as the project&#39;s rule book: the conventions, constraints and background knowledge that need to be known and followed, written down once instead of re-explained every time somebody opens a session.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;/posts/aide-blueprints/&#34;&gt;Blueprints&lt;/a&gt; are groupings of those decisions that align to some body of guidance or best practice, so you can onboard a new project in one command rather than typing out the same opinions about Go that you type out every time. &lt;a href=&#34;/posts/decisions-inherited/&#34;&gt;Estates&lt;/a&gt; are the other half of it, decisions travelling between related projects, usually from a parent repo down into the subprojects it contains. All of it is about getting the right information in front of the model so it makes sensible choices and doesn&#39;t invent its own.&lt;/p&gt;&#xA;&lt;p&gt;Until recently I never had any need for priorities between decisions. The latest revision for a given topic wins, and I think that is still the right model. What changed is that I added a blueprint for working in existing projects, honouring the conventions that are already there, and that immediately raised a problem: a language blueprint full of modern best practice will quite happily override the norms of a codebase that has been doing it another way for years. This release fixes that, along with a few other things I&#39;d been meaning to get to.&lt;/p&gt;&#xA;&lt;h2 id=&#34;precedence-and-one-threshold&#34;&gt;Precedence, and One Threshold&lt;/h2&gt;&#xA;&lt;p&gt;Decisions now carry an integer precedence, where higher gets injected earlier. For most of them that is all it does, it&#39;s a sort key and nothing more.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide decision set house-style &amp;#34;Follow the repo&amp;#34; --precedence=100&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The exception is 100. At or above that threshold a decision comes out of the ordinary set altogether and gets rendered in a block of its own, ahead of everything else, with the relationship spelled out rather than left to inference:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-markdown&#34;&gt;## Overriding Decisions&#xA;&#xA;These take precedence over every decision below and over general best practice.&#xA;Where they conflict with anything else, these win:&#xA;&#xA;- **existing-codebase-precedence**: The repository is the authority…&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;So something at 200 outranks something at 100 inside that block, while something at 80 stays in the ordinary set, sorted above the defaults but not claiming anything over them.&lt;/p&gt;&#xA;&lt;div class=&#34;table-scroll&#34; tabindex=&#34;0&#34;&gt;&#xA;&lt;table&gt;&#xA;&lt;thead&gt;&#xA;&lt;tr&gt;&#xA;&lt;th&gt;Precedence&lt;/th&gt;&#xA;&lt;th&gt;Renders in&lt;/th&gt;&#xA;&lt;th&gt;Claims&lt;/th&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/thead&gt;&#xA;&lt;tbody&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;code&gt;&amp;gt;= 100&lt;/code&gt;&lt;/td&gt;&#xA;&lt;td&gt;&lt;code&gt;## Overriding Decisions&lt;/code&gt;&lt;/td&gt;&#xA;&lt;td&gt;&amp;quot;where this conflicts with anything below, this wins&amp;quot;&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;code&gt;1&lt;/code&gt;–&lt;code&gt;99&lt;/code&gt;&lt;/td&gt;&#xA;&lt;td&gt;&lt;code&gt;## Project Decisions&lt;/code&gt;, above the defaults&lt;/td&gt;&#xA;&lt;td&gt;nothing&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;code&gt;0&lt;/code&gt; (default)&lt;/td&gt;&#xA;&lt;td&gt;&lt;code&gt;## Project Decisions&lt;/code&gt;&lt;/td&gt;&#xA;&lt;td&gt;nothing&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;negative&lt;/td&gt;&#xA;&lt;td&gt;&lt;code&gt;## Project Decisions&lt;/code&gt;, last&lt;/td&gt;&#xA;&lt;td&gt;nothing&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;The explicit header on that block is what makes the override clear, rather than leaving it to be inferred from where a decision happens to appear in the list.&lt;/p&gt;&#xA;&lt;p&gt;The weight itself is plumbed through the proto field, the Bolt store, the gRPC server, the CLI, share export and import, blueprints, the web API and its badge, and both session-start and subagent injection, so a decision that overrides in your session overrides in a subagent&#39;s too.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;mermaid&#34;&gt;flowchart TD&#xA;    L[local store] --&amp;gt; S{precedence}&#xA;    P[parent estate] --&amp;gt; S&#xA;    R[peer rings] --&amp;gt; S&#xA;    S --&amp;gt;|100 and above| O[&amp;#34;## Overriding Decisions&amp;#34;]&#xA;    S --&amp;gt;|below 100| D[&amp;#34;## Project Decisions&amp;#34;]&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;One useful property of the design is that an omitted precedence inherits whatever the current revision carries:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-go&#34;&gt;func ResolvePrecedence(g DecisionGetter, topic string, requested *int) int {&#xA;&#x9;if requested != nil {&#xA;&#x9;&#x9;return *requested&#xA;&#x9;}&#xA;&#x9;if prev, err := g.GetDecision(topic); err == nil &amp;amp;&amp;amp; prev != nil {&#xA;&#x9;&#x9;return prev.Precedence&#xA;&#x9;}&#xA;&#x9;return memory.PrecedenceDefault&#xA;}&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Decision writes are revisions rather than mutations, so without that, rewording a guardrail from the CLI would write a new revision at the default weight and silently demote a 100 down to a 0. An explicit value still wins, including an explicit &lt;code&gt;0&lt;/code&gt;, so you can demote something deliberately if you want to. All three write paths go through the same resolver, which is the only reason I trust it.&lt;/p&gt;&#xA;&lt;p&gt;I also fixed an existing bug I hadn&#39;t found until now. &lt;code&gt;sessionFetchContext&lt;/code&gt; was ranging a map:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-go&#34;&gt;// Ranging a map made this block reorder on every session, which is&#xA;// noise in agent context and defeats prompt caching on the prefix.&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Go randomises map iteration, so the injected block was coming out in a different order every session despite the content being identical, which is noise in the context and leaves the prompt cache without a stable prefix to reuse. It&#39;s now topic-sorted within each precedence band with the ring order preserved inside a weight, so local decisions still come before inherited ones, and three consecutive runs come out byte-identical.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-blueprint-that-started-it&#34;&gt;The Blueprint That Started It&lt;/h2&gt;&#xA;&lt;p&gt;&lt;code&gt;existing-software-project&lt;/code&gt; is the blueprint that prompted all of the above, and the only one that ships above the threshold. It&#39;s a bundle of decisions about working in a codebase you didn&#39;t write, all at &lt;code&gt;default_precedence: 100&lt;/code&gt;, and unlike the language blueprints it includes nothing of its own, since it&#39;s meant to sit alongside whatever language blueprints you already have:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide blueprint import existing-software-project&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The &lt;code&gt;existing-*&lt;/code&gt; decisions are there to honour a project&#39;s existing style, rules and process: read the surrounding code before editing it, use the committed toolchain, treat linter config as binding, keep the diff to what the task needs. An explicit instruction in the current task still outranks all of it and general best practice comes last, so the bundle won&#39;t impose something like Conventional Commits on a repository whose history has never used them.&lt;/p&gt;&#xA;&lt;h2 id=&#34;and-some-python&#34;&gt;And Some Python&lt;/h2&gt;&#xA;&lt;p&gt;I&#39;ve been working on more Python projects recently, so those decisions are now a blueprint too: &lt;code&gt;python&lt;/code&gt;, with &lt;code&gt;python-django&lt;/code&gt;, &lt;code&gt;python-api&lt;/code&gt; and &lt;code&gt;python-github-actions&lt;/code&gt; on top. Detection also picks up &lt;code&gt;requirements.txt&lt;/code&gt; and &lt;code&gt;uv.lock&lt;/code&gt; for Python, and &lt;code&gt;manage.py&lt;/code&gt; for Django.&lt;/p&gt;&#xA;&lt;p&gt;The &lt;a href=&#34;https://github.com/jmylchreest/aide/blob/main/docs/docs/features/blueprints.md&#34;&gt;blueprints documentation&lt;/a&gt; has the per-decision breakdowns for each of the new bundles if you want the detail.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Decisions, Inherited</title>
    <id>https://blog.i0.pm/posts/decisions-inherited/</id>
    <link href="https://blog.i0.pm/posts/decisions-inherited/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-decisions-inherited-30d3a26bd828.wav" rel="enclosure" type="audio/wav" length="11878514"></link>
    <updated>2026-07-21T00:00:00Z</updated>
    <published>2026-07-21T00:00:00Z</published>
    <summary type="text">aide decisions now travel. Subprojects inherit ADRs from their parent superrepo, and teams can subscribe to each other&#39;s decisions over plain git.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;aide&lt;/a&gt; records the architectural decisions a project has made and injects them into every session. That works right up until your project &lt;em&gt;contains&lt;/em&gt; other projects: a superrepo full of submodules is really one estate with one set of opinions, but each subproject&#39;s store had no idea the others existed. As of v0.1.8, decisions travel: down from ancestors, and sideways between teams.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-estate&#34;&gt;The Estate&lt;/h2&gt;&#xA;&lt;p&gt;aide now works out where it is (and what sits above it) from VCS evidence, with exactly one resolver:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide anchor --json&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Everything else reads that one answer, which also fixed a family of bugs where a session opened at the superrepo wrote into the wrong project&#39;s store. Sorry about those. Session context gains an &lt;strong&gt;Estate&lt;/strong&gt; section (parents above, subprojects below), and &lt;a href=&#34;/posts/aide-web-dashboard/&#34;&gt;aide-web&lt;/a&gt; draws the whole tree.&lt;/p&gt;&#xA;&lt;h2 id=&#34;inherit-from-ancestors&#34;&gt;Inherit from Ancestors&lt;/h2&gt;&#xA;&lt;p&gt;Nothing to configure. At session start aide walks up the chain and overlays each ancestor&#39;s decisions onto your context, labelled with where they came from. A worked example: parent repo &lt;code&gt;tl&lt;/code&gt; decides &lt;code&gt;fav-colour = orange&lt;/code&gt; and &lt;code&gt;team-editor = vim&lt;/code&gt;; the nested repo &lt;code&gt;tl/webshop&lt;/code&gt; has its own store with &lt;code&gt;api-style = REST&lt;/code&gt; and &lt;code&gt;fav-colour = blue&lt;/code&gt;. A session inside webshop sees:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-text&#34;&gt;api-style:   REST   [local]&#xA;fav-colour:  blue   [local]        &amp;lt;- shadows the parent&amp;#39;s orange&#xA;team-editor: vim    [parent: tl]   &amp;lt;- inherited&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Shadowing is by topic and nearest wins: webshop&#39;s blue means the parent&#39;s orange is never even looked at, and the parent is none the wiser. Nothing is copied between stores; provenance is synthesised at read time, so renaming or re-nesting a project can&#39;t corrupt anything. It isn&#39;t just plumbing, either: ask a headless Claude in each directory and the one in webshop explains that fav-colour is blue, decided locally, overriding the parent estate; the one in &lt;code&gt;tl&lt;/code&gt; still answers orange. The labels carry the semantics, not just the values.&lt;/p&gt;&#xA;&lt;p&gt;To override a parent, then, you just set your own (theirs stays intact upstream):&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide decision set fav-colour &amp;#34;blue&amp;#34;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;To record a decision &lt;em&gt;at&lt;/em&gt; the estate level instead, aim the write upwards:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide --store parent decision set logging &amp;#34;slog, structured&amp;#34;   # nearest container&#xA;aide --store top decision set go-version &amp;#34;1.26&amp;#34;               # the estate root&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;And &lt;code&gt;AIDE_CASCADE_DISABLED=1&lt;/code&gt; switches the whole cascade off.&lt;/p&gt;&#xA;&lt;h2 id=&#34;subscribe-to-peers&#34;&gt;Subscribe to Peers&lt;/h2&gt;&#xA;&lt;p&gt;For teams that don&#39;t share a filesystem, subscriptions run over plain git. Name your sources in &lt;code&gt;.aide/config/aide.json&lt;/code&gt;:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-json&#34;&gt;{ &amp;#34;subscriptions&amp;#34;: [&#xA;    { &amp;#34;name&amp;#34;: &amp;#34;platform-team&amp;#34;, &amp;#34;url&amp;#34;: &amp;#34;git@host:platform/context.git&amp;#34;, &amp;#34;branch&amp;#34;: &amp;#34;main&amp;#34; },&#xA;    { &amp;#34;name&amp;#34;: &amp;#34;proto-repo&amp;#34;,    &amp;#34;path&amp;#34;: &amp;#34;../protos&amp;#34; }&#xA;] }&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That&#39;s the setup done: the next session start fetches them, and peer decisions appear in your context as a read-only layer labelled &lt;code&gt;from peer platform-team&lt;/code&gt;. Where you &lt;em&gt;override&lt;/em&gt; a parent, you &lt;em&gt;adopt&lt;/em&gt; a peer:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide decision adopt api-style --from=platform-team&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;which copies it into your store with adoption provenance. Add &lt;code&gt;&amp;quot;publish&amp;quot;: true&lt;/code&gt; to a subscription and the loop runs both ways: your own decisions go back out for others to subscribe to. Decisions only, mind; memories never cross a project boundary.&lt;/p&gt;&#xA;&lt;h2 id=&#34;why-not-just-import&#34;&gt;Why Not Just Import?&lt;/h2&gt;&#xA;&lt;p&gt;aide already had two ways to get decisions into a store from outside: &lt;a href=&#34;/posts/aide-blueprints/&#34;&gt;blueprints&lt;/a&gt; (curated best-practice defaults you import when bootstrapping a project) and &lt;code&gt;aide share import&lt;/code&gt; (a point-in-time merge from a directory a teammate exported). Both are copies, and both are exactly right for what they do: a blueprint is a template, and once imported the decision is genuinely &lt;em&gt;yours&lt;/em&gt; to keep or overturn; a share import is one team converging one project&#39;s stores.&lt;/p&gt;&#xA;&lt;p&gt;Copies are the wrong tool for a living ADR owned by somebody else, though. The platform team&#39;s &lt;code&gt;api-style&lt;/code&gt; decision is theirs; they will re-decide it, and a copy you took in March says nothing about what they think in July. Copies rot, silently, and worse, they lie with confidence. A subscription is a &lt;em&gt;reference&lt;/em&gt;: you always read the owning team&#39;s current word, it arrives labelled with where it came from, and it never leaks back out of your store (you can&#39;t accidentally re-publish somebody else&#39;s ADR as your own). Adoption is the deliberate act that turns a reference into a copy, with a stamp saying so. Ownership stays where the decision lives; that&#39;s the entire justification, and it&#39;s the same one that makes the ancestor cascade read at session start rather than syncing records downward.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-session-is-the-scheduler&#34;&gt;The Session Is the Scheduler&lt;/h2&gt;&#xA;&lt;p&gt;My first instinct for the publishing side was a systemd timer, and it was wrong. Nothing here needs a scheduler, because aide already has one: the session lifecycle.&lt;/p&gt;&#xA;&lt;p&gt;Decisions are only ever made &lt;em&gt;inside&lt;/em&gt; sessions. So session &lt;strong&gt;start&lt;/strong&gt; pulls (any subscription cache older than an hour is refreshed, quietly, offline-silent) and session &lt;strong&gt;end&lt;/strong&gt; pushes (publish-enabled subscriptions ship whatever the session decided, bounded to a few seconds, silent if the network is away; unpublished records simply go out at the next session end). The event that creates the record is the event that ships it. If you find yourself writing a cron job for this, that&#39;s a bug report.&lt;/p&gt;&#xA;&lt;p&gt;The other cases need even less. Inside a superrepo there&#39;s nothing to sync at all: the cascade reads the ancestor&#39;s store directly at session start, same disk, always current. And a team sharing one repo already has its sync mechanism: the context records are files that commit with the repo, so &lt;code&gt;git pull&lt;/code&gt; is the import, &lt;code&gt;git push&lt;/code&gt; is the publish, and a pull request is the review process. Teams that want ADR changes reviewed before they propagate can publish through a PR into the context repo instead of enabling the automatic path; subscribers can&#39;t tell the difference. &lt;code&gt;aide sync&lt;/code&gt; survives as the manual lever, and it exits non-zero when a subscription fails, for the CI-inclined who want a red light rather than silence.&lt;/p&gt;&#xA;&lt;p&gt;Precedence in one line: local beats nearest ancestor, ancestors beat peers. Your project gets the last word; it just no longer starts the conversation ignorant.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>rosec 0.0.33: The Security Key Signs In</title>
    <id>https://blog.i0.pm/posts/rosec-0-0-33/</id>
    <link href="https://blog.i0.pm/posts/rosec-0-0-33/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-0-0-33-0348eaac5815.wav" rel="enclosure" type="audio/wav" length="6363818"></link>
    <updated>2026-07-09T00:00:00Z</updated>
    <published>2026-07-09T00:00:00Z</published>
    <summary type="text">rosec 0.0.33 lands WebAuthn end to end: register and sign in with passkeys from an unmodified browser, plus a rotatable plugin trust root and a hardening pass.</summary>
    <content type="html">&lt;p&gt;This is a pretty chunky release for &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt;, big enough that I genuinely flirted with calling it 0.1.0 before coming to my senses.&lt;/p&gt;&#xA;&lt;p&gt;The star of the show is something I&#39;ve been waiting for: WebAuthn support. An unmodified browser can now register a passkey with rosec and log in with it, full ceremony, no extensions, browser ↔ vault and back. Passkeys, end-to-end.&lt;/p&gt;&#xA;&lt;p&gt;A passkey is really just a private key with some metadata, and since rosec&#39;s providers already know how to look after private keys, it was mostly a matter of exposing them properly. The local vault, Bitwarden items, and KeePassXC databases all feed into the same model now. rosec only hands the key over at the exact moment of signing, then zeroizes it immediately. That&#39;s the &amp;quot;don&#39;t lose it&amp;quot; part handled.&lt;/p&gt;&#xA;&lt;p&gt;The tricky half was the &amp;quot;make the browser happy&amp;quot; part. Browsers talk CTAP2 to actual security keys, not random files, so FUSE was never going to cut it. We had to become a real (virtual) security key. &lt;code&gt;/dev/uhid&lt;/code&gt; lets userspace create one… but it&#39;s root-only, because the same interface could just as easily pretend to be a keyboard and type whatever it likes.&lt;/p&gt;&#xA;&lt;p&gt;Enter the tiny socket-activated broker &lt;code&gt;rosec-uhid&lt;/code&gt;. It runs as root for a few milliseconds, spins up a FIDO-only device from a hard-coded descriptor, hands the file descriptor to your unprivileged daemon, and immediately exits. Close the fd and the device disappears. Crash the daemon? No phantom key left behind. Lock the vault? It&#39;s the digital equivalent of yanking the USB stick. It&#39;s off by default and desktop-only: install the broker and set &lt;code&gt;fido2 = true&lt;/code&gt; if you want it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;trust-you-can-actually-rotate&#34;&gt;Trust you can actually rotate&lt;/h2&gt;&#xA;&lt;p&gt;The other notable change is how we handle trust for provider plugins. They were already signed WASM modules, but the daemon only trusted one hardcoded key. Rotating it was… awkward. Now we have a proper set of trust roots, so key rotation gets a graceful deprecation period instead of a surprise flag day. There&#39;s also a user-configurable trust anchor in &lt;code&gt;rosec.toml&lt;/code&gt;, so third-party authors can ship signed plugins without forcing people to turn off verification entirely.&lt;/p&gt;&#xA;&lt;p&gt;We also added proper tooling: &lt;code&gt;rosec-package-wasm&lt;/code&gt; for authors and &lt;code&gt;rosec provider validate&lt;/code&gt; for users. It even schema-validates the sandbox policy before signing, so any silly mistakes blow up on the author&#39;s machine instead of yours. Considerate, aren&#39;t we?&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-rest&#34;&gt;The rest&lt;/h2&gt;&#xA;&lt;p&gt;We did a full security review on the new code and the wider daemon: the virtual authenticator&#39;s device node is now &lt;code&gt;0600&lt;/code&gt; owned by you (no more leaning on seat &lt;code&gt;uaccess&lt;/code&gt;), passed file descriptors are close-on-exec, the portal backend properly verifies its caller, and we sprinkled in some DoS and zeroization fixes for good measure.&lt;/p&gt;&#xA;&lt;p&gt;The prompt also gained a scrollable single-select mode, very handy when you have multiple passkeys that match the same site.&lt;/p&gt;&#xA;&lt;p&gt;Usual caveat: these are software passkeys, not hardware-bound. So sites that demand hardware attestation will still turn up their noses. That&#39;s the trade-off for keys you can actually back up and move between machines.&lt;/p&gt;&#xA;&lt;p&gt;Repo and docs are on &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>clipferry: An X11 to Wayland Clipboard Bridge</title>
    <id>https://blog.i0.pm/posts/clipferry-an-x11-to-wayland-clipboard-bridge/</id>
    <link href="https://blog.i0.pm/posts/clipferry-an-x11-to-wayland-clipboard-bridge/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-clipferry-an-x11-to-wayland-clipboard-brid-391a636d6f81.wav" rel="enclosure" type="audio/wav" length="6777114"></link>
    <updated>2026-07-04T00:00:00Z</updated>
    <published>2026-07-04T00:00:00Z</published>
    <summary type="text">A small Rust daemon that bridges X11 and Wayland clipboards under Xwayland (xwayland-satellite on niri, builtin on Hyprland). Lazy and without polling.</summary>
    <content type="html">&lt;p&gt;Clipferry solves the problem of X11 ↔ Wayland clipboard sync. Let me explain why I built this.&lt;/p&gt;&#xA;&lt;p&gt;I often use &lt;a href=&#34;https://github.com/YaLTeR/niri&#34;&gt;niri&lt;/a&gt; or &lt;a href=&#34;https://hypr.land&#34;&gt;Hyprland&lt;/a&gt; with Steam. Steam, Proton and Wine are all X11 clients: they reach Wayland via &lt;a href=&#34;https://github.com/Supreeeme/xwayland-satellite&#34;&gt;xwayland-satellite&lt;/a&gt; on niri, and via Hyprland&#39;s builtin Xwayland on Hyprland.&lt;/p&gt;&#xA;&lt;p&gt;Copying inside a Wayland app and pasting into a Wine window works. Copying &lt;em&gt;out&lt;/em&gt; of an X11 app doesn&#39;t: the X11 selection never crosses back. A WoW SimC dump you want in raidbots, a talent or WeakAuras import string bound for Discord, a Path of Building code, a Factorio blueprint, an FFXIV gear set string: all stuck on the X11 side.&lt;/p&gt;&#xA;&lt;p&gt;Recent xwayland-satellite releases have closed some of these gaps, but apps that don&#39;t play the usual focus games, and less-common MIME types, still fall through on both stacks.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://github.com/dnut/clipboard-sync&#34;&gt;dnut/clipboard-sync&lt;/a&gt; hasn&#39;t been touched since 2023 and has a much broader scope than I wanted. Everything else was bash around &lt;code&gt;wl-copy&lt;/code&gt; and &lt;code&gt;xclip&lt;/code&gt;, polling in a loop with content hashes for loop suppression and a pipe cost paid on every copy whether anyone pastes or not. Fine for text; grim for a 30 MB screenshot.&lt;/p&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/clipferry&#34;&gt;&lt;strong&gt;clipferry&lt;/strong&gt;&lt;/a&gt; is a small Rust daemon that speaks both clipboard protocols directly and shuttles selections across on demand.&lt;/p&gt;&#xA;&lt;p&gt;The design is deliberately narrow.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Lazy by default.&lt;/strong&gt; When you copy, clipferry exchanges the list of MIME types with the other side. The bytes themselves only move when someone actually pastes. A 50 MB image copy is a handful of strings until a paste happens, not two full transfers up-front.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;All the MIME types, not just text.&lt;/strong&gt; Images, HTML, &lt;code&gt;text/uri-list&lt;/code&gt;, and a small translation table for the X11-isms (&lt;code&gt;x-special/gnome-copied-files&lt;/code&gt;, a couple of WeChat and Wine quirks), plus INCR in both directions for the awkwardly large ones.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Loop prevention by ownership identity, not hashing.&lt;/strong&gt; clipferry knows which selection claims are its own, so it never chases its own tail. No content hashes, no sleeps, no races.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Sandboxed.&lt;/strong&gt; It applies &lt;a href=&#34;https://landlock.io/&#34;&gt;Landlock&lt;/a&gt; to itself: no filesystem, no TCP. Plus the usual systemd hardening. Clipboard content is never logged, and payload buffers are zeroed when they die.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Small.&lt;/strong&gt; One process, a ~1 MiB binary, under 5 MiB resident.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;By default it runs in &lt;em&gt;backstop&lt;/em&gt; mode: it watches both clipboards passively, and only claims a selection when nothing else has bridged the other side within ~200 ms of a copy. If satellite&#39;s builtin sync handles a case, clipferry stays out of the way. Clipboard history managers (&lt;a href=&#34;https://github.com/sentriz/cliphist&#34;&gt;cliphist&lt;/a&gt;, &lt;a href=&#34;https://github.com/savedra1/clipse&#34;&gt;clipse&lt;/a&gt;) coexist as designed.&lt;/p&gt;&#xA;&lt;p&gt;Install is short:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;# Arch&#xA;paru -S clipferry&#xA;&#xA;# any distro&#xA;cargo install --git https://github.com/jmylchreest/clipferry&#xA;sudo cp contrib/clipferry.service /usr/lib/systemd/user/&#xA;systemctl --user enable --now clipferry.service&#xA;clipferry --oneshot-check    # sanity: both displays reachable?&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;There isn&#39;t much to configure, but there are a handful of flags to deal with edge cases. For most people the defaults will work. The &lt;a href=&#34;https://github.com/jmylchreest/clipferry&#34;&gt;README&lt;/a&gt; documents them.&lt;/p&gt;&#xA;&lt;p&gt;If copy-out-of-X11 has been annoying you, give it a go. Issues and patches welcome on the &lt;a href=&#34;https://github.com/jmylchreest/clipferry&#34;&gt;repo&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Escribí mi propio generador de sitios. ¿Deberías usarlo?</title>
    <id>https://blog.i0.pm/es/posts/i-wrote-my-own-site-generator-should-you-use-it/</id>
    <link href="https://blog.i0.pm/es/posts/i-wrote-my-own-site-generator-should-you-use-it/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/es-posts-i-wrote-my-own-site-generator-should-yo-8f4e2bc4d2cd.wav" rel="enclosure" type="audio/wav" length="20778408"></link>
    <updated>2026-06-28T00:00:00Z</updated>
    <published>2026-06-28T00:00:00Z</published>
    <summary type="text">Una comparación honesta de colophon frente a Hugo, Jekyll, Zola, Eleventy y Astro: en qué destaca cada uno, y cuándo colophon es (y no es) la opción correcta.</summary>
    <content type="html">&lt;p&gt;Construí &lt;a href=&#34;https://github.com/jmylchreest/colophon&#34;&gt;colophon&lt;/a&gt;, el generador con el que funciona este blog, así que toma todo lo que sigue como la opinión de alguien que corrige sus propios deberes. He intentado ceñirme a hechos y características, no al cariño. Donde colophon sea la elección equivocada lo diré, porque fingir lo contrario no ayuda a nadie, y a mí menos que a nadie.&lt;/p&gt;&#xA;&lt;p&gt;La verdad aburrida primero: casi todos los generadores de sitios estáticos hacen el mismo trabajo de fondo. Entra Markdown, sale un montón rápido de HTML estático, y lo alojas en algún sitio barato. Se diferencian en tres cosas que de verdad importan: lo maduro y bien respaldado que está el proyecto, qué viene incluido frente a lo que tienes que añadir tú, y para quién se escribió.&lt;/p&gt;&#xA;&lt;h2 id=&#34;los-consagrados&#34;&gt;Los consagrados&lt;/h2&gt;&#xA;&lt;p&gt;Estas son las herramientas a las que deberías recurrir por defecto.&lt;/p&gt;&#xA;&lt;div class=&#34;table-scroll&#34; tabindex=&#34;0&#34;&gt;&#xA;&lt;table&gt;&#xA;&lt;thead&gt;&#xA;&lt;tr&gt;&#xA;&lt;th&gt;Herramienta&lt;/th&gt;&#xA;&lt;th&gt;Entorno&lt;/th&gt;&#xA;&lt;th&gt;Forma&lt;/th&gt;&#xA;&lt;th&gt;Mejor para&lt;/th&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/thead&gt;&#xA;&lt;tbody&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Hugo&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Go, binario único&lt;/td&gt;&#xA;&lt;td&gt;Enorme, rápido, maduro&lt;/td&gt;&#xA;&lt;td&gt;Casi cualquier blog o sitio: velocidad, un catálogo enorme de temas y extensiones, una década de respuestas en internet&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Jekyll&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Ruby&lt;/td&gt;&#xA;&lt;td&gt;El original, el motor nativo de GitHub Pages&lt;/td&gt;&#xA;&lt;td&gt;Publicar sin configuración directamente desde un repositorio de GitHub&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Zola&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Rust, binario único&lt;/td&gt;&#xA;&lt;td&gt;Con pilas incluidas, sin extensiones por diseño&lt;/td&gt;&#xA;&lt;td&gt;Un binario único y rápido con Sass, búsqueda y resaltado incluidos, si no necesitas extensiones&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Eleventy&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Node&lt;/td&gt;&#xA;&lt;td&gt;Mínimo, infinitamente flexible&lt;/td&gt;&#xA;&lt;td&gt;Gente de JavaScript que quiere control total y diez lenguajes de plantillas para elegir&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Astro&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Node&lt;/td&gt;&#xA;&lt;td&gt;Framework de contenido con islas interactivas&lt;/td&gt;&#xA;&lt;td&gt;Sitios de contenido que además necesitan componentes interactivos de verdad&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;Hugo es la respuesta sensata para la mayoría, y es con lo que &lt;a href=&#34;/posts/rebuilt-the-blog-again/&#34;&gt;funcionaba este blog al principio&lt;/a&gt;. Renderiza un sitio grande en segundos, tiene con diferencia el mayor catálogo de temas, y si te topas con un muro, alguien lo resolvió hace años y lo dejó escrito. Jekyll es más antiguo y más lento en sitios grandes, pero si solo quieres empujar Markdown a GitHub Pages y olvidarte, nada es más simple. Zola es el más parecido a colophon en espíritu: un binario de Rust, cosas sensatas incluidas, y una negativa deliberada a hacer crecer un sistema de extensiones. Eleventy y Astro son el extremo JavaScript de la sala, Eleventy todo flexibilidad y ninguna opinión, Astro un framework de verdad para cuando un blog es en realidad una aplicación disfrazada de blog.&lt;/p&gt;&#xA;&lt;p&gt;Ninguno de ellos incluye de fábrica generación de imágenes con IA, lecturas de audio habladas, webmentions de la IndieWeb ni sindicación POSSE. No es una crítica: es una decisión de alcance, y puedes añadir casi todo con extensiones y un par de servicios externos.&lt;/p&gt;&#xA;&lt;h2 id=&#34;en-qu-se-diferencia-colophon-de-verdad&#34;&gt;En qué se diferencia colophon de verdad&lt;/h2&gt;&#xA;&lt;p&gt;colophon también es un único binario de Go que convierte Markdown en un sitio estático, así que en el trabajo de fondo es de lo más corriente. La diferencia está en lo que integra como funciones de primera clase, las piezas que de otro modo armarías con una extensión por aquí y un servicio por allá:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Imágenes de portada generadas con IA a partir de una referencia &lt;code&gt;gen:&lt;/code&gt;, y lecturas de audio (TTS) habladas de cada entrada, direccionadas por contenido y cacheadas&lt;/li&gt;&#xA;&lt;li&gt;Fontanería de la IndieWeb: microformatos, webmentions y sindicación POSSE a Bluesky y Mastodon, incluidos&lt;/li&gt;&#xA;&lt;li&gt;Un glosario con enlaces de referencia, presentaciones derivadas de la entrada, y un almacén &lt;a href=&#34;https://obsidian.md&#34;&gt;Obsidian&lt;/a&gt; como fuente de primera clase&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;La búsqueda en el propio sitio merece una mención concreta, y una honestidad concreta. La arquitectura no es original: es el modelo que popularizó &lt;a href=&#34;https://github.com/Pagefind/pagefind&#34;&gt;Pagefind&lt;/a&gt;, un índice fragmentado construido a partir de la salida renderizada, donde el navegador solo descarga los fragmentos que una consulta toca de verdad, y las &lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/design/search.md&#34;&gt;notas de diseño&lt;/a&gt; de colophon lo dicen tal cual. Lo distinto es que es nativa de Go y sin dependencias, con su propio formato abierto en lugar de uno de ingeniería inversa, un lector en JavaScript puro y sin WASM, cuando la forma habitual de conseguir esto es invocar la cadena de herramientas en Rust de Pagefind. Vive como un &lt;a href=&#34;https://github.com/jmylchreest/colophon/tree/main/search&#34;&gt;módulo independiente&lt;/a&gt; con una &lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/search/SPEC.md&#34;&gt;especificación neutral respecto al lenguaje&lt;/a&gt; y vectores de prueba dorados, así que indexa cualquier documento &lt;code&gt;{id, url, title, body}&lt;/code&gt; y cualquiera puede escribir un lector conforme en otro lenguaje. Estoy contento con cómo quedó, pero la idea no es mía.&lt;/p&gt;&#xA;&lt;p&gt;Ese paquete es toda la propuesta.&lt;/p&gt;&#xA;&lt;h2 id=&#34;accesibilidad-e-idiomas&#34;&gt;Accesibilidad e idiomas&lt;/h2&gt;&#xA;&lt;p&gt;Dos cosas en las que colophon se esfuerza de verdad, con resultados muy distintos hasta ahora.&lt;/p&gt;&#xA;&lt;p&gt;La accesibilidad se trata como trabajo del motor, no como algo que heredas del tema que te haya tocado elegir. Ha habido un barrido deliberado hacia WCAG AAA: los bloques de código, los diagramas y las matemáticas en bloque son regiones desplazables enfocables con el teclado, todos los controles tienen un indicador de foco visible, el texto atenuado y los enlaces se llevan al ratio de contraste 7:1 que pide AAA, los marcadores de audio y adjuntos llevan etiquetas reales, las ventanas emergentes del glosario son tooltips accesibles de verdad, y todo el sitio sigue funcionando con JavaScript desactivado. Cualquiera de las herramientas consagradas puede hacerse igual de accesible con un tema cuidadoso; la diferencia es quién carga con ese peso. Aquí va integrado en el motor, no se te deja a ti.&lt;/p&gt;&#xA;&lt;p&gt;La internacionalización es la parte que todavía se mueve. Los cimientos llevan tiempo ahí: una etiqueta de idioma por entrada que fluye a &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt;, a los feeds y a Open Graph, además de la interfaz del reproductor y tablas de pronunciación localizadas en seis idiomas. Más reciente, y ahora mismo en pruebas, están las entradas multiidioma de verdad: pones un &lt;code&gt;mi-entrada.es.md&lt;/code&gt; junto a la inglesa y obtienes una versión en español en &lt;code&gt;/es/posts/mi-entrada/&lt;/code&gt;, enlazada por un slug compartido, con alternativas &lt;code&gt;hreflang&lt;/code&gt;, un selector de idioma en la cabecera, y un aviso discreto según la preferencia del navegador que nunca te redirige a la fuerza. Cada traducción es una entrada completa, así que tiene su propia lectura, sus feeds y sus diapositivas. Esta misma página es la prueba: existe una versión en inglés, y el selector de arriba te llevará a ella. Es un experimento con la función, no un cambio de idioma: no voy a empezar a publicar en español, y esta versión es una traducción de la entrada en inglés y no algo escrito por separado, así que léela como una demostración de la fontanería más que como prosa en español. Es pronto, y el soporte multiidioma de Hugo sigue siendo más completo y más probado, pero la base es buena, y es justo el tipo de cosa donde la ayuda sería muy bienvenida.&lt;/p&gt;&#xA;&lt;h2 id=&#34;la-parte-honesta&#34;&gt;La parte honesta&lt;/h2&gt;&#xA;&lt;p&gt;No hay ninguna herramienta popular que iguale a colophon función por función. El nicho de binario único con pilas incluidas lo ocupan Zola y Hugo; el espíritu de posee-tu-contenido y federa-por-defecto lo encarna mejor &lt;a href=&#34;https://micro.blog/&#34;&gt;Micro.blog&lt;/a&gt;, que es algo encantador pero una plataforma alojada y de pago, no un generador que ejecutas tú. La combinación concreta de colophon no existe en ningún otro sitio en una sola caja, y eso corta por los dos lados: es la razón para mirarlo, y la razón para no hacerlo.&lt;/p&gt;&#xA;&lt;p&gt;colophon es un trabajo en curso y ahora mismo soy solo yo, así que las contribuciones son muy bienvenidas. La suposición es que la mayoría interactuará con él a través de un LLM y las habilidades que incluye, porque eso es lo que hace que plasmar tus ideas sea rápido, y espero lo mismo de quien quiera trabajar en él. ¿Has encontrado un fallo? Saca tu LLM preferido y manda un PR. Uso mis propias herramientas de agentes (&lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;aide&lt;/a&gt;), y si tú también las usas heredarás las mismas decisiones capturadas y los mismos estándares de implementación.&lt;/p&gt;&#xA;&lt;p&gt;Para ser justos en el otro sentido: tus &lt;em&gt;palabras&lt;/em&gt; no quedan atrapadas si decides probarlo. La entrada es Markdown plano y la salida es HTML estático corriente, así que mudarte a otra herramienta significa reescribir plantillas y configuración, no rescatar tu contenido. El bloqueo está en las comodidades, no en lo que escribes. Esa es la parte que más me importa, y es cierta para todas las herramientas de aquí.&lt;/p&gt;&#xA;&lt;h2 id=&#34;entonces-cundo-es-colophon-lo-correcto-si-es-que-alguna-vez-lo-es&#34;&gt;Entonces, ¿cuándo es colophon lo correcto, si es que alguna vez lo es?&lt;/h2&gt;&#xA;&lt;p&gt;Elige a uno de los consagrados si quieres un blog que simplemente funcione durante la próxima década. Para la mayoría, casi siempre, eso significa Hugo. Elige Jekyll si vives en GitHub Pages, Astro si necesitas componentes, Eleventy si quieres construirlo a tu manera, y Micro.blog si quieres la mitad de IndieWeb y la publicación cruzada sin ejecutar nada en absoluto. O simplemente, cualquiera de los cientos de herramientas que hacen algo parecido, ¡o constrúyete la tuya!&lt;/p&gt;&#xA;&lt;p&gt;colophon se gana su sitio en un caso concreto: quieres precisamente ese paquete con opinión, imágenes generadas, lecturas habladas, IndieWeb y POSSE, glosario y diapositivas, en un único binario en lugar de cosido a mano.&lt;/p&gt;&#xA;&lt;p&gt;Es especialmente útil si escribes en &lt;a href=&#34;https://obsidian.md&#34;&gt;Obsidian&lt;/a&gt;, y puedes vivir tan tranquilo al borde de un proyecto joven y de un solo mantenedor. Es una razón real. Solo que no es universal, y hoy la persona a la que mejor le encaja soy yo. Lo uso porque quería exactamente estas funciones en un mismo sitio y me conformo con ser mi propio servicio de soporte.&lt;/p&gt;&#xA;&lt;h2 id=&#34;relacionado&#34;&gt;Relacionado&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/a-colophon-blog-on-cloudflare-pages-and-r2/&#34;&gt;Un blog colophon en Cloudflare Pages y R2&lt;/a&gt;: cómo está construido y alojado este blog en realidad.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/wiring-the-blog-into-the-indieweb/&#34;&gt;Conectando el blog a la IndieWeb&lt;/a&gt;: la mitad de federación y POSSE, en la práctica.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/the-post-is-the-slide-deck/&#34;&gt;La entrada es la presentación&lt;/a&gt;: una de las funciones incluidas, presentaciones derivadas de una entrada.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/rebuilt-the-blog-again/&#34;&gt;Reconstruí el blog. Otra vez.&lt;/a&gt;: por qué me fui de Hugo en primer lugar.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</content>
  </entry>
  <entry>
    <title>I wrote my own site generator. Should you use it?</title>
    <id>https://blog.i0.pm/posts/i-wrote-my-own-site-generator-should-you-use-it/</id>
    <link href="https://blog.i0.pm/posts/i-wrote-my-own-site-generator-should-you-use-it/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-i-wrote-my-own-site-generator-should-you-u-b8a70541dc34.wav" rel="enclosure" type="audio/wav" length="16132800"></link>
    <updated>2026-06-28T00:00:00Z</updated>
    <published>2026-06-28T00:00:00Z</published>
    <summary type="text">An honest look at colophon next to Hugo, Jekyll, Zola, Eleventy and Astro: what each is good at, and when colophon is and isn&#39;t the right choice.</summary>
    <content type="html">&lt;p&gt;I built &lt;a href=&#34;https://github.com/jmylchreest/colophon&#34;&gt;colophon&lt;/a&gt;, the generator this blog runs on, so treat everything here as the opinion of a man reviewing his own homework. I&#39;ve tried to keep it to facts and features rather than affection. Where colophon is the wrong choice I&#39;ll say so, because pretending otherwise helps nobody, least of all me.&lt;/p&gt;&#xA;&lt;p&gt;The boring truth first: almost every static site generator does the same core job. Markdown goes in, a fast pile of static HTML comes out, and you host it somewhere cheap. They differ on three things that actually matter: how mature and well-supported the tool is, what comes built in versus what you bolt on, and who it was written for.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-grown-ups&#34;&gt;The grown-ups&lt;/h2&gt;&#xA;&lt;p&gt;These are the tools you should reach for by default.&lt;/p&gt;&#xA;&lt;div class=&#34;table-scroll&#34; tabindex=&#34;0&#34;&gt;&#xA;&lt;table&gt;&#xA;&lt;thead&gt;&#xA;&lt;tr&gt;&#xA;&lt;th&gt;Tool&lt;/th&gt;&#xA;&lt;th&gt;Runtime&lt;/th&gt;&#xA;&lt;th&gt;Shape&lt;/th&gt;&#xA;&lt;th&gt;Best at&lt;/th&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/thead&gt;&#xA;&lt;tbody&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Hugo&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Go, single binary&lt;/td&gt;&#xA;&lt;td&gt;Huge, fast, mature&lt;/td&gt;&#xA;&lt;td&gt;Almost any blog or site: speed, a vast theme and plugin ecosystem, a decade of answers online&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Jekyll&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Ruby&lt;/td&gt;&#xA;&lt;td&gt;The original, GitHub Pages&#39; native engine&lt;/td&gt;&#xA;&lt;td&gt;Zero-config publishing straight from a GitHub repo&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Zola&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Rust, single binary&lt;/td&gt;&#xA;&lt;td&gt;Batteries-ish, no plugins by design&lt;/td&gt;&#xA;&lt;td&gt;A fast single binary with Sass, search and highlighting built in, if you don&#39;t need extensions&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Eleventy&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Node&lt;/td&gt;&#xA;&lt;td&gt;Minimal, endlessly flexible&lt;/td&gt;&#xA;&lt;td&gt;JavaScript people who want full control and ten templating languages to choose from&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;tr&gt;&#xA;&lt;td&gt;&lt;strong&gt;Astro&lt;/strong&gt;&lt;/td&gt;&#xA;&lt;td&gt;Node&lt;/td&gt;&#xA;&lt;td&gt;Content framework with interactive islands&lt;/td&gt;&#xA;&lt;td&gt;Content sites that also need real interactive components&lt;/td&gt;&#xA;&lt;/tr&gt;&#xA;&lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;p&gt;Hugo is the sensible answer for most people, and it&#39;s what this blog &lt;a href=&#34;/posts/rebuilt-the-blog-again/&#34;&gt;originally ran on&lt;/a&gt;. It renders a large site in seconds, has by far the biggest theme catalogue, and if you hit a wall someone solved it years ago and wrote it down. Jekyll is older and slower on big sites, but if you just want to push Markdown to GitHub Pages and walk away, nothing is simpler. Zola is the closest of the lot to colophon in spirit: one Rust binary, sensible things built in, and a deliberate refusal to grow a plugin system. Eleventy and Astro are the JavaScript end of the room, Eleventy all flexibility and no opinions, Astro a proper framework for when a &amp;quot;blog&amp;quot; is really an app wearing a blog&#39;s clothes.&lt;/p&gt;&#xA;&lt;p&gt;None of them ship AI media generation, spoken-audio readings, IndieWeb webmentions or POSSE syndication in the box. That isn&#39;t a criticism: it&#39;s a scope decision, and you can add most of it with plugins and a few external services.&lt;/p&gt;&#xA;&lt;h2 id=&#34;where-colophon-is-actually-different&#34;&gt;Where colophon is actually different&lt;/h2&gt;&#xA;&lt;p&gt;colophon is also a single Go binary that turns Markdown into a static site, so on the core job it&#39;s unremarkable. The difference is what it folds in as first-party features, the parts you&#39;d otherwise assemble from a plugin here and a SaaS there:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;AI-generated hero images from a &lt;code&gt;gen:&lt;/code&gt; prompt, and spoken text-to-speech readings of each post, content-addressed and cached&lt;/li&gt;&#xA;&lt;li&gt;IndieWeb plumbing: microformats, webmentions, and POSSE syndication to Bluesky and Mastodon, built in&lt;/li&gt;&#xA;&lt;li&gt;A glossary with reference links, slide decks derived from the post, and an &lt;a href=&#34;https://obsidian.md&#34;&gt;Obsidian&lt;/a&gt; vault as a first-class source&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The on-site search deserves a specific mention, and a specific honesty. The architecture isn&#39;t original: it&#39;s the model &lt;a href=&#34;https://github.com/Pagefind/pagefind&#34;&gt;Pagefind&lt;/a&gt; popularised, a sharded index built from the rendered output, with the browser fetching only the shards and fragments a query actually touches, and colophon&#39;s &lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/design/search.md&#34;&gt;design notes&lt;/a&gt; say exactly that. What&#39;s different is that it&#39;s Go-native and dependency-free, with its own open format rather than a reverse-engineered one, a vanilla-JavaScript reader and no WASM, where the usual way to get this is to shell out to Pagefind&#39;s Rust toolchain. It lives as a &lt;a href=&#34;https://github.com/jmylchreest/colophon/tree/main/search&#34;&gt;standalone module&lt;/a&gt; with a &lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/search/SPEC.md&#34;&gt;language-neutral spec&lt;/a&gt; and golden test vectors, so it indexes any &lt;code&gt;{id, url, title, body}&lt;/code&gt; documents and anyone can write a conformant reader in another language. I&#39;m pleased with how it turned out, but I&#39;m not claiming the idea.&lt;/p&gt;&#xA;&lt;p&gt;That bundle is the whole pitch.&lt;/p&gt;&#xA;&lt;h2 id=&#34;accessibility-and-languages&#34;&gt;Accessibility, and languages&lt;/h2&gt;&#xA;&lt;p&gt;Two things colophon tries genuinely hard at, with very different results so far.&lt;/p&gt;&#xA;&lt;p&gt;Accessibility is treated as the engine&#39;s job rather than something you inherit from whichever theme you happened to pick. There has been a deliberate sweep toward WCAG AAA: code blocks, diagrams and display maths are keyboard-focusable scroll regions, every control has a visible focus indicator, the muted text and link colours are pushed to the 7:1 contrast ratio AAA asks for, audio and attachment markers carry real labels, the glossary pop-overs are proper accessible tooltips, and the whole site still works with JavaScript switched off. Any of the grown-up tools can be made just as accessible with a careful theme; the difference is who carries that weight. Here it&#39;s baked into the engine, not left to you.&lt;/p&gt;&#xA;&lt;p&gt;Internationalisation is the part still in flux. The foundations have been there a while: a per-post language tag that flows into &lt;code&gt;&amp;lt;html lang&amp;gt;&lt;/code&gt;, the feeds and Open Graph, plus localised player chrome and pronunciation tables in six languages. Newer, and currently being trialled, is proper multi-language posts: drop a &lt;code&gt;my-post.es.md&lt;/code&gt; beside the English one and you get a Spanish version at &lt;code&gt;/es/posts/my-post/&lt;/code&gt;, linked by a shared slug, with &lt;code&gt;hreflang&lt;/code&gt; alternates, a language selector in the header, and a quiet browser-preference banner that never force-redirects. Each translation is a full post, so it gets its own reading, feeds and deck. This very page is the test: there&#39;s a Spanish version, and the selector at the top will take you to it. That&#39;s an experiment in the feature rather than a change of language here. I&#39;m not starting to post in Spanish, and the Spanish version is a translation of this post rather than something written separately, so read it as a demonstration of the plumbing rather than as Spanish writing. It&#39;s early, and Hugo&#39;s multilingual is still more complete and more proven, but the bones are good, and it&#39;s exactly the sort of thing where help would be very welcome.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-honest-part&#34;&gt;The honest part&lt;/h2&gt;&#xA;&lt;p&gt;There is no popular tool that matches colophon feature-for-feature. The single-binary, batteries-included niche is held by Zola and Hugo; the own-your-content, federate-by-default spirit is best embodied by &lt;a href=&#34;https://micro.blog/&#34;&gt;Micro.blog&lt;/a&gt;, which is a lovely thing but a hosted, paid platform rather than a generator you run. colophon&#39;s particular combination doesn&#39;t exist elsewhere in one box, and that cuts both ways: it&#39;s the reason to look at it, and the reason not to.&lt;/p&gt;&#xA;&lt;p&gt;colophon is a work in progress and right now it&#39;s just me, so contributions are very welcome. The assumption is that most people will interact with it through an LLM and the skills it ships, because that&#39;s what makes getting your thoughts down quick, and I&#39;d expect the same from anyone who wants to work on it. Found a bug? Pull out your LLM of choice and file a PR. I use my own agent tooling (&lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;aide&lt;/a&gt;), and if you do too you&#39;ll inherit the same captured decisions and implementation standards.&lt;/p&gt;&#xA;&lt;p&gt;To be fair in the other direction: your &lt;em&gt;words&lt;/em&gt; aren&#39;t trapped if you did choose to give it a try. The input is plain Markdown and the output is ordinary static HTML, so moving to another tool means rewriting templates and config, not rescuing your content. The lock-in is in the conveniences, not the writing. That&#39;s the bit I care about most, and it&#39;s true of all the tools here.&lt;/p&gt;&#xA;&lt;h2 id=&#34;so-when-is-colophon-the-right-thing-if-ever&#34;&gt;So when is colophon the right thing, if ever&lt;/h2&gt;&#xA;&lt;p&gt;Pick one of the grown-ups if you want a blog that simply works for the next decade. For most people, most of the time, that means Hugo. Choose Jekyll if you live on GitHub Pages, Astro if you need components, Eleventy if you want to build it your way, and Micro.blog if you want the IndieWeb and cross-posting half without running anything at all. Or simply, any of the hundreds of other tools that do something similar, or roll your own!&lt;/p&gt;&#xA;&lt;p&gt;colophon earns its place in a narrow case: you specifically want that opinionated bundle, generated images, spoken readings, IndieWeb and POSSE, glossary and slides, in a single binary instead of stitched together.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s especially useful if you write in &lt;a href=&#34;https://obsidian.md&#34;&gt;Obsidian&lt;/a&gt;, and you can happily live close to the edge of a young, one-maintainer project. That&#39;s a real reason. It&#39;s just not a universal one, and today the person it fits best is me. I use it because I wanted exactly these features in one place and I&#39;m content to be my own support desk.&lt;/p&gt;&#xA;&lt;h2 id=&#34;related&#34;&gt;Related&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/a-colophon-blog-on-cloudflare-pages-and-r2/&#34;&gt;A Colophon Blog on Cloudflare Pages and R2&lt;/a&gt;: how this blog is actually built and hosted.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/wiring-the-blog-into-the-indieweb/&#34;&gt;Wiring the Blog into the IndieWeb&lt;/a&gt;: the federation and POSSE half, in practice.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/the-post-is-the-slide-deck/&#34;&gt;The Post Is the Slide Deck&lt;/a&gt;: one of the bundled features, decks derived from a post.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/rebuilt-the-blog-again/&#34;&gt;Rebuilt the Blog. Again.&lt;/a&gt;: why I moved off Hugo in the first place.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</content>
  </entry>
  <entry>
    <title>The Post Is the Slide Deck</title>
    <id>https://blog.i0.pm/posts/the-post-is-the-slide-deck/</id>
    <link href="https://blog.i0.pm/posts/the-post-is-the-slide-deck/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-the-post-is-the-slide-deck-319ceba35148.wav" rel="enclosure" type="audio/wav" length="9221114"></link>
    <updated>2026-06-28T00:00:00Z</updated>
    <published>2026-06-28T00:00:00Z</published>
    <summary type="text">colophon now derives a themed slide deck from any post: headings become slides, prose becomes presenter notes, and one line of frontmatter turns it on.</summary>
    <content type="html">&lt;p&gt;This one is a bit of an experiment. A blog post and a short talk are often the same material, just delivered differently, and people take things in differently too: some will read a post, some would rather sit through a handful of slides. I wanted to see whether one document could be pulled into more than one medium without any significant rewriting. colophon can now do exactly that: a post projects itself into a themed deck, published at &lt;code&gt;/&amp;lt;slug&amp;gt;/slides/&lt;/code&gt; and linked from its Downloads box, with the deck &lt;em&gt;derived&lt;/em&gt; from the post rather than maintained beside it. This post is one: open the Slides link and present it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;one-document-more-than-one-medium&#34;&gt;One document, more than one medium&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;The same material, read or presented&lt;/li&gt;&#xA;&lt;li&gt;No second copy to keep in sync&lt;/li&gt;&#xA;&lt;li&gt;The deck updates when the post does&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;A post and a short talk are usually the same argument dressed for a different room, so keeping them as one source, with the deck derived from it, means there&#39;s nothing to rewrite and nothing to drift. The post stays the source of truth and the deck is just a view of it. That was the experiment, and so far it seems a useful one.&lt;/p&gt;&#xA;&lt;h2 id=&#34;turn-it-on&#34;&gt;Turn it on&lt;/h2&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# colophon.yaml: the site default (off, here)&#xA;slides:&#xA;  enabled: false&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# any post: opt in, in the frontmatter&#xA;slides: true&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The site sets the default and each post overrides it by key, so a single key you set wins and the rest is inherited. On this blog decks are off in production by default, so a post asks for one on purpose. That is the whole setup: one line of frontmatter and the post grows a deck.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-becomes-what&#34;&gt;What becomes what&lt;/h2&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;mermaid&#34;&gt;flowchart LR&#xA;  P[&amp;#34;The post&amp;#34;] --&amp;gt; S[&amp;#34;Headings: slides&amp;#34;]&#xA;  P --&amp;gt; N[&amp;#34;Prose: presenter notes&amp;#34;]&#xA;  P --&amp;gt; B[&amp;#34;Code, images, tables, maths, diagrams: on the slide&amp;#34;]&#xA;&lt;/pre&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Headings split the deck into slides&lt;/li&gt;&#xA;&lt;li&gt;Paragraphs drop into the presenter notes&lt;/li&gt;&#xA;&lt;li&gt;Everything else stays on the slide: code, images, tables, maths, diagrams, callouts, pull-quotes&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Here is the rule that keeps a slide readable: a paragraph is narration, so it lands in the notes and never on the slide itself. If you want something &lt;em&gt;on&lt;/em&gt; the slide, make it a bullet, a code block, a quote, or an image. Write the post the way you always would and the split falls out of it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;where-the-breaks-go&#34;&gt;Where the breaks go&lt;/h2&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;slides:&#xA;  enabled: true&#xA;  split: [h2]        # h2 opens a slide; h3 and deeper fold into bullets&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Heading levels: &lt;code&gt;h1&lt;/code&gt; through &lt;code&gt;h6&lt;/code&gt;&lt;/li&gt;&#xA;&lt;li&gt;Structural: &lt;code&gt;hr&lt;/code&gt;, &lt;code&gt;splitslide&lt;/code&gt;&lt;/li&gt;&#xA;&lt;li&gt;Block kinds: &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;math&lt;/code&gt;, &lt;code&gt;diagram&lt;/code&gt;, &lt;code&gt;audio&lt;/code&gt;, &lt;code&gt;video&lt;/code&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;text:&amp;lt;match&amp;gt;&lt;/code&gt;: break before a block whose text starts with the match&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;By default every heading opens a new slide. Narrow it with &lt;code&gt;split&lt;/code&gt; when you&#39;d rather fold the deeper headings into bullets. This post sets &lt;code&gt;split: [h2]&lt;/code&gt;, which is exactly why each of these sections is its own slide.&lt;/p&gt;&#xA;&lt;h2 id=&#34;three-inline-markers&#34;&gt;Three inline markers&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;splitslide&amp;gt;&lt;/code&gt; forces a break mid-section&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;slide&amp;gt;…&amp;lt;/slide&amp;gt;&lt;/code&gt; makes one verbatim slide&lt;/li&gt;&#xA;&lt;li&gt;&lt;code&gt;&amp;lt;noslide&amp;gt;…&amp;lt;/noslide&amp;gt;&lt;/code&gt; keeps content in the post but out of the deck&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;These mirror the &lt;code&gt;&amp;lt;tts&amp;gt;&lt;/code&gt; family, and they&#39;re escape hatches rather than the main event. I reach for &lt;code&gt;&amp;lt;noslide&amp;gt;&lt;/code&gt; most: a Related list or a long aside earns its place in the post but would only clutter a deck. The slide just below is a real &lt;code&gt;&amp;lt;slide&amp;gt;&lt;/code&gt;, not a screenshot of one.&lt;/p&gt;&#xA;&#xA;&lt;div class=&#34;callout callout-tip&#34; data-callout=&#34;tip&#34;&gt;&#xA;&lt;div class=&#34;callout-title&#34;&gt;One idea per slide&lt;/div&gt;&#xA;&lt;div class=&#34;callout-body&#34;&gt;&#xA;&lt;p&gt;Sometimes a slide should be a single line and nothing else. &lt;code&gt;&amp;lt;slide&amp;gt;&lt;/code&gt; gives you exactly that, verbatim.&lt;/p&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;present-it-or-read-it&#34;&gt;Present it, or read it&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;kbd&gt;←&lt;/kbd&gt; / &lt;kbd&gt;→&lt;/kbd&gt; or swipe to move&lt;/li&gt;&#xA;&lt;li&gt;&lt;kbd&gt;P&lt;/kbd&gt; presenter notes, &lt;kbd&gt;F&lt;/kbd&gt; fullscreen&lt;/li&gt;&#xA;&lt;li&gt;&lt;kbd&gt;Enter&lt;/kbd&gt; plays the slide&#39;s media, &lt;kbd&gt;Esc&lt;/kbd&gt; closes the deck&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;With JavaScript it&#39;s a keyboard-and-swipe presentation. With JavaScript off, the very same file reads as a long-form document, the slides simply stacked as sections. There&#39;s no separate export and no PDF to email round, and it degrades to something useful rather than a blank frame.&lt;/p&gt;&#xA;&lt;h2 id=&#34;on-in-preview-off-in-prod&#34;&gt;On in preview, off in prod&lt;/h2&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;environments:&#xA;  - name: preview&#xA;    slides: { enabled: true }    # decks on while I&amp;#39;m drafting&#xA;  - name: production&#xA;    # inherits the site default (off)&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A talk is rarely ready the moment the post is. An environment can override the slides default, so the deck rides along on the preview deploy while it&#39;s still coming together, and only switches on in production once it&#39;s actually any good.&lt;/p&gt;&#xA;&lt;figure class=&#34;pullquote&#34;&gt;&#xA;&lt;blockquote&gt;&#xA;&lt;p&gt;Write the talk once. Let the deck be a view of it, not a second copy.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;/figure&gt;&#xA;&#xA;&lt;h2 id=&#34;related&#34;&gt;Related&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/a-colophon-blog-on-cloudflare-pages-and-r2/&#34;&gt;A Colophon Blog on Cloudflare Pages and R2&lt;/a&gt;: where this blog, and the build that now derives these decks, were first set up.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/elevenlabs-voices-and-pronunciation-dictionaries/&#34;&gt;ElevenLabs Voices and Pronunciation Dictionaries&lt;/a&gt;: another thing a post grows on its own, a spoken reading.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/wiring-the-blog-into-the-indieweb/&#34;&gt;Wiring the Blog into the IndieWeb&lt;/a&gt;: more derived-from-the-post plumbing, feeds and webmentions.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&#xA;</content>
  </entry>
  <entry>
    <title>ElevenLabs Voices and Pronunciation Dictionaries</title>
    <id>https://blog.i0.pm/posts/elevenlabs-voices-and-pronunciation-dictionaries/</id>
    <link href="https://blog.i0.pm/posts/elevenlabs-voices-and-pronunciation-dictionaries/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-elevenlabs-voices-and-pronunciation-dictio-338e7f78e745.wav" rel="enclosure" type="audio/wav" length="11067628"></link>
    <updated>2026-06-25T00:00:00Z</updated>
    <published>2026-06-25T00:00:00Z</published>
    <summary type="text">colophon now offers ElevenLabs alongside MiniMax for spoken posts, plus pronunciation dictionaries that fix the words a technical blog mispronounces, on either provider.</summary>
    <content type="html">&lt;p&gt;Every post on this blog has a spoken reading, generated text-to-speech (TTS), sitting under the title for anyone who&#39;d rather listen. It&#39;s been driven by &lt;a href=&#34;https://platform.minimax.io/&#34;&gt;MiniMax&lt;/a&gt; since I added it. Two things have changed recently, and I think they&#39;re worth writing down. A caveat first: this is all new and still moving, so treat the specifics as a snapshot that may change.&lt;/p&gt;&#xA;&lt;p&gt;The short version: there&#39;s now a &lt;strong&gt;second voice provider, ElevenLabs&lt;/strong&gt;, and &lt;strong&gt;pronunciation dictionaries&lt;/strong&gt; that work across both. Plus a quiet format change underneath.&lt;/p&gt;&#xA;&lt;h2 id=&#34;pronunciation-dictionaries&#34;&gt;Pronunciation dictionaries&lt;/h2&gt;&#xA;&lt;p&gt;The honest problem with generated speech is that it mispronounces exactly the words a technical blog uses most. A voice trained mostly on American English says &amp;quot;raowter&amp;quot; for &lt;em&gt;router&lt;/em&gt;, gives &lt;em&gt;cache&lt;/em&gt; an extra syllable, and has its own ideas about &lt;em&gt;aluminium&lt;/em&gt;. Re-recording isn&#39;t an option when the audio is generated, so the fix is to tell the voice how to say specific words.&lt;/p&gt;&#xA;&lt;p&gt;colophon now takes a &lt;strong&gt;pronunciation dictionary&lt;/strong&gt;, and it applies to whichever provider you&#39;re on. The cheapest version is the bundled British one:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;generation:&#xA;  speech:&#xA;    pronunciation_dict: en_GB     # the dictionary shipped with colophon&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Or point it at your own file, relative to the site root:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;generation:&#xA;  speech:&#xA;    pronunciation_dict: pronunciation.yaml&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The format is small on purpose. Each entry is a &lt;code&gt;word&lt;/code&gt; plus &lt;strong&gt;one&lt;/strong&gt; of two ways to fix it:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;pronunciations:&#xA;  - word: router&#xA;    ipa: ˈruːtə        # IPA: precise; rendered to each provider&amp;#39;s phoneme mechanism&#xA;  - word: nginx&#xA;    say: engine x      # respelling: substituted as plain text, works on any provider&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The two knobs are worth understanding:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;ipa&lt;/code&gt;&lt;/strong&gt; is the International Phonetic Alphabet (IPA) transcription, precise and unambiguous. colophon renders it to each provider&#39;s own phoneme mechanism (more on the ElevenLabs side of that below).&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;say&lt;/code&gt;&lt;/strong&gt; is a plain respelling, substituted as ordinary text before synthesis. It&#39;s less exact but completely portable, any voice can read &amp;quot;engine x&amp;quot;, no phoneme support required.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;One piece of hard-won advice that&#39;s baked into the bundled dictionary: keep it small. Overriding a word the voice &lt;em&gt;already&lt;/em&gt; says correctly almost always makes it worse. Add a word only after you&#39;ve heard it get one wrong, and audition the fix before you keep it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;a-second-voice-elevenlabs&#34;&gt;A second voice: ElevenLabs&lt;/h2&gt;&#xA;&lt;p&gt;MiniMax is cheap and fine, but &lt;a href=&#34;https://elevenlabs.io/&#34;&gt;ElevenLabs&lt;/a&gt; is the one people reach for when they want a specific, higher-fidelity voice, including a clone of their own. colophon now has an &lt;code&gt;elevenlabs&lt;/code&gt; speech driver alongside the MiniMax one. Switching is a config change:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;generation:&#xA;  speech:&#xA;    provider: elevenlabs&#xA;    model: eleven_multilingual_v2          # the default; override if you like&#xA;    voice: &amp;#34;&amp;lt;your ElevenLabs voice id&amp;gt;&amp;#34;    # a premade voice, or your own cloned one&#xA;    api_key: &amp;#34;{env:ELEVENLABS_API_KEY}&amp;#34;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;As with every secret in colophon, the key only ever arrives through the environment, never written into the config. It&#39;s read from &lt;code&gt;ELEVENLABS_API_KEY&lt;/code&gt; (or &lt;code&gt;COLOPHON_ELEVENLABS_API_KEY&lt;/code&gt;), so it lives in your keyring or CI secrets exactly like the deploy tokens.&lt;/p&gt;&#xA;&lt;h3 id=&#34;the-api-key-permissions&#34;&gt;The API key permissions&lt;/h3&gt;&#xA;&lt;p&gt;ElevenLabs lets you scope an API key to specific capabilities, and colophon needs:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Text to Speech&lt;/strong&gt;: to generate the readings. This is the only permission you need if you&#39;re not using a pronunciation dictionary.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Pronunciation Dictionaries (read and write)&lt;/strong&gt;: only if you set &lt;code&gt;pronunciation_dict&lt;/code&gt;. ElevenLabs doesn&#39;t take IPA inline; it applies pronunciation through an &lt;em&gt;uploaded, versioned dictionary&lt;/em&gt; tied to your account. So colophon syncs your &lt;code&gt;ipa&lt;/code&gt; entries up to ElevenLabs (it lists, creates, and adds or removes rules on a dictionary it manages for you), then references that dictionary when it synthesises. That sync is why the key needs write access to dictionaries, not just speech.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;So: a plain ElevenLabs reading needs &lt;strong&gt;Text to Speech&lt;/strong&gt; alone; add &lt;strong&gt;Pronunciation Dictionaries (read/write)&lt;/strong&gt; the moment you want your &lt;code&gt;ipa&lt;/code&gt; overrides to take effect. The &lt;code&gt;say&lt;/code&gt; respellings, being plain text substitution, need no extra permission on either provider.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-quiet-change-wav-now-opus-later&#34;&gt;The quiet change: WAV now, Opus later&lt;/h2&gt;&#xA;&lt;p&gt;Underneath both providers, the audio format changed. colophon no longer generates MP3; both drivers now return raw PCM (16 kHz mono) which colophon wraps as a &lt;strong&gt;WAV&lt;/strong&gt; file. Two nice consequences fall out of that. The waveform you see under each reading is computed from that same PCM, so there&#39;s no second render just to draw it (the old MiniMax path did a separate call for the waveform, which doubled the token spend, that&#39;s gone). And WAV is trivially decodable, so the peaks come for free.&lt;/p&gt;&#xA;&lt;p&gt;The obvious objection is size: WAV is uncompressed. That&#39;s the next piece of work, an Opus (SILK) encoder to shrink the readings without a heavyweight dependency. The plumbing already half-knows about it (&lt;code&gt;.opus&lt;/code&gt; is a recognised type), it just isn&#39;t wired to an encoder yet. For now the trade is deliberate: simple, decoder-free, accurate waveforms, and pay the bytes until the encoder lands.&lt;/p&gt;&#xA;&lt;h2 id=&#34;putting-it-together&#34;&gt;Putting it together&lt;/h2&gt;&#xA;&lt;p&gt;So the current shape of a reading: pick a provider (MiniMax or ElevenLabs), optionally give it your voice, and hand it a small pronunciation dictionary so it stops saying &amp;quot;raowter&amp;quot;. The dictionary is provider-agnostic; the voice and the key are provider-specific; the output is a WAV and a waveform.&lt;/p&gt;&#xA;&lt;p&gt;All of this is recent and still settling, so the field names and defaults may move. If you&#39;re wiring it up from this post, sanity-check against your own &lt;code&gt;colophon.yaml&lt;/code&gt; and the current build.&lt;/p&gt;&#xA;&lt;h2 id=&#34;related&#34;&gt;Related&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/a-colophon-blog-on-cloudflare-pages-and-r2/&#34;&gt;A Colophon Blog on Cloudflare Pages and R2&lt;/a&gt;: where this blog, and its MiniMax media generation, were first set up.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/tinct-minimax-input/&#34;&gt;A Palette from a Prompt&lt;/a&gt;: the same MiniMax provider, generating desktop wallpapers instead of speech.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Wiring the Blog into the IndieWeb</title>
    <id>https://blog.i0.pm/posts/wiring-the-blog-into-the-indieweb/</id>
    <link href="https://blog.i0.pm/posts/wiring-the-blog-into-the-indieweb/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-wiring-the-blog-into-the-indieweb-3157f8576d8a.wav" rel="enclosure" type="audio/wav" length="16946290"></link>
    <updated>2026-06-22T00:00:00Z</updated>
    <published>2026-06-22T00:00:00Z</published>
    <summary type="text">Joining the IndieWeb from a colophon blog: WebSub for real-time feeds, webmention.io to collect replies, sending webmentions, and the rel=me identity dance that lets you sign in as your own domain.</summary>
    <content type="html">&lt;p&gt;Having &lt;a href=&#34;/a-colophon-blog-on-cloudflare-pages-and-r2/&#34;&gt;put the blog on Cloudflare Pages and R2&lt;/a&gt;,&#xA;the next itch was to stop it being an island. A static site is a lovely thing to own, but on&#xA;its own it just sits there: no &amp;quot;someone replied&amp;quot;, no way for anyone to follow it without an RSS&#xA;reader they probably don&#39;t have. The &lt;a href=&#34;https://indieweb.org/&#34;&gt;IndieWeb&lt;/a&gt; answer to that is a&#xA;handful of small, open standards, and &lt;a href=&#34;https://github.com/jmylchreest/colophon&#34;&gt;colophon&lt;/a&gt; speaks&#xA;them. This is what I turned on, roughly in order of how much faff each one is.&lt;/p&gt;&#xA;&lt;p&gt;One note on scope before we start: colophon does the whole loop now, sending these interactions,&#xA;collecting them, and rendering the replies under your posts. So this isn&#39;t plumbing for a someday&#xA;feature; by the end the conversation actually shows up on the page. The order of operations is what&#xA;matters, and a couple of the steps are easy to get subtly wrong.&lt;/p&gt;&#xA;&lt;h2 id=&#34;websub-real-time-feeds-no-account&#34;&gt;WebSub: real-time feeds, no account&lt;/h2&gt;&#xA;&lt;p&gt;The cheapest win. A normal RSS/Atom feed is polled, so a reader finds your new post whenever it&#xA;next gets round to checking. &lt;a href=&#34;https://www.w3.org/TR/websub/&#34;&gt;WebSub&lt;/a&gt; flips that: you ping a hub&#xA;on publish, the hub pushes to every subscriber at once. Pure config, no signup, in &lt;code&gt;colophon.yaml&lt;/code&gt;&#xA;under your site:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;federation:&#xA;  feeds: [rss, atom, json]&#xA;  websub:&#xA;    hubs:&#xA;      - https://pubsubhubbub.appspot.com/   # Google&amp;#39;s public hub&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The ping only fires on a real public deploy, not a local build,&lt;notts&gt; so check the publish log for&#xA;&lt;code&gt;WEBSUB … ping ok&lt;/code&gt;, and confirm there&#39;s a &lt;code&gt;rel=&amp;quot;hub&amp;quot;&lt;/code&gt; link in &lt;code&gt;rss.xml&lt;/code&gt;/&lt;code&gt;atom.xml&lt;/code&gt; and a &lt;code&gt;hubs&lt;/code&gt;&#xA;array in &lt;code&gt;feed.json&lt;/code&gt;.&lt;/notts&gt; A slow or down hub logs a failure and is otherwise ignored; it never breaks&#xA;the publish.&lt;/p&gt;&#xA;&lt;h2 id=&#34;sending-webmentions-also-no-account&#34;&gt;Sending webmentions: also no account&lt;/h2&gt;&#xA;&lt;p&gt;A &lt;a href=&#34;https://www.w3.org/TR/webmention/&#34;&gt;webmention&lt;/a&gt; is the open web&#39;s version of an @-mention: when&#xA;I link to your post, my site POSTs yours a note saying &amp;quot;this URL mentions you&amp;quot;, and your site can&#xA;fetch mine back to verify it. Sending needs nothing set up, but it has to run &lt;em&gt;after&lt;/em&gt; a publish,&#xA;because the source URLs have to be live for the receiver to verify them:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon webmention send --env production --dry-run   # report, don&amp;#39;t POST&#xA;colophon webmention send --env production             # actually send&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;It walks the built output, takes each page&#39;s canonical URL as the source, collects the cross-origin&#xA;outbound links as targets, discovers each target&#39;s endpoint, and POSTs. It keeps a sent-cache&lt;notts&gt; at&#xA;&lt;code&gt;.colophon/cache/webmention-sent.json&lt;/code&gt;&lt;/notts&gt;, so re-runs only notify &lt;em&gt;new&lt;/em&gt; links and re-ping ones you&#39;ve&#xA;&lt;em&gt;removed&lt;/em&gt; (so the receiver re-checks and drops a mention you deleted). I bolted it onto the end of&#xA;my publish wrapper, so it fires on every deploy.&lt;/p&gt;&#xA;&lt;h2 id=&#34;receiving-webmentionio&#34;&gt;Receiving: webmention.io&lt;/h2&gt;&#xA;&lt;p&gt;To collect replies, likes and reposts, point a receiver at &lt;a href=&#34;https://webmention.io&#34;&gt;webmention.io&lt;/a&gt;,&#xA;a hosted endpoint that catches them for you:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;federation:&#xA;  indieweb:&#xA;    webmention:&#xA;      receiver: https://webmention.io/blog.i0.pm/webmention&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;With that set, colophon emits a &lt;notts&gt;&lt;code&gt;&amp;lt;link rel=&amp;quot;webmention&amp;quot;&amp;gt;&lt;/code&gt;&lt;/notts&gt; discovery tag site-wide, which is how&#xA;anyone (and Bridgy Fed, if you go that way) knows where to send mentions. The endpoint keys on&#xA;your domain, so the path stays &lt;code&gt;blog.i0.pm&lt;/code&gt; regardless of which page is mentioned.&lt;/p&gt;&#xA;&lt;h2 id=&#34;showing-the-responses&#34;&gt;Showing the responses&lt;/h2&gt;&#xA;&lt;p&gt;Collecting them is only half of it; the point is to &lt;em&gt;show&lt;/em&gt; the replies and likes under the post.&#xA;colophon does this two ways, and the choice is a proper trade-off:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;federation:&#xA;  indieweb:&#xA;    webmention:&#xA;      receiver: https://webmention.io/blog.i0.pm/webmention&#xA;      display: { mode: live }            # or: asset&#xA;      token: &amp;#34;{env:WEBMENTION_IO_TOKEN}&amp;#34; # only used by asset mode&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;asset&lt;/code&gt;&lt;/strong&gt; self-hosts the data: a &lt;code&gt;colophon webmention fetch&lt;/code&gt; pulls your mentions back through&#xA;the API token, caches them, and bakes a small JSON file per post that ships with the site. The&#xA;responses are served from your own origin, work without hammering anyone else, and degrade&#xA;gracefully. The cost is that they only refresh when you fetch and redeploy (a cron job, or a&#xA;decoupled &lt;code&gt;webmention publish&lt;/code&gt; that pushes just the mentions without rebuilding the site).&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;live&lt;/code&gt;&lt;/strong&gt; skips all of that: the reader&#39;s browser fetches the receiver directly on each page&#xA;load, so a new reply shows up the next time anyone opens the post, no rebuild, no token, no&#xA;cron. The cost is honesty about two things, it needs JavaScript (so the responses don&#39;t exist&#xA;for a no-JS reader), and it points your visitors&#39; browsers at a third party.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;There&#39;s a third path you don&#39;t pick directly: a no-JS theme. The engine can &lt;strong&gt;bake the responses&#xA;into the HTML at build time&lt;/strong&gt; from the same asset-mode data, so a theme that ships no JavaScript&#xA;(the bundled &lt;code&gt;minimal&lt;/code&gt; does exactly this) still renders replies and likes, server-side, no client&#xA;script. The JS themes use a placeholder the script fills; the no-JS ones get it pre-rendered. Same&#xA;data, the theme decides how it arrives.&lt;/p&gt;&#xA;&lt;p&gt;I went with &lt;strong&gt;live&lt;/strong&gt;. This is a personal blog, not a fortress; I&#39;d rather a reply appear the&#xA;moment it lands than run a cron to refresh a cache, and the responses are an enhancement below the&#xA;article, not the article itself. I&#39;ve left the &lt;code&gt;token:&lt;/code&gt; line in the config anyway,&lt;notts&gt; pointing at&#xA;&lt;code&gt;WEBMENTION_IO_TOKEN&lt;/code&gt; (keyring &lt;code&gt;COLOPHON_WEBMENTION_IO_TOKEN&lt;/code&gt;, exported by the publish wrapper),&lt;/notts&gt; so&#xA;flipping to &lt;code&gt;asset&lt;/code&gt; later is a one-word change rather than a dig through the docs.&lt;/p&gt;&#xA;&lt;h2 id=&#34;posse-publish-here-syndicate-everywhere&#34;&gt;POSSE: publish here, syndicate everywhere&lt;/h2&gt;&#xA;&lt;p&gt;Webmentions handle the conversation; the other half of owning your content is&#xA;&lt;a href=&#34;https://indieweb.org/POSSE&#34;&gt;POSSE&lt;/a&gt;, Publish on your Own Site, Syndicate Elsewhere. The canonical&#xA;copy lives here, and a copy goes out to the silos where people actually are, each one linking back.&#xA;colophon has a &lt;code&gt;syndicate&lt;/code&gt; step for it, deliberately separate from &lt;code&gt;publish&lt;/code&gt; so the original is&#xA;always live &lt;em&gt;first&lt;/em&gt;:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;federation:&#xA;  syndication:&#xA;    - id: bsky&#xA;      driver: bluesky&#xA;      handle: &amp;#34;{env:BLUESKY_HANDLE:-}&amp;#34;&#xA;      app_password: &amp;#34;{env:BLUESKY_APP_PASSWORD}&amp;#34;   # an app password, not your login&#xA;# and on the environment:&#xA;environments:&#xA;  - name: production&#xA;    syndicate: [bsky]      # only listed targets fire; preview/draft envs omit it, so they never post&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;There are four drivers, and the choice comes down to who holds the keys: &lt;strong&gt;mastodon&lt;/strong&gt; and&#xA;&lt;strong&gt;bluesky&lt;/strong&gt; post directly to your account with a token (or a Bluesky app password) you control;&#xA;&lt;strong&gt;bridgy&lt;/strong&gt; posts on your behalf via &lt;a href=&#34;https://brid.gy&#34;&gt;Bridgy&lt;/a&gt; so colophon holds no silo&#xA;credentials at all; and &lt;strong&gt;command&lt;/strong&gt; runs any program you like for anything without a built-in&#xA;driver. I started with Bluesky, direct, my app password, one account to reason about.&lt;/p&gt;&#xA;&lt;p&gt;The flow mirrors the webmention one, run it &lt;em&gt;after&lt;/em&gt; a publish so the canonical URL is live:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon syndicate --env production --dry-run        # preview; posts nothing, writes nothing&#xA;colophon syndicate --env production --allow-publish    # post the copies, record the ledger&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The ledger&lt;notts&gt; at &lt;code&gt;.colophon/syndication.json&lt;/code&gt;&lt;/notts&gt; (commit it) is the idempotency guard: it remembers what&#xA;went where, so re-running never double-posts, and it feeds the &amp;quot;Also posted on…&amp;quot; links back on the&#xA;post. Per-post you can opt out with &lt;notts&gt;&lt;code&gt;syndicate: false&lt;/code&gt;&lt;/notts&gt;, narrow to a subset with &lt;notts&gt;&lt;code&gt;syndicate: [bsky]&lt;/code&gt;&lt;/notts&gt;,&#xA;or write a custom blurb with &lt;notts&gt;&lt;code&gt;syndicate_text:&lt;/code&gt;&lt;/notts&gt;.&lt;notts&gt; Secrets, as ever, only arrive via &lt;code&gt;{env:VAR}&lt;/code&gt;.&lt;/notts&gt;&lt;/p&gt;&#xA;&lt;p&gt;One thing not to confuse: this is &lt;em&gt;syndication&lt;/em&gt; (push a copy out). It&#39;s a different job from making&#xA;the site itself followable from the fediverse, that&#39;s Bridgy &lt;strong&gt;Fed&lt;/strong&gt;, which hangs off the webmention&#xA;receiver, not the syndication list. Same neighbourhood, different houses.&lt;/p&gt;&#xA;&lt;h2 id=&#34;signing-in-as-your-own-domain&#34;&gt;Signing in as your own domain&lt;/h2&gt;&#xA;&lt;p&gt;One detail worth getting right. webmention.io doesn&#39;t have its own password; you sign in &lt;em&gt;as your&#xA;own website&lt;/em&gt; using &lt;a href=&#34;https://indieweb.org/IndieAuth&#34;&gt;IndieAuth&lt;/a&gt;, via&#xA;&lt;a href=&#34;https://indielogin.com&#34;&gt;indielogin.com&lt;/a&gt;. For that to work, the URL you log in with and an identity&#xA;provider it trusts (GitHub is the easy one) have to link to &lt;strong&gt;each other&lt;/strong&gt; with &lt;code&gt;rel=&amp;quot;me&amp;quot;&lt;/code&gt;, at the&#xA;&lt;em&gt;same&lt;/em&gt; URL on both ends.&lt;/p&gt;&#xA;&lt;p&gt;colophon emits the &lt;code&gt;rel=&amp;quot;me&amp;quot;&lt;/code&gt; link from your author&#39;s &lt;code&gt;urls:&lt;/code&gt;, so set that in&#xA;&lt;notts&gt;&lt;code&gt;authors/&amp;lt;id&amp;gt;.yaml&lt;/code&gt;&lt;/notts&gt;:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;# authors/jmylchreest.yaml&#xA;urls:&#xA;  - https://github.com/jmylchreest&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;GitHub does the other half for you: the &lt;strong&gt;website&lt;/strong&gt; field on your profile is published with&#xA;&lt;code&gt;rel=&amp;quot;me&amp;quot;&lt;/code&gt; automatically. So your site points at GitHub, GitHub points back at your site, the loop&#xA;closes, and indielogin.com is happy to authenticate you through GitHub.&lt;/p&gt;&#xA;&lt;p&gt;The catch is &lt;em&gt;which page&lt;/em&gt; carries the &lt;code&gt;rel=&amp;quot;me&amp;quot;&lt;/code&gt;. The link rides on colophon&#39;s author h-card, and&#xA;the loop only closes if the page you log in with is the same one GitHub links back to. Make sure&#xA;your GitHub website field and the URL you sign in with are the &lt;strong&gt;same&lt;/strong&gt;, and that it&#39;s a page that&#xA;actually renders the author card. On current colophon (v0.0.8) the author page does, so:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;GitHub profile website:&lt;/strong&gt; &lt;notts&gt;&lt;code&gt;https://blog.i0.pm/authors/jmylchreest/&lt;/code&gt;&lt;/notts&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Sign in to webmention.io with:&lt;/strong&gt; the same &lt;notts&gt;&lt;code&gt;https://blog.i0.pm/authors/jmylchreest/&lt;/code&gt;&lt;/notts&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Match those two and the login goes straight through. Mismatch them, even by a trailing path, and&#xA;you get &amp;quot;we couldn&#39;t find any way to authenticate you&amp;quot;, which just means the two pages didn&#39;t point&#xA;at each other.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-you-get&#34;&gt;What you get&lt;/h2&gt;&#xA;&lt;p&gt;So, the full loop: feeds that push instead of waiting to be polled, outgoing webmentions firing on&#xA;every publish, a receiver banking every reply and like against the domain, and those responses&#xA;rendering live under each post, the moment someone opens it. If you&#39;ve replied to this from your&#xA;own site or boosted it on the fediverse (via Bridgy Fed), you should be looking at the evidence a&#xA;little further down the page.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s a strange and rather nice feeling, wiring a static site you fully own into a conversation with&#xA;the rest of the web, without renting a single feature from anyone. More of the web should work like&#xA;this.&lt;/p&gt;&#xA;&lt;h2 id=&#34;related&#34;&gt;Related&lt;/h2&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/a-colophon-blog-on-cloudflare-pages-and-r2/&#34;&gt;A Colophon Blog on Cloudflare Pages and R2&lt;/a&gt; — the Pages + R2 setup this builds on.&lt;/li&gt;&#xA;&lt;li&gt;&lt;a href=&#34;/posts/rebuilt-the-blog-again/&#34;&gt;Rebuilt the Blog. Again.&lt;/a&gt; — why the blog runs on colophon in the first place.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</content>
  </entry>
  <entry>
    <title>A Colophon Blog on Cloudflare Pages and R2</title>
    <id>https://blog.i0.pm/posts/a-colophon-blog-on-cloudflare-pages-and-r2/</id>
    <link href="https://blog.i0.pm/posts/a-colophon-blog-on-cloudflare-pages-and-r2/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-a-colophon-blog-on-cloudflare-pages-and-r2-1792f6f1c9cd.wav" rel="enclosure" type="audio/wav" length="28427472"></link>
    <updated>2026-06-21T00:00:00Z</updated>
    <published>2026-06-21T00:00:00Z</published>
    <summary type="text">A practical walkthrough for standing up a new colophon blog on Cloudflare Pages and R2: API tokens and permissions, DNS, secrets from the keyring, generative images and audio, and the frontmatter that matters.</summary>
    <content type="html">&lt;p&gt;I &lt;a href=&#34;/rebuilt-the-blog-again/&#34;&gt;rebuilt this blog on colophon&lt;/a&gt; a few days ago and said it&#xA;deploys with one command. This is the post that earns that claim: standing up a brand new&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon&#34;&gt;colophon&lt;/a&gt; site on Cloudflare Pages, with the&#xA;images and search index living in R2 rather than shipping with the deployment. Nothing here&#xA;is secret sauce, but the order of operations matters, and a couple of the steps are easy to&#xA;get subtly wrong.&lt;/p&gt;&#xA;&lt;p&gt;The shape of it: &lt;strong&gt;Pages serves the HTML, R2 serves the heavy assets.&lt;/strong&gt; Pages has a file&#xA;count and size budget; a blog with a few hundred generated hero images will blow through it.&#xA;So colophon routes anything matching &lt;code&gt;**/assets/**&lt;/code&gt; and the search index to an R2 bucket and&#xA;rewrites the URLs to point there. The reader&#39;s browser fetches pages from one origin and&#xA;images from another, and neither you nor they have to think about it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-you-need-from-cloudflare&#34;&gt;What you need from Cloudflare&lt;/h2&gt;&#xA;&lt;p&gt;Make a &lt;a href=&#34;https://dash.cloudflare.com/sign-up&#34;&gt;Cloudflare&lt;/a&gt; account if you haven&#39;t, then collect&#xA;three things.&lt;/p&gt;&#xA;&lt;h3 id=&#34;account-id&#34;&gt;Account ID&lt;/h3&gt;&#xA;&lt;p&gt;It&#39;s in the dashboard URL once you&#39;re logged in&#xA;(&lt;code&gt;dash.cloudflare.com/&amp;lt;account-id&amp;gt;/...&lt;/code&gt;), and on the right-hand sidebar of any zone&#39;s overview&#xA;page. This is not a secret, but colophon needs it for both the Pages and R2 publishers.&lt;/p&gt;&#xA;&lt;h3 id=&#34;api-token&#34;&gt;API Token&lt;/h3&gt;&#xA;&lt;p&gt;Profile → API Tokens → &lt;em&gt;Create Token&lt;/em&gt; → &lt;em&gt;Create Custom Token&lt;/em&gt;. The&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md#secrets-and-permissions&#34;&gt;publishing docs&lt;/a&gt;&#xA;list the exact permissions, and they are tighter than the &amp;quot;Edit Cloudflare Workers&amp;quot; template&#xA;you&#39;ll be tempted to grab. For one token that does both jobs:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Account → Cloudflare Pages → Edit&lt;/strong&gt;: deploys the site.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Account → Workers R2 Storage → Edit&lt;/strong&gt;: reads and writes objects, and creates the bucket.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;That second one is the gotcha. The S3-style &lt;code&gt;colophon publish --create&lt;/code&gt; step also flips the&#xA;bucket to public and sets a CORS policy, and that needs R2 &lt;strong&gt;Admin&lt;/strong&gt; read and write, not just&#xA;object read and write. If &lt;code&gt;--create&lt;/code&gt; later complains it &amp;quot;needs R2 Admin Read &amp;amp; Write&amp;quot;, this is&#xA;why. You can use a narrower object-only token for day-to-day publishing once the bucket exists.&lt;/p&gt;&#xA;&lt;h3 id=&#34;an-r2-access-key-pair&#34;&gt;An R2 access key pair&lt;/h3&gt;&#xA;&lt;p&gt;R2 → &lt;em&gt;Manage R2 API Tokens&lt;/em&gt; → create one. You get an&#xA;&lt;code&gt;R2_ACCESS_KEY_ID&lt;/code&gt; and an &lt;code&gt;R2_SECRET_ACCESS_KEY&lt;/code&gt;; R2 speaks S3, so these are the S3 credentials&#xA;the publisher uses, separate from the Cloudflare API token above.&lt;/p&gt;&#xA;&lt;h3 id=&#34;dns-and-a-custom-domain&#34;&gt;DNS and a custom domain&lt;/h3&gt;&#xA;&lt;p&gt;Two domains, ideally: one for the site, one for the assets. I serve the blog at &lt;code&gt;blog.i0.pm&lt;/code&gt;&#xA;and assets at &lt;code&gt;assets.i0.pm&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Site:&lt;/strong&gt; in the Pages project, &lt;em&gt;Custom domains&lt;/em&gt; → add &lt;code&gt;blog.example.com&lt;/code&gt;. If the zone is on&#xA;Cloudflare, the CNAME is created for you; if not, add a CNAME to &lt;code&gt;&amp;lt;project&amp;gt;.pages.dev&lt;/code&gt; and let&#xA;it verify.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Assets:&lt;/strong&gt; in the R2 bucket, &lt;em&gt;Settings&lt;/em&gt; → &lt;em&gt;Public access&lt;/em&gt; → &lt;em&gt;Custom domains&lt;/em&gt; → add&#xA;&lt;code&gt;assets.example.com&lt;/code&gt;. That public hostname is what you&#39;ll hand colophon as the R2 &lt;code&gt;public_url&lt;/code&gt;,&#xA;and until it resolves, the asset routing stays dormant and builds keep assets co-located. Handy&#xA;for local work.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;scaffold-the-project&#34;&gt;Scaffold the project&lt;/h2&gt;&#xA;&lt;p&gt;&lt;code&gt;init&lt;/code&gt; writes the project; &lt;code&gt;publish --create&lt;/code&gt; provisions the destinations later. Don&#39;t conflate&#xA;the two.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon init blog &amp;amp;&amp;amp; cd blog&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That gives you a &lt;code&gt;colophon.yaml&lt;/code&gt;, a &lt;code&gt;content/&lt;/code&gt; directory (which is also an Obsidian vault, if&#xA;you want it), an author, and a persona. Open &lt;code&gt;colophon.yaml&lt;/code&gt; and wire up the two publishers and&#xA;a production environment. The key point, and the thing the&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md#configuration-and-interpolation&#34;&gt;publishing docs&lt;/a&gt;&#xA;lean on hard: &lt;strong&gt;no secret ever goes in this file.&lt;/strong&gt; Non-secret settings use &lt;code&gt;{env:VAR}&lt;/code&gt;&#xA;interpolation; credentials are read straight from the environment and never touch the YAML.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;sites:&#xA;  - id: main&#xA;    title: &amp;#34;Your blog&amp;#34;&#xA;    base_url: &amp;#34;{env:SITE_URL:-http://localhost:8080}&amp;#34;&#xA;    routing:&#xA;      - match: &amp;#34;**/assets/**&amp;#34;   # heavy assets → R2, not the Pages bundle&#xA;        publisher: r2&#xA;      - match: &amp;#34;_search/**&amp;#34;     # search index fetched cross-origin → R2 (needs CORS)&#xA;        publisher: r2&#xA;&#xA;publishers:&#xA;  - id: cf&#xA;    driver: cloudflare-pages&#xA;    project: &amp;#34;{env:CF_PAGES_PROJECT:-my-blog}&amp;#34;&#xA;    account_id: &amp;#34;{env:CLOUDFLARE_ACCOUNT_ID}&amp;#34;&#xA;  - id: r2&#xA;    driver: cloudflare-r2&#xA;    bucket: &amp;#34;{env:R2_BUCKET:-my-blog-assets}&amp;#34;&#xA;    account_id: &amp;#34;{env:CLOUDFLARE_ACCOUNT_ID}&amp;#34;&#xA;    public_url: &amp;#34;{env:R2_PUBLIC_URL:-}&amp;#34;   # https://assets.example.com; empty keeps routing inert&#xA;&#xA;environments:&#xA;  - name: production&#xA;    publish: [cf, r2]&#xA;    allow_publish: false        # safety latch: deploy needs --allow-publish&#xA;    base_url: &amp;#34;https://blog.example.com&amp;#34;&#xA;  - name: preview&#xA;    publish: [cf, r2]&#xA;    include_drafts: true        # drafts visible here, never in production&#xA;    overrides:&#xA;      cf:&#xA;        branch: preview         # its own Pages branch / preview URL&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;colophon env&lt;/code&gt; lists every &lt;code&gt;{env:VAR}&lt;/code&gt; the project references, set or not, which is the quickest&#xA;way to see what you still owe it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;sites-publishers-environments-why-three-nouns&#34;&gt;Sites, publishers, environments: why three nouns&lt;/h2&gt;&#xA;&lt;p&gt;colophon deliberately splits the job three ways, and it&#39;s worth understanding because it&#39;s what&#xA;makes the rest painless. A &lt;strong&gt;site&lt;/strong&gt; is the content and its identity (title, theme, what gets&#xA;written). A &lt;strong&gt;publisher&lt;/strong&gt; is pure &lt;em&gt;mechanism&lt;/em&gt;: how to ship bytes somewhere (to Pages, to R2, to a&#xA;local folder), with no opinion about when or why. An &lt;strong&gt;environment&lt;/strong&gt; is the &lt;em&gt;policy&lt;/em&gt; that ties&#xA;them together: a named build-and-deploy profile that says which publishers to use, whether to&#xA;include drafts, and any overrides. The&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md&#34;&gt;publishing docs&lt;/a&gt; put it as&#xA;publishers being &lt;em&gt;how&lt;/em&gt; and environments being &lt;em&gt;what and where&lt;/em&gt;.&lt;/p&gt;&#xA;&lt;p&gt;That separation is why I run &lt;strong&gt;two environments off the one site&lt;/strong&gt;: &lt;code&gt;production&lt;/code&gt; and &lt;code&gt;preview&lt;/code&gt;.&#xA;They share the same content, the same publishers, the same R2 bucket; they differ only in policy.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;production&lt;/code&gt;&lt;/strong&gt; ships only finished posts (&lt;code&gt;draft: false&lt;/code&gt;), to &lt;code&gt;blog.example.com&lt;/code&gt;, on the&#xA;&lt;code&gt;main&lt;/code&gt; Pages branch, and it&#39;s behind the &lt;code&gt;allow_publish: false&lt;/code&gt; safety latch so a deploy is&#xA;never accidental.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;preview&lt;/code&gt;&lt;/strong&gt; sets &lt;code&gt;include_drafts: true&lt;/code&gt; and overrides the Pages branch to &lt;code&gt;preview&lt;/code&gt;, so it&#xA;builds the work-in-progress and Cloudflare serves it at its own preview URL. I can read a draft&#xA;on my phone, exactly as it&#39;ll render, before anyone else sees it.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;I publish both because they answer different questions. Preview is &amp;quot;does this actually look right,&#xA;live, with the real images and search index?&amp;quot; Production is &amp;quot;the world can read this now.&amp;quot;&#xA;Splitting them means a draft can be deployed somewhere real and private without ever risking the&#xA;public site, and promoting it is just flipping &lt;code&gt;draft: false&lt;/code&gt; and publishing production. Same&#xA;content, two policies, one command each:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon publish --env preview --allow-publish      # drafts, preview branch&#xA;colophon publish --env production --allow-publish    # finished posts only&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Nothing about either environment is special or built in; you could add a &lt;code&gt;staging&lt;/code&gt;, a&#xA;second-site mirror, or a local &lt;code&gt;dist&lt;/code&gt; target for diffing output. Two is just the smallest set&#xA;that gives me &amp;quot;private dress rehearsal&amp;quot; and &amp;quot;live&amp;quot; without duplicating a scrap of content.&lt;/p&gt;&#xA;&lt;h2 id=&#34;where-the-secrets-live&#34;&gt;Where the secrets live&lt;/h2&gt;&#xA;&lt;p&gt;The interpolated &lt;code&gt;{env:VAR}&lt;/code&gt; values split cleanly into two piles:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Not secret&lt;/strong&gt; (&lt;code&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt;, &lt;code&gt;CF_PAGES_PROJECT&lt;/code&gt;, &lt;code&gt;R2_BUCKET&lt;/code&gt;, &lt;code&gt;R2_PUBLIC_URL&lt;/code&gt;,&#xA;&lt;code&gt;SITE_URL&lt;/code&gt;): commit these to a &lt;code&gt;.env.defaults&lt;/code&gt; in the repo. colophon loads it automatically.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Secret&lt;/strong&gt; (&lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;, &lt;code&gt;R2_ACCESS_KEY_ID&lt;/code&gt;, &lt;code&gt;R2_SECRET_ACCESS_KEY&lt;/code&gt;,&#xA;&lt;code&gt;MINIMAX_API_KEY&lt;/code&gt;): never in the repo, never in config. These come from the environment at&#xA;publish time.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;How they reach the environment depends on where you are:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;&lt;strong&gt;Locally:&lt;/strong&gt; out of the Secret Service. I keep mine in &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt;&#xA;(the keyring replacement I wrote about in &lt;a href=&#34;/posts/rosec/&#34;&gt;Secrets, Minus the Keyring&lt;/a&gt;),&#xA;namespaced &lt;code&gt;COLOPHON_*&lt;/code&gt;, and pull them with &lt;code&gt;secret-tool&lt;/code&gt; at publish time so the tokens never sit&#xA;in a plaintext &lt;code&gt;.env&lt;/code&gt; or in my shell history. A small wrapper resolves them to the bare names&#xA;colophon wants, runs &lt;code&gt;colophon doctor&lt;/code&gt; as a preflight, then publishes:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;# store once: secret-tool store --label=&amp;#39;colophon: …&amp;#39; service colophon key COLOPHON_&amp;lt;NAME&amp;gt;&#xA;for name in CLOUDFLARE_API_TOKEN R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY MINIMAX_API_KEY; do&#xA;  export &amp;#34;$name=$(secret-tool lookup service colophon key &amp;#34;COLOPHON_$name&amp;#34;)&amp;#34;&#xA;done&#xA;colophon doctor &amp;amp;&amp;amp; colophon publish --env production --allow-publish&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;/li&gt;&#xA;&lt;li&gt;&#xA;&lt;p&gt;&lt;strong&gt;In CI:&lt;/strong&gt; as encrypted &lt;a href=&#34;https://docs.github.com/actions/security-guides/encrypted-secrets&#34;&gt;GitHub Actions&lt;/a&gt;&#xA;secrets, exposed to the job as env vars. colophon scaffolds a workflow that reads&#xA;&lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;, &lt;code&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt;, &lt;code&gt;R2_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;R2_SECRET_ACCESS_KEY&lt;/code&gt;&#xA;straight from &lt;code&gt;secrets.*&lt;/code&gt;. Same variable names, different source.&lt;/p&gt;&#xA;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The whole design is that the names are constant and only the &lt;em&gt;source&lt;/em&gt; changes, so the same&#xA;&lt;code&gt;colophon publish&lt;/code&gt; runs unmodified on your laptop and in Actions.&lt;/p&gt;&#xA;&lt;h2 id=&#34;provision-then-publish&#34;&gt;Provision, then publish&lt;/h2&gt;&#xA;&lt;p&gt;First time only, let colophon create the Pages project and the R2 bucket (idempotent, so it&#39;s&#xA;safe to leave in):&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon publish --env production --create --allow-publish&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;--create&lt;/code&gt; makes the Pages project, creates the bucket, and sets the bucket&#39;s CORS policy so the&#xA;cross-origin &lt;code&gt;fetch()&lt;/code&gt; of the search index works. A cross-origin &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; needs no CORS; a&#xA;&lt;code&gt;fetch()&lt;/code&gt; or an ES &lt;code&gt;import()&lt;/code&gt; does, which is why the search index specifically needs it. After&#xA;that, the daily incantation is just:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon publish --env production --allow-publish&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;h2 id=&#34;generative-images-and-a-spoken-reading&#34;&gt;Generative images and a spoken reading&lt;/h2&gt;&#xA;&lt;p&gt;This is the part I enjoy. colophon can generate hero images from a text prompt and a spoken&#xA;reading of each post. The provider is your choice: image generation can go through Google&#xA;GenAI or &lt;a href=&#34;https://openrouter.ai/&#34;&gt;OpenRouter&lt;/a&gt; just as happily, and you&#39;re not obliged to use&#xA;any of it. I picked &lt;a href=&#34;https://platform.minimax.io/&#34;&gt;MiniMax&lt;/a&gt; (the same provider&#xA;&lt;a href=&#34;/a-palette-from-a-prompt/&#34;&gt;tinct uses for palettes&lt;/a&gt;) because it&#39;s cheap and effective, and one&#xA;key, &lt;code&gt;MINIMAX_API_KEY&lt;/code&gt;, drives both the images and the speech. Swap the &lt;code&gt;provider:&lt;/code&gt; lines below&#xA;if you&#39;d rather use something else.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;generation:&#xA;  image:&#xA;    provider: minimax          # image-01; api_key falls back to MINIMAX_API_KEY&#xA;    system_prompt: &amp;#34;editorial tech illustration, dark muted palette, full-bleed, no text&amp;#34;&#xA;    defaults: { aspect: &amp;#34;16:9&amp;#34; }&#xA;  speech:&#xA;    provider: minimax          # speech-2.6-hd; same key&#xA;    enabled: true              # every post gets a reading unless it sets audio: false&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The clever bit, documented in&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/image-generation.md&#34;&gt;image-generation.md&lt;/a&gt;,&#xA;is that &lt;strong&gt;the repo is the cache.&lt;/strong&gt; Any &lt;code&gt;hero:&lt;/code&gt; or inline image whose value starts with &lt;code&gt;gen:&lt;/code&gt; is&#xA;generated from the prompt, content-addressed, and the result is committed to the repo. A normal&#xA;&lt;code&gt;colophon build&lt;/code&gt; ships whatever&#39;s cached and never calls the API; you only spend tokens when you&#xA;explicitly ask:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon build --generate-ai      # or: colophon publish ... --generate-ai&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;So a hero is just a sentence in the frontmatter:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;hero: &amp;#34;gen:a worn mechanical keyboard lit by a single monitor in a dark room&amp;#34;&#xA;hero_alt: A worn mechanical keyboard lit by a single monitor in a dark room&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Change the &lt;code&gt;system_prompt&lt;/code&gt; and you change the cache identity, so the next &lt;code&gt;--generate-ai&lt;/code&gt;&#xA;regenerates everything. Useful to know before you tweak it at midnight and wonder why your whole&#xA;back catalogue is redrawing.&lt;/p&gt;&#xA;&lt;p&gt;The reading uses one of the provider&#39;s stock voices by default, but you don&#39;t have to settle for&#xA;a stranger reading your words. MiniMax (and the others) let you &lt;strong&gt;clone a voice&lt;/strong&gt; from a short&#xA;sample of your own, which gives you a voice profile ID you can point colophon at. Set it once on&#xA;the author or persona as &lt;code&gt;voice:&lt;/code&gt;, or per post with &lt;code&gt;audio_voice:&lt;/code&gt;, and every reading goes out in&#xA;your own voice rather than a generic one:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-yaml&#34;&gt;audio_voice: &amp;#34;my-cloned-voice-id&amp;#34;   # frontmatter: this post in your own voice&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That&#39;s on my list: the default here is a placeholder until my own clone is trained.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-frontmatter-that-actually-matters&#34;&gt;The frontmatter that actually matters&lt;/h2&gt;&#xA;&lt;p&gt;You don&#39;t write most of this by hand, and that&#39;s the point. &lt;code&gt;colophon new post &amp;quot;Title&amp;quot;&lt;/code&gt; picks a&#xA;unique pinned &lt;code&gt;slug:&lt;/code&gt;, stamps the &lt;code&gt;date:&lt;/code&gt;, sets the byline &lt;code&gt;author:&lt;/code&gt; and the writing &lt;code&gt;persona:&lt;/code&gt;,&#xA;and leaves the post &lt;code&gt;draft: true&lt;/code&gt;. The fields worth understanding, all detailed in&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/content.md&#34;&gt;content.md&lt;/a&gt; and&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/seo.md&#34;&gt;seo.md&lt;/a&gt;:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;slug:&lt;/code&gt;&lt;/strong&gt;: pinned at creation so the URL never drifts when you retitle. Leave it alone.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;draft:&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;true&lt;/code&gt; keeps it out of production but visible in the &lt;code&gt;preview&lt;/code&gt; environment.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;description:&lt;/code&gt;&lt;/strong&gt;: the one-line summary used in listings and as an SEO fallback.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;hero:&lt;/code&gt; / &lt;code&gt;hero_alt:&lt;/code&gt;&lt;/strong&gt;: the banner image (a path, an &lt;code&gt;![[embed]]&lt;/code&gt;, or a &lt;code&gt;gen:&lt;/code&gt; prompt) and&#xA;its alt text. Always write real alt text for a meaningful image; an empty &lt;code&gt;hero_alt:&lt;/code&gt; marks a&#xA;purely decorative one.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;seo:&lt;/code&gt;&lt;/strong&gt;: an optional block for a distinct page title, keywords, and social-card copy when&#xA;the defaults aren&#39;t enough.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;audio: false&lt;/code&gt;&lt;/strong&gt;: opt a single post out of the spoken reading.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;writing-it-with-an-llm-in-your-own-voice&#34;&gt;Writing it with an LLM, in your own voice&lt;/h2&gt;&#xA;&lt;p&gt;If you&#39;re writing with an LLM or an agent harness, this is where it gets genuinely pleasant, and&#xA;it&#39;s the part I&#39;m most quietly pleased with. colophon ships a set of&#xA;&lt;a href=&#34;https://github.com/jmylchreest/colophon/blob/main/docs/skills.md&#34;&gt;agent skills&lt;/a&gt;, the most&#xA;important being &lt;em&gt;write&lt;/em&gt;. There are companions for metadata, cross-linking and publishing too, but&#xA;&lt;em&gt;write&lt;/em&gt; is the one that matters, because it solves the thing that makes LLM-written blogs so&#xA;obviously LLM-written: they all sound the same.&lt;/p&gt;&#xA;&lt;p&gt;The trick is that the model never starts from a blank prompt. Before it writes a word, the skill&#xA;runs:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon persona context default --topic &amp;#34;what the post is about&amp;#34;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That does two things. It hands over the persona&#39;s &lt;strong&gt;style guide&lt;/strong&gt; (the explicit rules: British&#xA;spelling, no em dashes, lead with the point, the tone), and then it reaches into the&#xA;&lt;strong&gt;corpus of your past posts&lt;/strong&gt; and pulls back the handful most relevant to this topic, ranked with&#xA;BM25. So a post about distributed systems gets fed your &lt;em&gt;actual&lt;/em&gt; previous writing on distributed&#xA;systems; a hardware teardown gets your teardowns. The model isn&#39;t told &amp;quot;write like a thoughtful&#xA;British engineer&amp;quot; in the abstract, it&#39;s shown concrete examples of how &lt;em&gt;you&lt;/em&gt; actually write,&#xA;about &lt;em&gt;this kind of thing&lt;/em&gt;, and asked to continue in that vein. The voice it produces is a&#xA;reflection of your own corpus, not a generic pastiche of one, and it sharpens as the corpus&#xA;grows: every post you publish becomes an exemplar the next one can learn from.&lt;/p&gt;&#xA;&lt;p&gt;With the voice loaded, &lt;code&gt;colophon new post&lt;/code&gt; scaffolds the file with the frontmatter contract&#xA;already correct, and the model fills the body and the prose-level fields (description, alt text,&#xA;tags), told not to touch the pinned slug or invent links. The harness handles the bookkeeping;&#xA;you (or the model in your voice) handle the words. This very post was drafted that way.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;colophon new post &amp;#34;Raft Leader Election&amp;#34; --author you --persona default --tag distributed-systems&#xA;colophon serve --open=latest    # preview before you flip draft: false&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Flip &lt;code&gt;draft: false&lt;/code&gt;, run the publish command, and it&#39;s live. One command, as promised. The&#xA;infrastructure is your own fault now, which is rather the appeal.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>A Palette from a Prompt</title>
    <id>https://blog.i0.pm/posts/tinct-minimax-input/</id>
    <link href="https://blog.i0.pm/posts/tinct-minimax-input/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-minimax-input-5741923b96b0.wav" rel="enclosure" type="audio/wav" length="3207604"></link>
    <updated>2026-06-20T00:00:00Z</updated>
    <published>2026-06-20T00:00:00Z</published>
    <summary type="text">tinct v0.4.0 adds a MiniMax input plugin, generating an image from a text prompt, extracting its palette, and exporting it as a wallpaper in one command.</summary>
    <content type="html">&lt;p&gt;A tinct palette can already come from a text prompt, not only from a wallpaper, a photo or a remote theme: it has generative inputs for Google GenAI and &lt;a href=&#34;https://openrouter.ai/&#34;&gt;OpenRouter&lt;/a&gt; already. v0.4.0 adds a third, &lt;strong&gt;minimax&lt;/strong&gt;, which generates an image from a prompt through &lt;a href=&#34;https://platform.minimax.io/&#34;&gt;MiniMax&lt;/a&gt;&#39;s Hailuo model (&lt;code&gt;image-01&lt;/code&gt;) and then extracts the palette the usual way, k-means over the pixels. What it brings to the line-up is being cheap and fast: a single image model, nothing to choose, and a quick endpoint.&lt;/p&gt;&#xA;&lt;p&gt;The nice part is that it&#39;s one step, not two. The same &lt;code&gt;tinct generate&lt;/code&gt; call that derives the palette also caches the generated image and exports it as a wallpaper, so a single command turns a prompt into a coherent desktop &lt;em&gt;and&lt;/em&gt; the wallpaper that matches it:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;export MINIMAX_API_KEY=&amp;#34;…&amp;#34;&#xA;tinct generate -i minimax \&#xA;  --ai.prompt &amp;#34;cyberpunk city street with neon signs at night&amp;#34; \&#xA;  -o hyprland,hyprpaper,kitty&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Generated images are cached under &lt;code&gt;~/.cache/tinct/generated/minimax/&lt;/code&gt;, keyed by a hash of the prompt and model, so re-running the same prompt is free. One wrinkle worth a fix: MiniMax occasionally pads its output with black letterbox bars, which would skew both the extracted palette and the wallpaper, so the plugin trims them before either step.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s a small addition, another generative backend sitting alongside Google and OpenRouter, so you can pick whichever provider suits your budget and taste and still theme straight from a prompt. AUR &lt;code&gt;tinct-bin&lt;/code&gt;; the full input-plugin list is on the &lt;a href=&#34;https://jmylchreest.github.io/tinct/&#34;&gt;tinct site&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Best Practices, Bootstrapped</title>
    <id>https://blog.i0.pm/posts/aide-blueprints/</id>
    <link href="https://blog.i0.pm/posts/aide-blueprints/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-blueprints-596270995887.wav" rel="enclosure" type="audio/wav" length="9922980"></link>
    <updated>2026-06-18T00:00:00Z</updated>
    <published>2026-06-18T00:00:00Z</published>
    <summary type="text">aide blueprints seed a new project&#39;s decision store with curated, composable best-practice decisions, auto-detected from project markers and tracked by version.</summary>
    <content type="html">&lt;p&gt;A fresh project starts with an empty head. &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;aide&lt;/a&gt; records the architectural decisions a codebase has made, so a session and every agent in it argue from the same facts. But on day one there are no decisions, and you&#39;re back to either re-litigating the same conventions you settle on in every repo, or letting the agent quietly invent its own.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Blueprints&lt;/strong&gt; are the fix: curated bundles of best-practice decisions you seed a project with in one command.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;aide blueprint import go&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That drops a set of idiomatic-Go decisions straight into the project&#39;s store: error handling, context use, table-driven tests, &lt;code&gt;slog&lt;/code&gt;, &lt;code&gt;golangci-lint&lt;/code&gt;, and so on. From that point they behave exactly like decisions you recorded by hand. They&#39;re injected into every session&#39;s context and enforced by all agents, so the conventions are present before the first line is written rather than discovered halfway through review.&lt;/p&gt;&#xA;&lt;h2 id=&#34;composable-not-monolithic&#34;&gt;Composable, not monolithic&lt;/h2&gt;&#xA;&lt;p&gt;The thing I like most is that blueprints stack. A blueprint can &lt;code&gt;include&lt;/code&gt; others, so they compose along the grain of how projects are actually built:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;# go, plus the CI/CD practices, plus the universals they both build on&#xA;aide blueprint import go-github-actions&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;go&lt;/code&gt; includes &lt;code&gt;general&lt;/code&gt; (commits, PRs, dependency hygiene, secrets), &lt;code&gt;general&lt;/code&gt; includes the documentation core, and &lt;code&gt;go-github-actions&lt;/code&gt; layers the CI specifics on top. You ask for the leaf you want and the chain underneath comes with it. No copy-paste, no drift between a dozen repos that all meant to follow the same rules.&lt;/p&gt;&#xA;&lt;h2 id=&#34;it-detects-what-youre-building&#34;&gt;It detects what you&#39;re building&lt;/h2&gt;&#xA;&lt;p&gt;You don&#39;t even have to name them. &lt;code&gt;--detect&lt;/code&gt; reads the project&#39;s markers and imports what fits:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;$ aide blueprint import --detect&#xA;Detected: go, github-actions, go-github-actions&#xA;&#xA;  general            5 new&#xA;  go                18 new&#xA;  github-actions     7 new&#xA;  go-github-actions  5 new&#xA;&#xA;35 imported, 0 updated&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A &lt;code&gt;go.mod&lt;/code&gt; triggers the &lt;code&gt;go&lt;/code&gt; pack; a &lt;code&gt;.github/workflows/&lt;/code&gt; directory triggers the &lt;code&gt;github-actions&lt;/code&gt; label; the two together resolve the compound &lt;code&gt;go-github-actions&lt;/code&gt; blueprint. It runs on the same project-marker index the grammar system uses, so a custom marker in your own &lt;code&gt;.aide/grammars/&lt;/code&gt; can trigger an org-specific blueprint automatically. Bootstrapping the house style across a fleet of repos becomes one command per repo.&lt;/p&gt;&#xA;&lt;h2 id=&#34;versioned-and-it-knows-what-it-set&#34;&gt;Versioned, and it knows what it set&lt;/h2&gt;&#xA;&lt;p&gt;Seeding state is easy; keeping it honest later is the part that usually rots. Blueprints carry a version, and an import is careful about what it touches. Anything &lt;em&gt;you&lt;/em&gt; set by hand is left alone. A decision a blueprint set earlier is upgraded only if the blueprint&#39;s version is newer and the content actually changed. Every imported decision is stamped &lt;code&gt;decided_by: blueprint:&amp;lt;name&amp;gt;@&amp;lt;version&amp;gt;&lt;/code&gt;, so you can always tell the curated baseline from the calls you made yourself.&lt;/p&gt;&#xA;&lt;p&gt;That provenance is what makes the whole thing safe to re-run. &lt;code&gt;aide blueprint import --detect&lt;/code&gt; on an existing project is a no-op where nothing moved and a clean upgrade where the upstream advice improved, and it never quietly overwrites a decision you made deliberately.&lt;/p&gt;&#xA;&lt;h2 id=&#34;roll-your-own-share-them-across-the-team&#34;&gt;Roll your own, share them across the team&lt;/h2&gt;&#xA;&lt;p&gt;The shipped blueprints are a starting point, not the ceiling. A blueprint is just a JSON file of decisions, so the more interesting use is writing your own: your team&#39;s conventions, the house style, the &amp;quot;we always do it &lt;em&gt;this&lt;/em&gt; way, and here&#39;s the reasoning&amp;quot; rules that currently live in a wiki page nobody reads twice. Drop one in &lt;code&gt;.aide/blueprints/&lt;/code&gt; to override a shipped blueprint locally, or publish a whole set for everyone.&lt;/p&gt;&#xA;&lt;p&gt;Sharing is deliberately boring. A registry is just a base URL that serves &lt;code&gt;&amp;lt;name&amp;gt;.json&lt;/code&gt; files, so any static host works: an internal web server, an S3 bucket, a GitLab repo on the intranet. Point a project&#39;s config at it once,&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-json&#34;&gt;// .aide/config/aide.json&#xA;{ &amp;#34;blueprints&amp;#34;: { &amp;#34;registries&amp;#34;: [&amp;#34;https://blueprints.corp.internal&amp;#34;] } }&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;and &lt;code&gt;aide blueprint import acme-standards&lt;/code&gt; pulls your org&#39;s rules exactly the way &lt;code&gt;go&lt;/code&gt; pulls the built-in ones. (There&#39;s a &lt;code&gt;--registry=&lt;/code&gt; flag for one-offs, and you can import a bare URL too.)&lt;/p&gt;&#xA;&lt;p&gt;The point, for a team, is that &amp;quot;the way we do things here&amp;quot; stops being tribal knowledge you hope new hires (and their AI assistants) absorb by osmosis. Write the guidelines, styles and general rules down once, host them where your devs already have access, and aide injects them into every session, so every human &lt;em&gt;and&lt;/em&gt; every agent on the project works from the same rules without anyone having to remember to bring them up.&lt;/p&gt;&#xA;&lt;h2 id=&#34;why-bother&#34;&gt;Why bother&lt;/h2&gt;&#xA;&lt;p&gt;The honest value isn&#39;t the import command, it&#39;s the curation. Writing down &amp;quot;use &lt;code&gt;errors.Is&lt;/code&gt;, not string matching&amp;quot; once, well, with the reasoning attached, and then having it apply to every Go project you or an agent ever touch, is a different thing from remembering to say it each time. The blueprint is the decisions; the command is just how they arrive. Repo and the full blueprint list are on &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Rebuilt the Blog. Again.</title>
    <id>https://blog.i0.pm/posts/rebuilt-the-blog-again/</id>
    <link href="https://blog.i0.pm/posts/rebuilt-the-blog-again/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rebuilt-the-blog-again-55de8f6ad78d.wav" rel="enclosure" type="audio/wav" length="1847984"></link>
    <updated>2026-06-18T00:00:00Z</updated>
    <published>2026-06-18T00:00:00Z</published>
    <summary type="text">Moving off Hugo and onto colophon, and why the old site never had anything on it anyway.</summary>
    <content type="html">&lt;p&gt;The previous incarnation of this blog ran on Hugo with a rather nice theme and exactly zero real posts. It had a first post that began &amp;quot;Lorem ipsum dolor sit amet&amp;quot; and an about page extolling Bootstrap 5. A monument to good intentions, lovingly version-controlled and never once written in.&lt;/p&gt;&#xA;&lt;p&gt;So this is less a migration than a fresh start. There was nothing to port, just demo content and a title I&#39;m rather fond of.&lt;/p&gt;&#xA;&lt;p&gt;This time it runs on &lt;a href=&#34;https://github.com/jmylchreest/colophon&#34;&gt;colophon&lt;/a&gt;, a static-site generator I&#39;ve been writing. Markdown in, static HTML out; an Obsidian vault as the source, so I can scribble a note and have it become a post without leaving my editor. Search is static and on-device, there&#39;s no JavaScript required to read anything, and the whole thing deploys with one command.&lt;/p&gt;&#xA;&lt;p&gt;Whether &lt;em&gt;this&lt;/em&gt; version accumulates more than one post remains to be seen. But the infrastructure is, at least, my own fault now.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Learning From the Session</title>
    <id>https://blog.i0.pm/posts/aide-instinct/</id>
    <link href="https://blog.i0.pm/posts/aide-instinct/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-instinct-d886c006b00d.wav" rel="enclosure" type="audio/wav" length="3761316"></link>
    <updated>2026-06-15T00:00:00Z</updated>
    <published>2026-06-15T00:00:00Z</published>
    <summary type="text">aide instruments its own sessions and, with the opt-in reflect hook, proposes memories from the patterns it sees, repeated corrections and failing tools, via its instinct system.</summary>
    <content type="html">&lt;p&gt;aide&#39;s memory has always been something you write: you tell it a preference, it remembers. The more interesting question is whether it can notice things you &lt;em&gt;didn&#39;t&lt;/em&gt; think to write down. That&#39;s what the &lt;strong&gt;instinct&lt;/strong&gt; system is for, and it leans on a quieter piece of plumbing that landed first: observability.&lt;/p&gt;&#xA;&lt;p&gt;aide now instruments itself. Every tool call, every skill it injects, every session is an observed event with a span and a cost, the same stream that feeds the token dashboard. Instinct reads that stream looking for patterns worth keeping: the same correction you&#39;ve made three times, a tool that keeps failing the same way (friction is a strong signal that something&#39;s wrong with the convention, not just the call). When a pattern converges, aide proposes it as a candidate memory, which you accept or bin. The point is that the assistant stops repeating a mistake because the lesson got written down, by the tool, from watching, rather than waiting for you to notice and dictate it.&lt;/p&gt;&#xA;&lt;p&gt;The mechanism is a Stop hook called &lt;strong&gt;reflect&lt;/strong&gt;: at the end of a session it runs the instinct parser over that session&#39;s observe events and surfaces the proposals. It&#39;s deliberately opt-in and still experimental, so it&#39;s off by default; you turn it on with &lt;code&gt;AIDE_REFLECT=1&lt;/code&gt; or &lt;code&gt;&amp;quot;reflect&amp;quot;: { &amp;quot;enabled&amp;quot;: true }&lt;/code&gt; in &lt;code&gt;.aide/config/aide.json&lt;/code&gt;. The newest tier adds an LLM judge for promotion (is this repetition actually a rule, or coincidence?) and a friction detector that treats repeated tool failures as live signal.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s the part of aide I&#39;m least sure how far to push, which is exactly why it&#39;s behind a flag. Memory you write is predictable; memory the tool proposes from watching you is powerful and occasionally presumptuous, and I&#39;d rather earn that trust than assume it. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Two Razer Naga Buttons, One Keycode</title>
    <id>https://blog.i0.pm/posts/razer-naga-duplicate-buttons/</id>
    <link href="https://blog.i0.pm/posts/razer-naga-duplicate-buttons/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-razer-naga-duplicate-buttons-4a7698cc736b.wav" rel="enclosure" type="audio/wav" length="10965918"></link>
    <updated>2026-06-15T00:00:00Z</updated>
    <published>2026-06-15T00:00:00Z</published>
    <summary type="text">input-remapper couldn&#39;t tell two of my mouse&#39;s side buttons apart. No Linux tool can; the fix is a firmware mode toggle, not a remapper.</summary>
    <content type="html">&lt;p&gt;I have a Razer Naga V2 HyperSpeed. The whole point of it is the twelve-button grid under your thumb, so I sat down to remap those buttons on Linux with &lt;a href=&#34;https://github.com/sezanzeb/input-remapper&#34;&gt;input-remapper&lt;/a&gt;, expecting a five-minute job.&lt;/p&gt;&#xA;&lt;p&gt;It went wrong immediately. Button 11 did exactly what button 6 did; button 12 did exactly what button 2 did. Not &amp;quot;similar&amp;quot;: &lt;code&gt;evtest&lt;/code&gt; showed them emitting &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; scancodes (&lt;code&gt;7002d&lt;/code&gt; → &lt;code&gt;KEY_MINUS&lt;/code&gt; for both 6 and 11). input-remapper saw them as the same key, so there was nothing to remap.&lt;/p&gt;&#xA;&lt;p&gt;The instinct is to go looking for a better remapper. That instinct is wrong, and it&#39;s worth seeing why. &lt;code&gt;MSC_SCAN&lt;/code&gt; is the HID usage code, what the mouse literally puts on the wire. If two buttons send the same usage, they send the same scancode; there&#39;s no hidden serial number. evdev hands userspace &lt;code&gt;(type, code, value)&lt;/code&gt; plus that scancode hint, and that&#39;s the &lt;em&gt;entire&lt;/em&gt; vocabulary. If two events are identical across all of it, &lt;strong&gt;any&lt;/strong&gt; userspace remapper is downstream of the collision. You can&#39;t branch on information that doesn&#39;t exist.&lt;/p&gt;&#xA;&lt;p&gt;So the duplicate isn&#39;t made in software; it&#39;s made upstream, in the mouse, before Linux ever sees it. The Naga has onboard memory, and mine had a junk profile baked into it (button 1 was mapped to &lt;code&gt;Shift+3&lt;/code&gt;, and two pairs of buttons collided onto one key). Normally you&#39;d rewrite those slots with Razer Synapse on Windows. Nothing on Linux can: OpenRazer doesn&#39;t do button remapping, libratbag refuses Razer over the EULA, and the web tools don&#39;t cover this model.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-fix-driver-mode&#34;&gt;The fix: driver mode&lt;/h2&gt;&#xA;&lt;p&gt;Razer mice expose a &lt;code&gt;device_mode&lt;/code&gt; switch through the OpenRazer kernel driver. Normal mode (&lt;code&gt;0x00&lt;/code&gt;) replays the onboard profile, my broken one. &lt;strong&gt;Driver mode&lt;/strong&gt; (&lt;code&gt;0x03&lt;/code&gt;) makes the firmware ignore the profile and emit its raw factory layout: twelve unique keys. One write does it:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;echo -n -e &amp;#39;\x03\x00&amp;#39; | sudo tee /sys/bus/hid/devices/*1532*00B4*/device_mode&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;And &lt;code&gt;evtest&lt;/code&gt; immediately goes clean: button 6 is &lt;code&gt;KEY_6&lt;/code&gt;, button 11 is &lt;code&gt;KEY_MINUS&lt;/code&gt;. The collision is gone because the firmware stopped lying.&lt;/p&gt;&#xA;&lt;p&gt;It resets on power cycle, so you need to re-apply it. The cleanest way is to let OpenRazer own it: it re-applies on startup, hotplug &lt;em&gt;and&lt;/em&gt; resume, by setting &lt;code&gt;driver_mode = true&lt;/code&gt; per device in &lt;code&gt;~/.config/openrazer/razer.conf&lt;/code&gt;. (One trap: the section is keyed by serial, and this unit&#39;s serial read is flaky, so I declared both the real serial and the &lt;code&gt;UNKNOWN_…&lt;/code&gt; fallback.) A udev rule on &lt;code&gt;bind&lt;/code&gt; works too, but won&#39;t survive suspend.&lt;/p&gt;&#xA;&lt;p&gt;That covers boot, hotplug and resume, which is most of it. Every so often a wireless wake still comes back with the broken profile reasserted, so rather than dig through anything mid-game I keep a one-tap &amp;quot;fix my mouse&amp;quot; bind. No &lt;code&gt;sudo&lt;/code&gt; this time: I&#39;m in the &lt;code&gt;openrazer&lt;/code&gt; group, so the udev-owned sysfs is writable directly, and a tiny script forces driver mode then re-applies my DPI:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-ini&#34;&gt;bindd = $mainMod, Pause, Naga driver mode, exec, ~/.local/bin/naga-driver-mode&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;#!/bin/sh&#xA;# ~/.local/bin/naga-driver-mode&#xA;for dm in /sys/bus/hid/devices/*1532*00B4*/device_mode; do&#xA;    [ -e &amp;#34;$dm&amp;#34; ] || continue&#xA;    printf &amp;#39;\003\000&amp;#39; &amp;gt; &amp;#34;$dm&amp;#34;&#xA;done&#xA;~/.config/hypr/scripts/naga-dpi-cycle.sh apply   # a wake can reset DPI too&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;A button starts lying, I hit the chord, and the mouse is honest again before I&#39;ve lost the fight.&lt;/p&gt;&#xA;&lt;p&gt;A few side effects come with bypassing the onboard profile. The dedicated DPI button stops cycling, so I drive DPI from Hyprland instead (below). Reading DPI and &lt;code&gt;device_mode&lt;/code&gt; back both lie even though writes work fine. And the battery never shows up in UPower for this mouse, so I read the charge level straight from OpenRazer (&lt;code&gt;charge_level&lt;/code&gt; in sysfs, 0–255, or over its D-Bus). None are deal breakers, just the tax for making the hardware honest.&lt;/p&gt;&#xA;&lt;h2 id=&#34;getting-dpi-back&#34;&gt;Getting DPI back&lt;/h2&gt;&#xA;&lt;p&gt;Driver mode has one cost worth its own section: the onboard DPI button stops cycling, because the cycling &lt;em&gt;was&lt;/em&gt; the onboard profile. OpenRazer can still set DPI by writing the &lt;code&gt;dpi&lt;/code&gt; sysfs, though, so I moved the whole thing into Hyprland.&lt;/p&gt;&#xA;&lt;p&gt;The two profile buttons either side of the wheel emit &lt;code&gt;F13&lt;/code&gt; and &lt;code&gt;F14&lt;/code&gt;, which Hyprland sees as &lt;code&gt;code:191&lt;/code&gt; and &lt;code&gt;code:192&lt;/code&gt;. I bind those to a small stage-cycler:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-ini&#34;&gt;bindd = , code:191, Naga DPI down, exec, ~/.config/hypr/scripts/naga-dpi-cycle.sh down&#xA;bindd = , code:192, Naga DPI up,   exec, ~/.config/hypr/scripts/naga-dpi-cycle.sh up&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The script keeps a list of DPI stages, tracks the current one in a state file (so it survives a reboot), and writes the chosen value to the &lt;code&gt;dpi&lt;/code&gt; sysfs as four big-endian bytes (X-hi, X-lo, Y-hi, Y-lo). It never reads DPI back, because that&#39;s the part that lies; it only writes.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;#!/usr/bin/env bash&#xA;# naga-dpi-cycle.sh [up|down|apply]: step the Naga through DPI stages.&#xA;# (Trimmed here of the device-not-found guard and the notify-send.)&#xA;set -eu&#xA;STAGES=(400 800 1000 1200 1600 3200)&#xA;state=&amp;#34;${XDG_STATE_HOME:-$HOME/.local/state}/naga-dpi-stage&amp;#34;&#xA;dpi=$(ls /sys/bus/hid/devices/*1532*00B4*/dpi | head -1)&#xA;last=$(( ${#STAGES[@]} - 1 ))&#xA;&#xA;idx=$(cat &amp;#34;$state&amp;#34; 2&amp;gt;/dev/null || echo 3)        # default: stage 4 (1200 dpi)&#xA;case &amp;#34;${1:-apply}&amp;#34; in&#xA;    up)   idx=$(( idx &amp;lt; last ? idx + 1 : last )) ;;&#xA;    down) idx=$(( idx &amp;gt; 0    ? idx - 1 : 0    )) ;;&#xA;esac&#xA;printf &amp;#39;%s&amp;#39; &amp;#34;$idx&amp;#34; &amp;gt; &amp;#34;$state&amp;#34;&#xA;&#xA;val=${STAGES[idx]}; hi=$(( (val &amp;gt;&amp;gt; 8) &amp;amp; 0xff )); lo=$(( val &amp;amp; 0xff ))&#xA;printf &amp;#34;$(printf &amp;#39;\\x%02x\\x%02x\\x%02x\\x%02x&amp;#39; &amp;#34;$hi&amp;#34; &amp;#34;$lo&amp;#34; &amp;#34;$hi&amp;#34; &amp;#34;$lo&amp;#34;)&amp;#34; &amp;gt; &amp;#34;$dpi&amp;#34;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;One &lt;code&gt;exec-once&lt;/code&gt; restores the last stage on login, since a wireless wake can reset that too:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-ini&#34;&gt;exec-once = ~/.config/hypr/scripts/naga-dpi-cycle.sh apply&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The short version: if two buttons send identical evdev events, stop blaming the remapper and go fix the mouse.&lt;/p&gt;&#xA;&lt;p&gt;For now driver mode is the answer, and it holds. One day I might give in, boot Windows, and write a clean profile with Synapse just once, so the hardware behaves on its own and I can drop the whole arrangement. Or, better yet, Razer decides Linux is worth treating as a first-class citizen and none of this song and dance is necessary. I&#39;m not holding my breath. I would, though, love to be wrong.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>One Core, Many Bootloaders</title>
    <id>https://blog.i0.pm/posts/refind-btrfs-one-core-many-bootloaders/</id>
    <link href="https://blog.i0.pm/posts/refind-btrfs-one-core-many-bootloaders/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-refind-btrfs-one-core-many-bootloaders-b9c6fa0276f7.wav" rel="enclosure" type="audio/wav" length="7121570"></link>
    <updated>2026-05-29T00:00:00Z</updated>
    <published>2026-05-29T00:00:00Z</published>
    <summary type="text">refind-btrfs-snapshots grows beyond rEFInd. A shared core now drives BLS entries and per-snapshot Unified Kernel Images, with a pure-Go signer, and multi-profile UKIs in design.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/refind-btrfs-snapshots&#34;&gt;refind-btrfs-snapshots&lt;/a&gt; started life answering one question, &amp;quot;how do I make rEFInd offer my btrfs snapshots?&amp;quot;, but the interesting work, snapshot discovery, kernel inspection and fstab alignment, was never really about rEFInd. It was about figuring out &lt;em&gt;how a given snapshot wants to boot&lt;/em&gt;. rEFInd was just the first thing I rendered that into.&lt;/p&gt;&#xA;&lt;p&gt;So the project has been refactored into a shared core with a focused binary per boot story:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;refind-btrfs-snapshots&lt;/code&gt;&lt;/strong&gt;: rEFInd submenu entries (where it began).&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;bls-btrfs-snapshots&lt;/code&gt;&lt;/strong&gt;: Boot Loader Spec entries for systemd-boot and BLS-aware GRUB.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;uki-btrfs-snapshots&lt;/code&gt;&lt;/strong&gt;: per-snapshot Unified Kernel Images (more on this below).&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;peseal&lt;/code&gt;&lt;/strong&gt;: a pure-Go Authenticode signer for PE/UKI binaries, &lt;code&gt;sbctl&lt;/code&gt;-compatible, so the UKI path stays CGO-free.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;kernel-spy&lt;/code&gt;&lt;/strong&gt;: a read-only diagnostic that dumps every kernel, initramfs, microcode and UKI the discovery layer can see.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;One core, many front-ends: the same shape I keep coming back to, because the hard part is the analysis, not the output format.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-uki-problem&#34;&gt;The UKI problem&lt;/h2&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://uapi-group.org/specifications/specs/unified_kernel_image/&#34;&gt;Unified Kernel Images&lt;/a&gt; are where this gets genuinely tricky, and they&#39;re the &amp;quot;support other formats more generically&amp;quot; goal I&#39;d been circling. A UKI bundles kernel, initramfs, cmdline and os-release into one signed EFI binary, and crucially the cmdline lives &lt;em&gt;inside&lt;/em&gt; the image, in its &lt;code&gt;.cmdline&lt;/code&gt; section. Under Secure Boot that embedded cmdline is authoritative: the boot loader can&#39;t override it with an external &lt;code&gt;options=&lt;/code&gt; string.&lt;/p&gt;&#xA;&lt;p&gt;That&#39;s fatal for snapshot booting, because a snapshot-bootable cmdline &lt;em&gt;has&lt;/em&gt; to differ per snapshot: it carries &lt;code&gt;rootflags=subvol=&amp;lt;snapshot&amp;gt;&lt;/code&gt;. Neither the rEFInd nor the BLS output can fix this, because the thing that needs to change is sealed inside the UKI. The cmdline has to go &lt;em&gt;inside&lt;/em&gt; an image the firmware will actually run.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;What ships today&lt;/strong&gt; is Mode 1, &lt;em&gt;cloned UKIs&lt;/em&gt;. For each bootable snapshot, clone the source UKI into &lt;code&gt;&amp;lt;esp&amp;gt;/EFI/Linux/&lt;/code&gt;, copying the kernel and initrd straight across and rewriting only the &lt;code&gt;.cmdline&lt;/code&gt; to target that snapshot&#39;s subvolume. It costs a full UKI&#39;s worth of space per snapshot, but it boots anywhere that can launch an &lt;code&gt;.efi&lt;/code&gt;, and &lt;code&gt;peseal&lt;/code&gt; signs each one so Secure Boot stays intact.&lt;/p&gt;&#xA;&lt;h2 id=&#34;whats-still-in-design&#34;&gt;What&#39;s still in design&lt;/h2&gt;&#xA;&lt;p&gt;The cheaper approach is Mode 2, a single &lt;strong&gt;multi-profile UKI&lt;/strong&gt; per kernel, with one &lt;code&gt;.profile&lt;/code&gt; section per snapshot sharing the kernel and initrd and carrying only its own cmdline. It&#39;s a lovely idea with a sharp edge: today, passing a profile selector at boot is essentially a systemd-boot story, so it isn&#39;t yet the universal answer cloning is. That, plus a proper &lt;strong&gt;reconciliation engine&lt;/strong&gt; (compute the desired set of entries from scratch each run, diff against what&#39;s on the ESP, delete before adding, and pre-flight the free space so it refuses loudly rather than half-filling your boot partition), is written up in the project&#39;s wishlist and is what I&#39;m building toward next.&lt;/p&gt;&#xA;&lt;p&gt;The throughline: a snapshot you can&#39;t boot is just a backup you haven&#39;t tested. The point of all five binaries is to make &amp;quot;boot last Tuesday&amp;quot; work whatever your firmware, bootloader and Secure Boot setup happen to be. Repo and wishlist on &lt;a href=&#34;https://github.com/jmylchreest/refind-btrfs-snapshots&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Many Agents, One Repo</title>
    <id>https://blog.i0.pm/posts/aide-swarm/</id>
    <link href="https://blog.i0.pm/posts/aide-swarm/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-swarm-bbb2337ae718.wav" rel="enclosure" type="audio/wav" length="2909052"></link>
    <updated>2026-05-25T00:00:00Z</updated>
    <published>2026-05-25T00:00:00Z</published>
    <summary type="text">aide&#39;s swarm runs parallel AI agents in isolated git worktrees through a real SDLC pipeline, coordinated over gRPC with atomic task claiming. Orchestration, not a free-for-all.</summary>
    <content type="html">&lt;p&gt;&amp;quot;Run a bunch of agents in parallel&amp;quot; is the kind of thing that demos beautifully and then corrupts your working tree the first time two of them edit the same file. The hard part of multi-agent work was never spawning the agents; it&#39;s keeping them from standing on each other. That&#39;s what aide&#39;s &lt;strong&gt;swarm&lt;/strong&gt; is actually about, and it just grew a proper coordination layer.&lt;/p&gt;&#xA;&lt;p&gt;Two design choices do most of the work.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Isolated git worktrees.&lt;/strong&gt; Each agent works in its own worktree, not the shared checkout. They can edit, build and test in parallel without colliding, and the results merge back deliberately rather than racing. A blast radius of one worktree is a very different thing from a blast radius of your branch.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Atomic task claiming.&lt;/strong&gt; Work is a queue of tasks, and an agent &lt;em&gt;claims&lt;/em&gt; one atomically before touching it: no two agents pick up the same task, and a crashed agent&#39;s task is reclaimable rather than lost. With the new coordination layer that&#39;s gRPC streaming over the daemon, an agent CLI to drive it, and a dashboard to watch it happen.&lt;/p&gt;&#xA;&lt;p&gt;The other half is that the swarm runs work through an actual &lt;strong&gt;SDLC pipeline&lt;/strong&gt; (design → test → implement → verify → docs) rather than asking one agent to do everything in one shot. Each stage is a checkpoint, which is how you get a reviewable result out the far end instead of a pile of plausible diffs.&lt;/p&gt;&#xA;&lt;p&gt;None of this is &amp;quot;AGI writes your app while you sleep.&amp;quot; It&#39;s the unglamorous orchestration (worktrees, queues, claims, stages) that makes parallel agents &lt;em&gt;safe&lt;/em&gt; enough to be useful. Which, predictably, was far more work than the demo version. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>awob: Another Wayland Overlay Bar</title>
    <id>https://blog.i0.pm/posts/awob-another-wayland-osd-bar/</id>
    <link href="https://blog.i0.pm/posts/awob-another-wayland-osd-bar/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-awob-another-wayland-osd-bar-e680ccb443e9.wav" rel="enclosure" type="audio/wav" length="9982734"></link>
    <updated>2026-05-04T00:00:00Z</updated>
    <published>2026-05-04T00:00:00Z</published>
    <summary type="text">A drop-in replacement for wob with KDL theming, typed IPC, and a small ecosystem of self-supervising event listeners.</summary>
    <content type="html">&lt;blockquote&gt;&#xA;&lt;p&gt;If &lt;a href=&#34;https://github.com/francma/wob&#34;&gt;wob&lt;/a&gt; covers what you need, &lt;strong&gt;use wob&lt;/strong&gt;. It&#39;s tiny, fast,&#xA;and battle-tested. awob exists for the moment where you want one more thing wob doesn&#39;t try&#xA;to give you.&lt;/p&gt;&#xA;&lt;/blockquote&gt;&#xA;&lt;p&gt;I&#39;ve been using wob for years. It does exactly one thing, reading a number from a FIFO and drawing a bar, and it does it without ceremony. Volume up? A bar. Brightness down? A bar. Battery at 5%? A red-ish bar. That&#39;s the entire shape of it, and most days it&#39;s exactly the right shape.&lt;/p&gt;&#xA;&lt;p&gt;But the moments where it isn&#39;t quite enough kept piling up. I wanted an icon next to the bar to remind me whether I was looking at volume or brightness. I wanted the active sink&#39;s name when the volume changed. I wanted a different style for &amp;quot;muted&amp;quot; versus &amp;quot;loud&amp;quot;. I wanted a battery OSD to fire on AC plug &lt;em&gt;immediately&lt;/em&gt;, not twenty seconds later when UPower&#39;s polling timer caught up.&lt;/p&gt;&#xA;&lt;p&gt;Each of those was solvable in shell glue: a &lt;code&gt;wob&lt;/code&gt; call wrapped in a script that read the audio state and rendered text with &lt;code&gt;notify-send&lt;/code&gt;. But that&#39;s not really wob&#39;s fault. It&#39;s me wanting something wob doesn&#39;t try to be. So I built &lt;strong&gt;awob&lt;/strong&gt;, &lt;em&gt;Another Wayland Overlay Bar&lt;/em&gt;.&lt;/p&gt;&#xA;&lt;p&gt;&lt;video class=&#34;post-video&#34; controls preload=&#34;metadata&#34; playsinline aria-label=&#34;awob cycling through its shipped themes&#34;&gt;&lt;source src=&#34;https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/posts/assets/awob-demo.mp4&#34; type=&#34;video/mp4&#34;&gt;&lt;/video&gt;&lt;/p&gt;&#xA;&lt;h2 id=&#34;whats-different&#34;&gt;What&#39;s different&lt;/h2&gt;&#xA;&lt;p&gt;Same shape as wob: a transient overlay that appears, draws something, fades out. Same Wayland surface model (&lt;code&gt;wlr-layer-shell-v1&lt;/code&gt;, no GTK, no Qt). What changed:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;The theme is a data file, not a soup of CLI flags.&lt;/strong&gt; The whole visual is described in a &lt;a href=&#34;https://kdl.dev&#34;&gt;KDL&lt;/a&gt; scene file: a palette, named styles, an element tree (rectangles, text, icons, the bar) and an animation timeline. Hot-reloaded on save: change a colour, see it on the next OSD.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Listeners are processes, not shell glue.&lt;/strong&gt; PipeWire volume, sysfs battery via udev, backlight, keyboard backlight; each is its own small binary that subscribes to its upstream and forwards typed events. The daemon&#39;s supervisor auto-discovers them on &lt;code&gt;PATH&lt;/code&gt; and respawns crashes.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;IPC is typed, not positional.&lt;/strong&gt; Instead of writing &lt;code&gt;78 critical&lt;/code&gt; into a FIFO you send &lt;code&gt;awob send --preempt --icon audio-volume-high volume 78 100&lt;/code&gt;. The wob FIFO format still works through a shim listener, so existing keybinds keep working.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;a-theme-is-one-file&#34;&gt;A theme is one file&lt;/h2&gt;&#xA;&lt;p&gt;The default theme is deliberately &amp;quot;every concept the engine supports, in one self-contained file&amp;quot;. It doubles as documentation:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-kdl&#34;&gt;palette {&#xA;    bg     &amp;#34;rgba(28,28,35,0.85)&amp;#34;&#xA;    fg     &amp;#34;#f3e8d7&amp;#34;&#xA;    track  &amp;#34;rgba(255,255,255,0.08)&amp;#34;&#xA;    low    &amp;#34;#8fdc55&amp;#34;&#xA;    normal &amp;#34;#baea96&amp;#34;&#xA;    warn   &amp;#34;#e89a49&amp;#34;&#xA;    crit   &amp;#34;#dc8855&amp;#34;&#xA;}&#xA;&#xA;surface {&#xA;    width 360 height 64 anchor &amp;#34;bottom&amp;#34; offset 0 -56&#xA;    fade-in &amp;#34;150ms&amp;#34;  show &amp;#34;2000ms&amp;#34;  fade-out &amp;#34;150ms&amp;#34;&#xA;}&#xA;&#xA;scene {&#xA;    rect z=0 width=&amp;#34;100%&amp;#34; height=&amp;#34;100%&amp;#34; radius=12 fill=&amp;#34;$bg&amp;#34; shadow=&amp;#34;0 8 24 rgba(0,0,0,0.4)&amp;#34;&#xA;    image z=1 src=&amp;#34;{$icon ?? icon($event)}&amp;#34; x=14 y=&amp;#34;center&amp;#34; width=22 height=22&#xA;    text z=1 value=&amp;#34;{truncate($app ?? label($event), 24)}&amp;#34; x=46 y=14 font=&amp;#34;Inter 14 500&amp;#34; colour=&amp;#34;$fg&amp;#34;&#xA;    text z=1 anchor=&amp;#34;top-right&amp;#34; value=&amp;#34;{int($progress * 100)}%&amp;#34; x=14 y=14 font=&amp;#34;Inter 14 500&amp;#34; colour=&amp;#34;$fg&amp;#34;&#xA;    bar z=2 x=46 y=42 width=&amp;#34;100%-60&amp;#34; height=8 radius=999 fill=&amp;#34;$accent&amp;#34; min=0 max=&amp;#34;$max&amp;#34; value=&amp;#34;$value&amp;#34; from=&amp;#34;{$lastValue ?? $value}&amp;#34;&#xA;}&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;&lt;code&gt;$value&lt;/code&gt;, &lt;code&gt;$max&lt;/code&gt;, &lt;code&gt;$lastValue&lt;/code&gt;, &lt;code&gt;$event&lt;/code&gt;, &lt;code&gt;$app&lt;/code&gt;, &lt;code&gt;$icon&lt;/code&gt; are bindings populated at send-time; &lt;code&gt;$lastValue&lt;/code&gt; is what makes the bar tween smoothly between consecutive sends. A second shipped theme, &lt;code&gt;wob&lt;/code&gt;, is a pixel-faithful clone of upstream (so existing rices keep working), and you switch between them at runtime with &lt;code&gt;awob theme set wob&lt;/code&gt;.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-listener-that-justified-the-design&#34;&gt;The listener that justified the design&lt;/h2&gt;&#xA;&lt;p&gt;The interesting case is the battery listener. The first cut used UPower over D-Bus, because that&#39;s what every other Linux app does. The problem: UPower polls the kernel for some events on a thirty-second timer, so plugging in AC at 11% wouldn&#39;t fire the &amp;quot;charging&amp;quot; OSD for up to half a minute.&lt;/p&gt;&#xA;&lt;p&gt;But sysfs has the data already. &lt;code&gt;/sys/class/power_supply/BAT0/status&lt;/code&gt; flips from &lt;code&gt;Discharging&lt;/code&gt; to &lt;code&gt;Charging&lt;/code&gt; the instant the kernel fires a &lt;code&gt;power_supply&lt;/code&gt; uevent. So: drop UPower, subscribe to udev, read sysfs directly. AC plug to &amp;quot;charging&amp;quot; OSD now lands in a few hundred milliseconds, and the zbus dependency falls out of the listener entirely.&lt;/p&gt;&#xA;&lt;p&gt;There&#39;s a wrinkle: on some hardware (Dell, ThinkPad, Framework) the battery driver lags the AC-adapter uevent by a few seconds, so reading sysfs at AC-event time sees the &lt;em&gt;old&lt;/em&gt; state. A five-second burst-poll window after any uevent re-reads sysfs every second and handles it cleanly. Adding a new event source is a new binary, not a daemon patch, which is the whole point.&lt;/p&gt;&#xA;&lt;h2 id=&#34;five-minute-install-on-arch&#34;&gt;Five-minute install on Arch&lt;/h2&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;paru -S awob-bin awob-listeners-all&#xA;systemctl --user enable --now awob.service&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That&#39;s enough anywhere systemd wires up &lt;code&gt;graphical-session.target&lt;/code&gt; (Hyprland with uwsm, GNOME, KDE). On Hyprland without uwsm, add &lt;code&gt;exec-once = awob-daemon&lt;/code&gt; to &lt;code&gt;hyprland.conf&lt;/code&gt; instead.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-it-deliberately-isnt&#34;&gt;What it deliberately isn&#39;t&lt;/h2&gt;&#xA;&lt;p&gt;It&#39;s not a notification daemon: for toast notifications run &lt;a href=&#34;https://github.com/emersion/mako&#34;&gt;mako&lt;/a&gt;, &lt;a href=&#34;https://github.com/ErikReider/SwayNotificationCenter&#34;&gt;swaync&lt;/a&gt;, or &lt;a href=&#34;https://github.com/jmylchreest/histui&#34;&gt;histui&lt;/a&gt; (the one I run alongside it). It&#39;s not cross-platform; Wayland and Linux only. And it&#39;s not a kitchen-sink OSD framework: rectangles, text, icons, a bar, a shadow, a value tween. That&#39;s the lot.&lt;/p&gt;&#xA;&lt;p&gt;Pre-1.0 as I write this, so the wire format and theme schema may still shift; pin a version in scripts. Repo and docs are on &lt;a href=&#34;https://github.com/jmylchreest/awob&#34;&gt;GitHub&lt;/a&gt;; issues and PRs welcome.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Filling Out the Terminal</title>
    <id>https://blog.i0.pm/posts/tinct-terminal-tooling/</id>
    <link href="https://blog.i0.pm/posts/tinct-terminal-tooling/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-terminal-tooling-438814dd7436.wav" rel="enclosure" type="audio/wav" length="2353624"></link>
    <updated>2026-05-03T00:00:00Z</updated>
    <published>2026-05-03T00:00:00Z</published>
    <summary type="text">A wave of new tinct output plugins (btop, tmux, wezterm, yazi, rofi and more) so a terminal-centric setup recolours end to end from one palette.</summary>
    <content type="html">&lt;p&gt;tinct could already theme a terminal: the emulator and the editor inside it. But a terminal-centric setup is a lot more than one emulator, and the supporting cast kept missing out: the multiplexer you live in all day, the file manager in the other pane, the system monitor, the launcher. Each was still a hand-rolled config away from matching everything else.&lt;/p&gt;&#xA;&lt;p&gt;So a batch of output plugins just landed to fill that in:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Terminals&lt;/strong&gt;: wezterm and foot join kitty, alacritty and ghostty.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Multiplexer&lt;/strong&gt;: tmux.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;TUIs&lt;/strong&gt;: yazi (files), btop (system monitor), helix (editor).&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Launchers&lt;/strong&gt;: rofi alongside the existing fuzzel and wofi.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;With these, a &lt;code&gt;tinct generate … -o all&lt;/code&gt; recolours the whole terminal workflow in one shot (emulator, multiplexer, file manager, monitor, launcher) from the same palette that&#39;s already doing your desktop. The thing I keep enjoying about the plugin model is that none of this needed changes to tinct itself; each app is just another small output plugin, and the list is now long enough that &amp;quot;does tinct theme X?&amp;quot; is usually &amp;quot;yes&amp;quot;.&lt;/p&gt;&#xA;&lt;p&gt;AUR &lt;code&gt;tinct-bin&lt;/code&gt;; the current plugin roster is in the &lt;a href=&#34;https://jmylchreest.github.io/tinct/&#34;&gt;docs&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>KeePassXC Joins the Collection</title>
    <id>https://blog.i0.pm/posts/rosec-keepassxc/</id>
    <link href="https://blog.i0.pm/posts/rosec-keepassxc/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-keepassxc-4fe9ac79b8e0.wav" rel="enclosure" type="audio/wav" length="2427930"></link>
    <updated>2026-05-02T00:00:00Z</updated>
    <published>2026-05-02T00:00:00Z</published>
    <summary type="text">rosec gains a KeePassXC provider, reading a .kdbx file straight onto the Secret Service bus, SSH keys and TOTP seeds included.</summary>
    <content type="html">&lt;p&gt;The provider list keeps growing, and this one I&#39;d wanted from the start. &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt; can now read a &lt;strong&gt;KeePassXC &lt;code&gt;.kdbx&lt;/code&gt; file&lt;/strong&gt; directly: unlock it, list its entries, and serve them on the Secret Service bus next to Bitwarden, your local vault and a migrating GNOME Keyring. A libsecret lookup doesn&#39;t care which one answered.&lt;/p&gt;&#xA;&lt;p&gt;The nice part is what comes along for the ride. KeePassXC stores more than passwords, and rosec picks up the rest of it:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;SSH keys&lt;/strong&gt; in the database, including KeePassXC&#39;s own KeeAgent attachments, register with rosec&#39;s built-in SSH agent automatically. The keys you already keep in your &lt;code&gt;.kdbx&lt;/code&gt; just &lt;em&gt;work&lt;/em&gt; with &lt;code&gt;ssh&lt;/code&gt;, no &lt;code&gt;ssh-add&lt;/code&gt;.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;TOTP seeds&lt;/strong&gt; surface as live files under &lt;code&gt;$XDG_RUNTIME_DIR/rosec/totp/&lt;/code&gt;, same as every other provider.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;yay -S rosec-provider-keepassxc-file-bin&#xA;rosec provider add keepassxc-file&#xA;rosec unlock&#xA;export SSH_AUTH_SOCK=&amp;#34;$XDG_RUNTIME_DIR/rosec/agent.sock&amp;#34;&#xA;ssh-add -l            # keys straight out of your .kdbx&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;It&#39;s marked experimental: the &lt;code&gt;.kdbx&lt;/code&gt; format has corners, and I&#39;d rather under-promise on a file your whole credential life might live in. Try it against a copy first. Repo and docs, as ever, on &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Two-Factor Codes You Can `cat`</title>
    <id>https://blog.i0.pm/posts/rosec-totp/</id>
    <link href="https://blog.i0.pm/posts/rosec-totp/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-totp-4ac0280f06d1.wav" rel="enclosure" type="audio/wav" length="8511886"></link>
    <updated>2026-04-12T00:00:00Z</updated>
    <published>2026-04-12T00:00:00Z</published>
    <summary type="text">rosec turns TOTP seeds into live files (cat one for the current six-digit code) reusing the same FUSE trick as the SSH agent, with a QR scanner to import seeds.</summary>
    <content type="html">&lt;p&gt;Back when &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt; grew an &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;SSH agent and a FUSE filesystem&lt;/a&gt; for keys, the pattern underneath it was the interesting bit: take a secret the daemon already holds and expose it as a &lt;em&gt;file&lt;/em&gt;, so any tool that can read a file can use it. This is that same trick, pointed at two-factor codes.&lt;/p&gt;&#xA;&lt;p&gt;A TOTP seed is just another secret. So if an item in any provider carries one, rosec now surfaces it as a live file under &lt;code&gt;$XDG_RUNTIME_DIR/rosec/totp/&lt;/code&gt;, and &lt;code&gt;cat&lt;/code&gt;-ing the file gives you the current six-digit code, regenerated every window:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;cat &amp;#34;$XDG_RUNTIME_DIR/rosec/totp/GitHub&amp;#34;&#xA;# 481922&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;No app to open, no phone to reach for, no copy-paste dance: the code is a file, which means it&#39;s also a shell pipeline, a &lt;code&gt;wofi&lt;/code&gt; entry, a keybind. Seeds go in either by storing them on an item directly or with the &lt;strong&gt;built-in QR scanner&lt;/strong&gt;, which reads the setup QR a site shows you and stores the seed in the provider of your choice. Like the SSH filesystem it&#39;s behind a toggle (&lt;code&gt;totp_fuse&lt;/code&gt;), off until you ask for it, and the prompt helper auto-clears the clipboard after a paste so a code doesn&#39;t linger.&lt;/p&gt;&#xA;&lt;h2 id=&#34;is-that-safe&#34;&gt;Is that safe?&lt;/h2&gt;&#xA;&lt;p&gt;A live 2FA code sitting in a file sounds alarming, so it&#39;s worth spelling out, because there&#39;s a real trade-off hiding in it.&lt;/p&gt;&#xA;&lt;p&gt;The mechanics first. The mount lives under &lt;code&gt;$XDG_RUNTIME_DIR/rosec/totp/&lt;/code&gt;, which on Linux is &lt;code&gt;/run/user/&amp;lt;your-uid&amp;gt;/&lt;/code&gt;: a per-user tmpfs, mode &lt;code&gt;0700&lt;/code&gt;, owned by you, that never touches the disk. rosec insists on &lt;code&gt;XDG_RUNTIME_DIR&lt;/code&gt;; if it isn&#39;t set the filesystem simply doesn&#39;t mount, rather than falling back somewhere world-readable. The mount itself is read-only, &lt;code&gt;nosuid&lt;/code&gt;, &lt;code&gt;nodev&lt;/code&gt;, &lt;code&gt;noexec&lt;/code&gt;, with an owner-only session ACL, so only your uid can traverse it: not other users, not root by default. The code files report mode &lt;code&gt;0400&lt;/code&gt;, owner-read and nothing else.&lt;/p&gt;&#xA;&lt;p&gt;There&#39;s also nothing to steal at rest. It&#39;s a virtual filesystem, so there is no &lt;code&gt;GitHub&lt;/code&gt; file on a disk anywhere. The six digits are generated fresh on each read, handed back, and the buffer holding them zeroed straight after; nothing is written to disk, and the code is never logged. The seed itself stays in the daemon&#39;s memory behind its provider, not in the mount.&lt;/p&gt;&#xA;&lt;p&gt;So the code is exactly as exposed as your SSH agent socket (same directory, same rules), your keyring, or the passwords your browser has saved: anything running &lt;em&gt;as you&lt;/em&gt; can read it, and nothing else can.&lt;/p&gt;&#xA;&lt;p&gt;That last clause is the trade-off, stated plainly. &amp;quot;Readable only by your user&amp;quot; is the same wall every other secret on your machine already stands behind, and it stops other users and remote attackers cold. It does not stop code running as you: a malicious dependency, a dodgy package build, a hijacked shell can &lt;code&gt;cat&lt;/code&gt; the file as easily as you can. For most secrets that&#39;s the accepted bargain. For TOTP it deserves a second&#39;s thought, because the whole point of a TOTP is to be a &lt;em&gt;second&lt;/em&gt; factor on a &lt;em&gt;separate&lt;/em&gt; device. Serve it from the same machine that holds the password and you&#39;ve folded both factors into one trust boundary: own the machine as that user, and you have the password and the current code together.&lt;/p&gt;&#xA;&lt;p&gt;For me, and I&#39;d guess most people, that&#39;s still the right trade, because the threat 2FA actually earns its keep against is the remote one: the phished password, the breach, the credential-stuffing, and none of those can reach the file. But if your threat model genuinely includes &amp;quot;my own machine might be running something hostile&amp;quot;, a code on your phone buys you something a code in a file cannot, and that&#39;s exactly why the whole thing stays behind the &lt;code&gt;totp_fuse&lt;/code&gt; toggle, off until you decide it&#39;s worth it.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s a small feature that I use constantly, and it&#39;s the clearest example of why the provider model was worth the effort: unify the secrets once, and &amp;quot;current 2FA code&amp;quot; becomes &lt;code&gt;cat&lt;/code&gt; of a path. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>aide Is Not a Claude Code Plugin</title>
    <id>https://blog.i0.pm/posts/aide-not-a-claude-code-plugin/</id>
    <link href="https://blog.i0.pm/posts/aide-not-a-claude-code-plugin/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-not-a-claude-code-plugin-5972dc01e0cf.wav" rel="enclosure" type="audio/wav" length="7858808"></link>
    <updated>2026-04-09T00:00:00Z</updated>
    <published>2026-04-09T00:00:00Z</published>
    <summary type="text">aide keeps decisions, scoped memories and a code index as portable, current fact, shared across Claude Code, OpenCode and Codex CLI rather than locked to one vendor.</summary>
    <content type="html">&lt;p&gt;When I &lt;a href=&#34;/posts/aide-resurrected/&#34;&gt;brought aide back&lt;/a&gt;, the headline was &amp;quot;context as code&amp;quot;: your project&#39;s memory and decisions live in the repo as reviewable records rather than in some vendor&#39;s hosted store. That&#39;s a nice slogan right up until your tool only works with one vendor&#39;s assistant, at which point you&#39;ve just moved the lock-in by one box.&lt;/p&gt;&#xA;&lt;p&gt;So this is the part that makes the slogan true: &lt;strong&gt;aide isn&#39;t tied to Claude Code.&lt;/strong&gt; It started there, picked up &lt;a href=&#34;https://opencode.ai&#34;&gt;OpenCode&lt;/a&gt; support a couple of days after the first release, and as of this week runs on &lt;strong&gt;Codex CLI&lt;/strong&gt; too. Three harnesses, one knowledge store.&lt;/p&gt;&#xA;&lt;p&gt;The trick is an adapter layer. Each assistant has its own lifecycle (session start, tool calls, the hook points where aide injects context or captures a memory) and each names and shapes those events differently. The adapters map every harness&#39;s events onto one shared set of core functions; the Go binary doing the actual work (storage, search, the MCP server) doesn&#39;t know or care which assistant is on the other end. Add a harness, write an adapter, done.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-the-store-actually-holds&#34;&gt;What the store actually holds&lt;/h2&gt;&#xA;&lt;p&gt;Portability only matters because of &lt;em&gt;what&lt;/em&gt; moves with you, and that&#39;s the part I care about most. The failure mode of an AI assistant isn&#39;t that it&#39;s stupid, it&#39;s that it confidently works from the wrong context: a stale decision, a half-remembered convention, a fact that was true three refactors ago. Garbage in, plausible garbage out. aide&#39;s whole design is aimed at that, by storing only things that are current and checkable and handing them over as fact rather than vibes.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Decisions are constitutional.&lt;/strong&gt; A decision in aide is an ADR: a topic, a choice, a rationale. It behaves like a clause in a constitution, injected into &lt;em&gt;every&lt;/em&gt; new session automatically, so the assistant starts already knowing &amp;quot;we use JWT with refresh tokens, because…&amp;quot;. When you change your mind you don&#39;t edit the old one, you record a new decision for the same topic; the latest supersedes and the full history stays readable. Amendments, not overwrites. The agent only ever sees the current law, and you can always read how it got there.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Memories are scoped.&lt;/strong&gt; Not everything should be shouted into every session. A memory carries a scope: &lt;code&gt;scope:global&lt;/code&gt; rides along in every project, &lt;code&gt;project:&amp;lt;name&amp;gt;&lt;/code&gt; only surfaces in the project it belongs to, and session memories group what was learned in one sitting. The injector pulls the global preferences and the current project&#39;s context and leaves the rest in the index, searchable but quiet. The session gets the facts relevant to &lt;em&gt;here&lt;/em&gt;, not the union of everything aide has ever heard.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;The code index is fact on demand.&lt;/strong&gt; The other half is structural truth about the code itself, served over MCP: where a symbol is defined, who calls it, the shape of a file, built from a tree-sitter parse rather than a guess. When the assistant needs to know something about the codebase it asks and gets the current answer, instead of pattern-matching from whatever happened to be in the context window.&lt;/p&gt;&#xA;&lt;p&gt;The through-line is that every one of these surfaces &lt;em&gt;current, material fact&lt;/em&gt; and nothing else. That&#39;s the antidote to context poisoning: you can&#39;t be dragged off course by stale context if the store only hands over what&#39;s true now. And because a precise fact is small (the one symbol, the one decision, not a wad of maybe-relevant text) it costs a fraction of the tokens that stuffing the window full of &amp;quot;context&amp;quot; would.&lt;/p&gt;&#xA;&lt;h2 id=&#34;which-is-why-portability-matters&#34;&gt;Which is why portability matters&lt;/h2&gt;&#xA;&lt;p&gt;Put those together and the reason to keep it vendor-neutral becomes obvious. This isn&#39;t a chat history you&#39;d shrug off losing; it&#39;s your team&#39;s accumulated, curated, current understanding of a codebase. Switch assistant, or run two side by side, and the &lt;code&gt;.aide/&lt;/code&gt; directory comes with you: same decisions, same scoped memories, same code map. Records you can read, diff and move aren&#39;t just tidier than embeddings in someone&#39;s cloud, they&#39;re the only version of &amp;quot;memory&amp;quot; that survives you changing your mind about which AI you use. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Watching Where the Tokens Go</title>
    <id>https://blog.i0.pm/posts/aide-web-dashboard/</id>
    <link href="https://blog.i0.pm/posts/aide-web-dashboard/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-web-dashboard-edc04a78bed2.wav" rel="enclosure" type="audio/wav" length="2579508"></link>
    <updated>2026-03-30T00:00:00Z</updated>
    <published>2026-03-30T00:00:00Z</published>
    <summary type="text">aide grew a web dashboard, aide-web, to make its context injection observable, tracking tokens spent versus avoided per tool and over time.</summary>
    <content type="html">&lt;p&gt;aide spends its life doing things you can&#39;t see: injecting memories and decisions at session start, answering the assistant&#39;s MCP queries, deciding which skill to surface. Useful, but invisible, and invisible machinery is hard to trust or tune. So aide grew a web dashboard, &lt;code&gt;aide-web&lt;/code&gt;, to make it observable.&lt;/p&gt;&#xA;&lt;p&gt;The centre of it is token intelligence. Every context aide injects and every fact it serves has a cost and, ideally, a saving: the decision it handed over is tokens you didn&#39;t spend re-explaining your auth strategy; the precise code-index answer is the wad of source you didn&#39;t have to paste in. The dashboard tracks both sides, spent versus avoided, per tool and over time, so &amp;quot;is this actually helping?&amp;quot; stops being a vibe and starts being a chart.&lt;/p&gt;&#xA;&lt;p&gt;&lt;code&gt;aide dashboard&lt;/code&gt; launches the web UI; &lt;code&gt;aide status&lt;/code&gt; gives you the same picture in the terminal if you&#39;d rather not leave it.&lt;/p&gt;&#xA;&lt;p&gt;One honest caveat, and it&#39;s why the feature is labelled experimental: the token counts are &lt;em&gt;estimates&lt;/em&gt;. They come from calibrated per-language character ratios, not a real tokeniser, so they&#39;re good for relative comparison and spotting trends, not for exact cost accounting. Treat the shape of the graph as the signal, not the third decimal place. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>rosec Can Read the Old Keyring</title>
    <id>https://blog.i0.pm/posts/rosec-gnome-keyring/</id>
    <link href="https://blog.i0.pm/posts/rosec-gnome-keyring/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-gnome-keyring-220436bc88d1.wav" rel="enclosure" type="audio/wav" length="2082044"></link>
    <updated>2026-03-04T00:00:00Z</updated>
    <published>2026-03-04T00:00:00Z</published>
    <summary type="text">A read-only GNOME Keyring provider lets rosec surface everything already in your keyring, so you can migrate off it at your own pace.</summary>
    <content type="html">&lt;p&gt;The awkward part of replacing GNOME Keyring is that years of secrets are already &lt;em&gt;in&lt;/em&gt; it: Wi-Fi passwords, app tokens, the odd thing you forgot you saved. Switching the Secret Service over to &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt; shouldn&#39;t mean leaving all of that behind, or doing a big-bang export the night you flip the switch.&lt;/p&gt;&#xA;&lt;p&gt;So rosec now has a &lt;strong&gt;read-only GNOME Keyring provider&lt;/strong&gt;. Point it at your existing keyring and every secret in there shows up on the bus alongside your other providers (Bitwarden, your local vault) as one unified collection. You can read them, copy the ones worth keeping into a vault you actually control, and retire the keyring whenever you&#39;re ready.&lt;/p&gt;&#xA;&lt;p&gt;Read-only is deliberate. The keyring is the thing you&#39;re migrating &lt;em&gt;away&lt;/em&gt; from; rosec treats it as a source to drain, not a destination to keep writing to. Nothing new lands back in it.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;yay -S rosec-provider-gnome-keyring-bin&#xA;rosec provider add gnome-keyring&#xA;secret-tool search --all          # your old keyring secrets, now served by rosec&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;One backend closer to rosec being the only Secret Service on the machine. KeePassXC is next on the list.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>rosec Grows a Vault of Its Own</title>
    <id>https://blog.i0.pm/posts/rosec-local-vault/</id>
    <link href="https://blog.i0.pm/posts/rosec-local-vault/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-local-vault-26d6ddc9df91.wav" rel="enclosure" type="audio/wav" length="2067180"></link>
    <updated>2026-02-28T00:00:00Z</updated>
    <published>2026-02-28T00:00:00Z</published>
    <summary type="text">rosec gains a local encrypted vault provider: secrets at rest on your own disk, unlocked at login through PAM, no cloud account required.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt; launched reading from Bitwarden, which is the case I built it for. But tying your whole Secret Service to a cloud account isn&#39;t for everyone, and &amp;quot;read my secrets from somewhere else&amp;quot; shouldn&#39;t &lt;em&gt;require&lt;/em&gt; a somewhere-else.&lt;/p&gt;&#xA;&lt;p&gt;So rosec now has a provider of its own: a &lt;strong&gt;local encrypted vault&lt;/strong&gt;. Secrets live encrypted on your own disk, and the daemon serves them on the bus exactly like any other backend; &lt;code&gt;secret-tool&lt;/code&gt; and friends can&#39;t tell the difference. It&#39;s the answer for anyone who wants the multi-provider Secret Service without a remote dependency in the loop.&lt;/p&gt;&#xA;&lt;p&gt;The piece that makes it pleasant is &lt;strong&gt;PAM unlock&lt;/strong&gt;, which landed alongside it: log in once and the vault opens with your login password, so there&#39;s no second prompt before your first secret lookup of the session.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;rosec provider add local&#xA;rosec unlock&#xA;rosec item add --provider local --label &amp;#34;GitHub&amp;#34; --attr username=alice --secret &amp;#39;hunter2&amp;#39;&#xA;secret-tool lookup label &amp;#34;GitHub&amp;#34;     # → hunter2, from your own disk&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;This is the provider I&#39;d reach for first now: Bitwarden when I want sync, the local vault when I want nothing leaving the machine. More backends still to come.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>SSH Keys That Mount Themselves</title>
    <id>https://blog.i0.pm/posts/rosec-ssh-agent/</id>
    <link href="https://blog.i0.pm/posts/rosec-ssh-agent/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-ssh-agent-d8d73b6269cf.wav" rel="enclosure" type="audio/wav" length="6939986"></link>
    <updated>2026-02-25T00:00:00Z</updated>
    <published>2026-02-25T00:00:00Z</published>
    <summary type="text">rosec gains an SSH agent that auto-populates from any provider, plus a FUSE filesystem that exposes your public keys and writes an ~/.ssh/config snippet for them.</summary>
    <content type="html">&lt;p&gt;The day after &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt; started serving secrets on the bus, the most obvious &amp;quot;wait, these are just secrets too&amp;quot; feature landed: SSH keys.&lt;/p&gt;&#xA;&lt;p&gt;An SSH key is a secret like any other, so why keep it in a separate &lt;code&gt;~/.ssh/&lt;/code&gt; pile and run &lt;code&gt;ssh-add&lt;/code&gt; by hand? rosec now ships a &lt;strong&gt;built-in SSH agent&lt;/strong&gt;. Any private key stored in any provider (a first-class key item, a PEM blob dropped in a note) gets registered automatically at &lt;code&gt;$XDG_RUNTIME_DIR/rosec/agent.sock&lt;/code&gt;. Point &lt;code&gt;SSH_AUTH_SOCK&lt;/code&gt; at it and your keys are simply &lt;em&gt;there&lt;/em&gt;:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;export SSH_AUTH_SOCK=&amp;#34;$XDG_RUNTIME_DIR/rosec/agent.sock&amp;#34;&#xA;ssh-add -l        # every key in your vault, no ssh-add ever run&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The part I&#39;m more pleased with is the &lt;strong&gt;FUSE filesystem&lt;/strong&gt; that came with it. The agent holds the private keys, but SSH tooling still wants public keys and config on disk, so rosec mounts a small read-only filesystem under &lt;code&gt;$XDG_RUNTIME_DIR/rosec/ssh/&lt;/code&gt;: each key&#39;s &lt;em&gt;public&lt;/em&gt; half under &lt;code&gt;keys/&lt;/code&gt;, and a generated config snippet under &lt;code&gt;config.d/&lt;/code&gt;. Nothing private ever touches the disk.&lt;/p&gt;&#xA;&lt;h2 id=&#34;the-right-key-every-time&#34;&gt;The right key, every time&lt;/h2&gt;&#xA;&lt;p&gt;The config is the part that earns its keep, and it&#39;s driven by two custom fields you can set on any key item:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;ssh-host&lt;/code&gt;&lt;/strong&gt; is the host pattern (or patterns, one per line) the key is for: &lt;code&gt;github.com&lt;/code&gt;, &lt;code&gt;*.internal.example.com&lt;/code&gt;, and so on. Each becomes a &lt;code&gt;Host&lt;/code&gt; block.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;code&gt;ssh-user&lt;/code&gt;&lt;/strong&gt; is the login user, and if you leave it off rosec falls back to the item&#39;s username field. It becomes the &lt;code&gt;User&lt;/code&gt; line.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;From those, each item gets its own &lt;code&gt;config.d/&amp;lt;name&amp;gt;.conf&lt;/code&gt;:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sshconfig&#34;&gt;Host git.internal.example.com&#xA;    User deploy&#xA;    IdentityFile &amp;#34;/run/user/1000/rosec/ssh/keys/by-name/prod-deploy.pub&amp;#34;&#xA;    IdentityAgent &amp;#34;/run/user/1000/rosec/agent.sock&amp;#34;&#xA;    IdentitiesOnly yes&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;You wire that into SSH once. I keep a one-line static file so my real &lt;code&gt;~/.ssh/config&lt;/code&gt; never has to change, and it pulls in the live mount:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sshconfig&#34;&gt;# ~/.ssh/config&#xA;Include configs.d/*.conf&#xA;&#xA;# ~/.ssh/configs.d/10-rosecd.conf&#xA;Include /run/user/1000/rosec/ssh/config.d/*.conf&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;That last directive, &lt;code&gt;IdentitiesOnly yes&lt;/code&gt;, is the quiet hero. By default &lt;code&gt;ssh&lt;/code&gt; offers the agent every key it holds, one after another, until something works; against a server with &lt;code&gt;MaxAuthTries 6&lt;/code&gt; a well-stocked agent gets you kicked off for &amp;quot;Too many authentication failures&amp;quot; before it ever reaches the right key. Here each host is pinned to exactly its key and told to offer &lt;em&gt;only&lt;/em&gt; that one. No guessing which user to connect as, no marching through eight wrong keys first, no lockout. The right key and the right user, first try, written straight from the secret that holds the key. Add a key, set &lt;code&gt;ssh-host&lt;/code&gt;, and the config to use it writes itself.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s optional and behind a config toggle (&lt;code&gt;ssh_fuse&lt;/code&gt;), because a self-mounting filesystem is exactly the sort of thing you want to opt into rather than have appear. But with it on, &amp;quot;I added a new SSH key&amp;quot; and &amp;quot;I can use that key everywhere&amp;quot; become the same step.&lt;/p&gt;&#xA;&lt;p&gt;This is the shape I want the whole project to have: unify the secrets once, and the conveniences fall out as filesystems and sockets over the top. The &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;2FA codes&lt;/a&gt; got the same treatment later. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Secrets, Minus the Keyring</title>
    <id>https://blog.i0.pm/posts/rosec/</id>
    <link href="https://blog.i0.pm/posts/rosec/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-13ef7614c5fe.wav" rel="enclosure" type="audio/wav" length="3648228"></link>
    <updated>2026-02-24T00:00:00Z</updated>
    <published>2026-02-24T00:00:00Z</published>
    <summary type="text">rosec is a Secret Service daemon for Linux that reads from your real password manager (launching with Bitwarden), plus an SSH agent and TOTP files that populate themselves.</summary>
    <content type="html">&lt;p&gt;On Linux, &lt;code&gt;org.freedesktop.secrets&lt;/code&gt; is how desktop applications store passwords: anything using &lt;code&gt;libsecret&lt;/code&gt; or &lt;code&gt;secret-tool&lt;/code&gt; talks to it. In practice that means GNOME Keyring, whether or not you run GNOME, and whether or not your secrets actually live there. Mine don&#39;t; they&#39;re in Bitwarden. I wanted the Secret Service on my machine to read from &lt;em&gt;there&lt;/em&gt;, transparently, without every app needing to know.&lt;/p&gt;&#xA;&lt;p&gt;So &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;rosec&lt;/a&gt; is a multi-provider Secret Service daemon. It implements the freedesktop spec, so any libsecret-aware tool keeps working unchanged, but behind the bus it reads from a provider you choose. It launches with Bitwarden, both Password Manager and Secrets Manager, surfaced as one collection on the bus.&lt;/p&gt;&#xA;&lt;p&gt;Once every secret lives somewhere the daemon can reach, more falls out of it than password lookups: an SSH agent that needs no &lt;code&gt;ssh-add&lt;/code&gt;, two-factor codes you can &lt;code&gt;cat&lt;/code&gt;. Both got built out in the weeks after launch, so they each get their own post; the foundation underneath them is just this, one spec-compliant Secret Service, reading from a provider you choose.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;yay -S rosec-bin&#xA;rosec enable        # installs the D-Bus activation, masks gnome-keyring-daemon&#xA;secret-tool lookup label &amp;#34;GitHub&amp;#34;     # → reads from Bitwarden via rosec&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The point of the design is the &lt;em&gt;provider&lt;/em&gt; boundary; Bitwarden is just the first one. A local encrypted vault, KeePassXC files and a read-only GNOME Keyring for migration are what comes next, each as its own backend on the same bus. (I&#39;ll post as those land.)&lt;/p&gt;&#xA;&lt;p&gt;This is squarely a scratch-my-own-itch project sitting on a sensitive part of the stack, so give the threat model your own scrutiny before you trust it with anything that matters; it&#39;s young. Repo and docs are on &lt;a href=&#34;https://github.com/jmylchreest/rosec&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Giving keylightd a Real API</title>
    <id>https://blog.i0.pm/posts/keylightd-typed-api/</id>
    <link href="https://blog.i0.pm/posts/keylightd-typed-api/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-typed-api-f108fa6d403e.wav" rel="enclosure" type="audio/wav" length="3651716"></link>
    <updated>2026-02-10T00:00:00Z</updated>
    <published>2026-02-10T00:00:00Z</published>
    <summary type="text">keylightd&#39;s HTTP API moves to Chi + Huma, with typed handlers, an auto-generated OpenAPI spec, and real-time WebSocket events, without breaking a single existing route.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;keylightd&lt;/a&gt; shipped its HTTP API on the standard library&#39;s &lt;code&gt;http.ServeMux&lt;/code&gt;, hand-marshalling JSON in each handler. That&#39;s fine until it isn&#39;t: the routes drift from the docs, every handler repeats the same decode/encode dance, and &amp;quot;what does this endpoint actually return?&amp;quot; is answered by reading the source. So the API got a proper rebuild.&lt;/p&gt;&#xA;&lt;p&gt;The handlers now run on &lt;strong&gt;&lt;a href=&#34;https://github.com/go-chi/chi&#34;&gt;Chi&lt;/a&gt; + &lt;a href=&#34;https://huma.rocks/&#34;&gt;Huma&lt;/a&gt;&lt;/strong&gt;, which buys three things at once:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Typed requests and responses.&lt;/strong&gt; Each endpoint is a Go struct in and a Go struct out; the framework handles validation and serialisation. No more manual JSON plumbing, and the compiler catches the shape errors I used to find at runtime.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;An OpenAPI spec for free.&lt;/strong&gt; Because the types &lt;em&gt;are&lt;/em&gt; the contract, the spec is generated from them: it can&#39;t drift from the implementation, because it&#39;s derived from it. Anything that speaks OpenAPI can now generate a client.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;A middleware ecosystem.&lt;/strong&gt; Rate limiting, structured logging and the rest drop in as Chi middleware instead of bespoke wrappers.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The rule I held myself to: &lt;strong&gt;all thirteen existing routes preserved, byte-for-byte.&lt;/strong&gt; This is infrastructure plumbing (a tray app and a GNOME extension are on the other end) so a rebuild that quietly changed a response shape would be a bug, not an upgrade. Same contract, better foundations.&lt;/p&gt;&#xA;&lt;p&gt;The same pass added &lt;strong&gt;WebSocket events&lt;/strong&gt;: instead of polling the daemon to ask &amp;quot;is the light on yet?&amp;quot;, a client can subscribe and get state changes pushed to it. The GNOME extension reflecting a light you toggled from the CLI, instantly, is the kind of small thing that only happens once the daemon can &lt;em&gt;tell&lt;/em&gt; you rather than wait to be asked.&lt;/p&gt;&#xA;&lt;p&gt;Logging moved to structured &lt;code&gt;slog&lt;/code&gt; with hot-reloadable filters in the same sweep: the boring, load-bearing stuff. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>aide, Resurrected: Minus the Vector Database</title>
    <id>https://blog.i0.pm/posts/aide-resurrected/</id>
    <link href="https://blog.i0.pm/posts/aide-resurrected/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-resurrected-da33ec84e136.wav" rel="enclosure" type="audio/wav" length="8766662"></link>
    <updated>2026-02-07T00:00:00Z</updated>
    <published>2026-02-07T00:00:00Z</published>
    <summary type="text">aide is back. Persistent memory, code intelligence and multi-agent orchestration for AI coding assistants, rebuilt around lexical search and tree-sitter instead of a local vector DB.</summary>
    <content type="html">&lt;p&gt;I had a tool called &lt;em&gt;aide memoire&lt;/em&gt;, a scratchpad of memory and context for an AI coding assistant, built the way everyone builds these: a &lt;strong&gt;local vector database&lt;/strong&gt;, embeddings of everything, semantic recall. It worked, more or less, and then it bit-rotted into something I no longer trusted. So I brought it back from scratch as &lt;strong&gt;&lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;aide&lt;/a&gt;&lt;/strong&gt;, and the thing I was most certain about going in was that the vector database wasn&#39;t coming with it.&lt;/p&gt;&#xA;&lt;p&gt;That sounds like a step backwards. Embeddings-in-a-vector-store is the default for coding-assistant memory. But the more I&#39;d lived with the old design, the more the vector DB felt like the wrong tool for &lt;em&gt;this&lt;/em&gt; job, so the resurrection drops it. aide runs on lexical full-text search (BBolt + Bleve, BM25-style) and a tree-sitter symbol index instead, and I think it&#39;s better for it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;why-no-vector-db&#34;&gt;Why no vector DB&lt;/h2&gt;&#xA;&lt;p&gt;The pitch for embeddings is &lt;em&gt;semantic&lt;/em&gt; recall: find the chunk that&#39;s vibes-similar to your query. But what an agent needs from a project usually isn&#39;t vibes; it&#39;s &lt;strong&gt;facts&lt;/strong&gt;. Where is this symbol defined? What calls it? What did we decide about retries, and why? What&#39;s the convention here? Those are exact, structural questions, and answering them with nearest-neighbour search over a cloud of floats is a fuzzy tool aimed at a precise target. aide&#39;s bet is that the power is in factual context, not semantic similarity, and once you take that bet, the wins stack up:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;No model in the loop.&lt;/strong&gt; Embeddings have to be &lt;em&gt;generated&lt;/em&gt;, so you end up piggybacking on an LLM just to remember things: tokens and latency to store, more to recall, a whole model dependency for what should be a database lookup. A lexical index and a tree-sitter parse need no model at all. aide remembers without asking anyone&#39;s API for permission.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Accuracy.&lt;/strong&gt; A factual lookup returns &lt;em&gt;the&lt;/em&gt; answer, not the five chunks that scored closest. &amp;quot;Where is &lt;code&gt;Foo&lt;/code&gt; defined&amp;quot; has a correct result; cosine similarity only has a ranking. For code, exact beats approximate nearly every time.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Fewer tokens.&lt;/strong&gt; Because the answer is precise it&#39;s also &lt;em&gt;small&lt;/em&gt;: the one symbol, the one decision, the actual call sites, instead of a wad of semantically-adjacent text padded out to fill the context window. Token reduction and better answers turn out to be the same lever, not a trade-off.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Context as code.&lt;/strong&gt; Everything aide knows (memories, decisions, findings) lives as plain records in a per-project &lt;code&gt;.aide/&lt;/code&gt; directory and exports to markdown you can commit. It&#39;s PR-reviewable, diffable, and you can read &lt;em&gt;why&lt;/em&gt; something surfaced. A blob of floats is none of those.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;And it&#39;s where the field went.&lt;/strong&gt; Native harnesses converged on a small always-loaded index plus on-demand file reads, not local-vector RAG: no model to run, nothing leaving the machine, an index measured in megabytes, the same behaviour on every box.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;For finding code, a tree-sitter symbol index gives you definitions, references and call graphs across a dozen languages with zero setup and no compiler in the loop: error-tolerant, precomputed, whole-repo. For finding &lt;em&gt;knowledge&lt;/em&gt;, full-text over short human-written records beats cosine similarity over chunks. Neither needs a GPU.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-it-actually-does&#34;&gt;What it actually does&lt;/h2&gt;&#xA;&lt;p&gt;aide is an MCP plugin that gives an assistant (Claude Code today; more shortly) the things a new contributor would kill for:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Memory + decisions&lt;/strong&gt;: preferences, context and architectural choices that persist across sessions, injected at startup and enforceable when you write code that contradicts them.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Code intelligence&lt;/strong&gt;: tree-sitter symbol search, references and call graphs.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Findings&lt;/strong&gt;: static analysis for complexity, coupling, secrets, duplication and dead code.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Survey&lt;/strong&gt;: a map of modules, entry points, tech stack and churn hotspots.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Skills + multi-agent orchestration&lt;/strong&gt;: keyword-triggered workflows, and parallel agents working in isolated git worktrees.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The throughline is that it serves &lt;em&gt;compact, token-budgeted answers&lt;/em&gt; instead of whole files, so a long-running agent spends its context window on the work rather than rediscovering the project every session.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s early and opinionated, which is the way I like a v0. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/aide&#34;&gt;GitHub&lt;/a&gt;. More on the individual pieces, and the opinions behind them, to come.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>The Notifications You Missed</title>
    <id>https://blog.i0.pm/posts/histui/</id>
    <link href="https://blog.i0.pm/posts/histui/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-histui-1d230fccde0d.wav" rel="enclosure" type="audio/wav" length="4176756"></link>
    <updated>2025-12-26T00:00:00Z</updated>
    <published>2025-12-26T00:00:00Z</published>
    <summary type="text">histui is a themeable GTK4 notification daemon for Wayland that keeps a persistent history, with a TUI browser and CLI for querying past notifications.</summary>
    <content type="html">&lt;p&gt;Notifications on Wayland are a solved problem right up until you miss one. A toast appears for five seconds while you&#39;re looking at another monitor, fades out, and is gone, and there&#39;s no good way to ask &amp;quot;what did that say?&amp;quot; &lt;a href=&#34;https://dunst-project.org/&#34;&gt;dunst&lt;/a&gt; and &lt;a href=&#34;https://github.com/emersion/mako&#34;&gt;mako&lt;/a&gt; are excellent at &lt;em&gt;showing&lt;/em&gt; notifications; they&#39;re just not built to &lt;em&gt;keep&lt;/em&gt; them.&lt;/p&gt;&#xA;&lt;p&gt;So &lt;a href=&#34;https://github.com/jmylchreest/histui&#34;&gt;histui&lt;/a&gt; is a notification daemon that does both. It draws notifications (a GTK4 layer-shell daemon with full CSS theming, custom layouts, light/dark switching and hot-reload) and it stores every one of them in SQLite, so your history outlives the toast.&lt;/p&gt;&#xA;&lt;p&gt;The history is what I built histui &lt;em&gt;for&lt;/em&gt;. There&#39;s a TUI browser with vim-style keybindings, and a CLI that filters by app, urgency, time range or regex and speaks JSON, dmenu and plain-ID output, so it drops straight into a script or a &lt;a href=&#34;https://github.com/Alexays/Waybar&#34;&gt;waybar&lt;/a&gt; module showing live counts.&lt;/p&gt;&#xA;&lt;p&gt;But it turned out not to be the part I appreciate most. What won me over day to day is the &lt;em&gt;presentation&lt;/em&gt; side I half-expected to ignore: per-urgency audio, so an error sounds different from a chat ping; GTK theming rich enough to be worth fussing over, wired to &lt;a href=&#34;https://github.com/jmylchreest/tinct&#34;&gt;tinct&lt;/a&gt; so the notifications recolour along with the rest of the desktop and hot-reload while I tweak; and image previews rendered right there in the toast. I set out to build a better history and ended up with a nicer notifier.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s flexible about how far you let it in. Run it standalone as a full dunst/mako replacement, or keep your existing daemon and run histui in monitor mode purely for the history. There are adapters to import what dunst, mako or swaync already logged, so you don&#39;t start from an empty slate.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;yay -S histui-bin&#xA;systemctl --user enable --now histuid&#xA;histui            # browse your history&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Early days, and themeable enough that I expect most of the interesting work to happen in other people&#39;s CSS. Repo, a demo video and docs are on &lt;a href=&#34;https://github.com/jmylchreest/histui&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Wrangling IPTV into One Playlist</title>
    <id>https://blog.i0.pm/posts/tvarr/</id>
    <link href="https://blog.i0.pm/posts/tvarr/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tvarr-5e667db6023a.wav" rel="enclosure" type="audio/wav" length="8709820"></link>
    <updated>2025-12-05T00:00:00Z</updated>
    <published>2025-12-05T00:00:00Z</published>
    <summary type="text">tvarr is a self-hosted IPTV relay; it aggregates M3U/Xtream sources, merges EPG, and repackages or transcodes streams on demand.</summary>
    <content type="html">&lt;p&gt;If you self-host live TV, you&#39;ve met the gap tvarr fills. You have a couple of IPTV sources in different formats, each with its own playlist, its own EPG, its own quirks, and a client that wants exactly one tidy M3U and one tidy guide. Stitching those together by hand is miserable, and the tools I tried either did too much or assumed a workflow that wasn&#39;t mine.&lt;/p&gt;&#xA;&lt;p&gt;So &lt;a href=&#34;https://github.com/jmylchreest/tvarr&#34;&gt;tvarr&lt;/a&gt; is a self-hosted relay that sits between your sources and your clients. It aggregates M3U and Xtream inputs, merges the EPG data, and generates a single unified playlist. Point your player at tvarr instead of at five different providers, and it sees one coherent channel list. The aggregation is the easy half, though. The interesting half is what happens to the actual streams.&lt;/p&gt;&#xA;&lt;h2 id=&#34;one-origin-many-players&#34;&gt;One origin, many players&lt;/h2&gt;&#xA;&lt;p&gt;Most IPTV origins allow a &lt;em&gt;single&lt;/em&gt; connection per channel. But you might have a phone on HLS, a TV on MPEG-TS and a browser on DASH all wanting that same channel at once. Open one upstream connection per client and you trip the limit, and waste the bandwidth, immediately.&lt;/p&gt;&#xA;&lt;p&gt;So tvarr holds &lt;strong&gt;one origin connection per channel&lt;/strong&gt; and fans it out through a shared buffer. Ingest probes the source (HLS, DASH, RTSP or raw MPEG-TS) and demuxes it down to codec-agnostic &lt;em&gt;elementary streams&lt;/em&gt;, the raw H.264 or H.265 video and AAC or AC3 audio with their timing intact, written into a per-channel &lt;code&gt;SharedESBuffer&lt;/code&gt;. Output processors then mux those same elementary streams into whatever each client actually needs: HLS-TS, HLS-fMP4, DASH or raw TS. One fetch upstream, many formats downstream, and the whole thing tears itself down when the last client disconnects.&lt;/p&gt;&#xA;&lt;p&gt;If a client needs a codec the source doesn&#39;t carry, transcoding happens &lt;strong&gt;on demand and once&lt;/strong&gt;. A single FFmpeg transcode produces, say, a VP9/Opus variant that lands back in the same buffer, and every client that wants that variant is served from it. No per-client transcode, no pre-transcoding a library you might never watch.&lt;/p&gt;&#xA;&lt;h2 id=&#34;distributed-transcoding-over-grpc&#34;&gt;Distributed transcoding over gRPC&lt;/h2&gt;&#xA;&lt;p&gt;Transcoding is the expensive part, so tvarr can push it off the box doing the relaying entirely. A separate worker daemon, &lt;strong&gt;ffmpegd&lt;/strong&gt;, connects to tvarr (the coordinator) over &lt;strong&gt;gRPC&lt;/strong&gt;, detects its own FFmpeg capabilities (which encoders, which GPUs) and registers them. Run one embedded locally, or a pool across machines: a couple of GPU boxes for NVENC and VAAPI, a spare CPU server for overflow.&lt;/p&gt;&#xA;&lt;p&gt;When a variant is needed, the coordinator builds a job and picks a worker on what actually matters: does it have the required encoder, does it have a free job slot, and is there a GPU going spare (preferred over CPU). The job is dispatched down a gRPC stream, the worker spawns FFmpeg, and the transcoded elementary streams come back into the shared buffer. If a worker drops off mid-stream its jobs are reassigned to another. A low-power machine can serve the playlist and the UI while the heavy lifting happens somewhere with a graphics card.&lt;/p&gt;&#xA;&lt;h2 id=&#34;filters-and-data-mapping&#34;&gt;Filters and data mapping&lt;/h2&gt;&#xA;&lt;p&gt;The other half of taming five providers&#39; worth of inconsistent metadata is rules, and tvarr uses an expression language rather than a pile of checkboxes. There are two kinds, applied in order. &lt;strong&gt;Data mapping&lt;/strong&gt; runs first and rewrites messy fields; &lt;strong&gt;filters&lt;/strong&gt; then include or exclude channels on the now-clean values, so you tidy before you decide.&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-text&#34;&gt;# data mapping (first): normalise the mess&#xA;SET group_title = &amp;#34;UK Sports&amp;#34;   where  group_title matches &amp;#34;.*UK.*Sport.*&amp;#34;&#xA;&#xA;# filters (second): include/exclude on the clean values&#xA;INCLUDE  group_title contains &amp;#34;Sports&amp;#34;&#xA;EXCLUDE  channel_name matches &amp;#34;.*(Test|Backup|XXX).*&amp;#34;&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;Map the mess into shape, then filter on the result, and a tidy, predictable playlist falls out of a heap of inconsistent sources. Filters apply to EPG entries too, not just channels.&lt;/p&gt;&#xA;&lt;p&gt;It runs as a single container with a web UI for managing sources, rules and output playlists:&lt;/p&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-bash&#34;&gt;docker run -d -p 8080:8080 -v tvarr-data:/data \&#xA;  -e TVARR_SERVER_BASE_URL=http://your-host:8080 \&#xA;  ghcr.io/jmylchreest/tvarr:release&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;If you&#39;ve used Threadfin or StreamMaster it lives in the same neighbourhood; this is just my take on the shape of the problem. It&#39;s early and moving quickly. Repo and docs are on &lt;a href=&#34;https://github.com/jmylchreest/tvarr&#34;&gt;GitHub&lt;/a&gt;; if it&#39;s useful, brilliant, and if it isn&#39;t quite right, open an issue.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>A Tray Icon, and a Migration on Hold</title>
    <id>https://blog.i0.pm/posts/keylightd-tray/</id>
    <link href="https://blog.i0.pm/posts/keylightd-tray/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-tray-9b0628e65cf2.wav" rel="enclosure" type="audio/wav" length="4265094"></link>
    <updated>2025-11-21T00:00:00Z</updated>
    <published>2025-11-21T00:00:00Z</published>
    <summary type="text">keylightd gains a cross-platform system-tray app via Wails, and an honest account of the Wails v3 migration that&#39;s parked waiting on an upstream bug.</summary>
    <content type="html">&lt;p&gt;Not everyone lives in GNOME, and not everyone wants a shell extension. So &lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;keylightd&lt;/a&gt; now has a &lt;strong&gt;system-tray app&lt;/strong&gt;: a small &lt;a href=&#34;https://wails.io&#34;&gt;Wails&lt;/a&gt; desktop application, on Linux and Windows alike, that puts your lights one click away in the system tray.&lt;/p&gt;&#xA;&lt;p&gt;keylightd already finds every Elgato Key Light on your network over mDNS; the tray app is the friendly face on top of that. The icon toggles the lights on and off, and clicking through opens a little window with brightness and colour-temperature sliders, either for an individual light or for a whole group, plus the option to hide the ones you never reach for. It&#39;s a thin client over the daemon&#39;s API, mostly icons and a status loop, so it stays in sync no matter where the change came from: this window, the CLI, the GNOME extension or a script hitting the socket.&lt;/p&gt;&#xA;&lt;p&gt;And of course it&#39;s themed. The window is plain CSS with hot reload, and &lt;a href=&#34;https://github.com/jmylchreest/tinct&#34;&gt;tinct&lt;/a&gt; has an output plugin for it, so when I regenerate my palette the tray restyles itself to match the rest of the desktop, no restart. Tiny, but it makes me happy.&lt;/p&gt;&#xA;&lt;p&gt;That&#39;s the good news. Here&#39;s the honest part, because I&#39;d rather write it down than pretend.&lt;/p&gt;&#xA;&lt;p&gt;I tried to move the tray app to &lt;strong&gt;Wails v3&lt;/strong&gt;, which has a much nicer native systray story and cleaner bindings, and the migration is &lt;em&gt;code-complete&lt;/em&gt; but &lt;strong&gt;on hold&lt;/strong&gt;. Wails v3 is still in alpha, and the build segfaults during GTK initialisation on Linux (a &lt;code&gt;SIGSEGV&lt;/code&gt; in the alpha&#39;s GTK path, not in keylightd&#39;s code). I could paper over it, but a tray app that crashes on launch for half my users isn&#39;t a release; it&#39;s a liability. So the v3 branch sits there, finished and pinned, waiting for Wails v3 to go stable, and the shipping tray app stays on v2 where it actually works.&lt;/p&gt;&#xA;&lt;p&gt;This is the unglamorous reality of building on a fast-moving toolkit: sometimes the right move is to do the work, watch it break on something upstream, and then &lt;em&gt;not ship it&lt;/em&gt;. The branch isn&#39;t abandoned; it&#39;s parked, with a note to revisit when the segfault is fixed. Meanwhile the v2 tray, the GNOME extension, the CLI and the API all drive the same daemon, so nobody&#39;s actually waiting on it.&lt;/p&gt;&#xA;&lt;p&gt;Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>A Palette for the Whole Desktop</title>
    <id>https://blog.i0.pm/posts/tinct-whole-desktop/</id>
    <link href="https://blog.i0.pm/posts/tinct-whole-desktop/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-whole-desktop-f645af467c13.wav" rel="enclosure" type="audio/wav" length="2605372"></link>
    <updated>2025-11-18T00:00:00Z</updated>
    <published>2025-11-18T00:00:00Z</published>
    <summary type="text">tinct now themes GTK and Qt (GTK3/4, libadwaita, Qt5/6, GNOME Shell, KDE Plasma) so the palette reaches the apps that don&#39;t read a config file you control.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/tinct&#34;&gt;tinct&lt;/a&gt; started by theming the parts of my setup that read a plain config file I own: kitty, Hyprland, waybar, dunst, neovim. That covers a terminal-and-Wayland life nicely, right up until you open a GTK file manager or a Qt settings dialog and it&#39;s still wearing last month&#39;s colours. The toolkit apps were the conspicuous gap: the browser chrome, the file picker, half of GNOME and KDE, all stubbornly default.&lt;/p&gt;&#xA;&lt;p&gt;That gap is now closed. tinct has output plugins for the desktop toolkits themselves:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;GTK&lt;/strong&gt;: GTK3, GTK4 and libadwaita, so GNOME apps and anything GTK-based pick up the palette.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Qt&lt;/strong&gt;: Qt5 and Qt6, plus &lt;strong&gt;KDE Plasma&lt;/strong&gt; and &lt;strong&gt;konsole&lt;/strong&gt;.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;GNOME Shell&lt;/strong&gt;: the shell, not just the apps under it.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;Which means &lt;code&gt;tinct generate -i image -p wallpaper.jpg -o all&lt;/code&gt; now recolours the &lt;em&gt;whole&lt;/em&gt; desktop in one go: terminal, window manager, bar, and the GTK/Qt apps that used to sit outside the reach of a Pywal-style tool. Getting these right is fiddlier than a terminal config (toolkits have opinions, and contrast matters more when real UI chrome is involved), which is exactly why having tinct&#39;s semantic role-assignment and WCAG checks do it beats hand-templating.&lt;/p&gt;&#xA;&lt;p&gt;Still early, still moving. AUR is &lt;code&gt;tinct-bin&lt;/code&gt;; the full output-plugin list lives in the &lt;a href=&#34;https://jmylchreest.github.io/tinct/&#34;&gt;docs&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>One Palette, Many Configs</title>
    <id>https://blog.i0.pm/posts/tinct/</id>
    <link href="https://blog.i0.pm/posts/tinct/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-719ddacbefa7.wav" rel="enclosure" type="audio/wav" length="3469898"></link>
    <updated>2025-11-04T00:00:00Z</updated>
    <published>2025-11-04T00:00:00Z</published>
    <summary type="text">tinct is a plugin-based colour-palette generator and theme manager: Pywal/Matugen-style theming, but extensible at both the input and the output ends.</summary>
    <content type="html">&lt;p&gt;I like my desktop to feel of a piece: terminal, bar, window borders, launcher, editor, all drawn from the same palette. The usual way to get there is &lt;a href=&#34;https://github.com/dylanaraps/pywal&#34;&gt;Pywal&lt;/a&gt; or &lt;a href=&#34;https://github.com/InioX/matugen&#34;&gt;Matugen&lt;/a&gt;: extract colours from a wallpaper, template them into a pile of config files. They&#39;re good tools and I used them for years. But I kept bumping into the same two walls: the inputs I wanted weren&#39;t there, and adding a new application meant another bespoke shell template bolted on the side.&lt;/p&gt;&#xA;&lt;p&gt;So &lt;a href=&#34;https://github.com/jmylchreest/tinct&#34;&gt;tinct&lt;/a&gt; is my answer: an extensible colour-palette generator and theme manager, built around plugins at &lt;em&gt;both&lt;/em&gt; ends. A palette is something you produce from an input plugin and apply through output plugins, and either side is open.&lt;/p&gt;&#xA;&lt;p&gt;On the input side, a palette can come from an image (the Pywal case), from a remote theme (point it at Catppuccin&#39;s palette JSON and it pulls the colours straight out), or from a manual specification. On the output side, tinct does the semantic work, assigning colours to roles with WCAG contrast checking, so you don&#39;t end up with unreadable text on a bad day&#39;s wallpaper, then writes out config. The first batch of output plugins covers the setup I actually run: kitty, Hyprland and hyprpaper, waybar, dunst, fuzzel and wofi, neovim, zellij. &lt;code&gt;-o all&lt;/code&gt; writes the lot in one pass, and a new application is a new output plugin, not a fork of the tool.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s early; this is the &amp;quot;scratch my own itch, then realise other people have the same itch&amp;quot; stage, so expect rough edges and shifting defaults. It&#39;s written in Go, installs from the AUR (&lt;code&gt;yay -S tinct-bin&lt;/code&gt;) or &lt;code&gt;go install&lt;/code&gt;, and the repo and docs are on &lt;a href=&#34;https://github.com/jmylchreest/tinct&#34;&gt;GitHub&lt;/a&gt;. More inputs and a lot more output plugins are where it goes from here.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Booting Into a Snapshot</title>
    <id>https://blog.i0.pm/posts/refind-btrfs-snapshots/</id>
    <link href="https://blog.i0.pm/posts/refind-btrfs-snapshots/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-refind-btrfs-snapshots-a589ee1df337.wav" rel="enclosure" type="audio/wav" length="5025600"></link>
    <updated>2025-06-12T00:00:00Z</updated>
    <published>2025-06-12T00:00:00Z</published>
    <summary type="text">A single static Go binary that generates rEFInd boot entries for your btrfs snapshots, auto-detecting each snapshot&#39;s boot mode and quietly handling stale kernels.</summary>
    <content type="html">&lt;p&gt;btrfs snapshots are a wonderful safety net right up to the moment you actually need one, at which point you discover that &amp;quot;roll back to this morning&amp;quot; means booting into a snapshot, and your boot manager has never heard of it. The excellent &lt;a href=&#34;https://github.com/Venom1991/refind-btrfs&#34;&gt;refind-btrfs&lt;/a&gt; solves this, but it&#39;s Python with a dependency tree, and I wanted something I could drop onto a machine as one file and forget about.&lt;/p&gt;&#xA;&lt;p&gt;So &lt;strong&gt;&lt;a href=&#34;https://github.com/jmylchreest/refind-btrfs-snapshots&#34;&gt;refind-btrfs-snapshots&lt;/a&gt;&lt;/strong&gt; is a single static Go binary that discovers your btrfs snapshots and generates &lt;a href=&#34;https://www.rodsbooks.com/refind/&#34;&gt;rEFInd&lt;/a&gt; boot-menu entries for them. Run it as a one-off whenever you like; it&#39;s a plain binary, so systemd isn&#39;t required. But drop in the optional systemd path unit and it watches your snapshots directory, so the instant Snapper takes a snapshot the boot menu regenerates and that backup is bootable straight away, with nothing for you to remember to run. Either way, you pick a snapshot from the boot menu and boot straight into it.&lt;/p&gt;&#xA;&lt;p&gt;The part I care about is that it gets the &lt;em&gt;details&lt;/em&gt; right, because boot is exactly where you don&#39;t want surprises:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;Per-snapshot boot-mode detection.&lt;/strong&gt; Whether a snapshot boots in ESP mode (kernel on a separate &lt;code&gt;/boot&lt;/code&gt;) or btrfs mode (kernel inside the snapshot) is read from &lt;em&gt;that snapshot&#39;s own&lt;/em&gt; &lt;code&gt;/etc/fstab&lt;/code&gt;, not assumed globally. So snapshots taken before you changed your disk layout still boot correctly, and one menu can mix both kinds.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Stale-kernel handling, the feature other tools skip.&lt;/strong&gt; With a separate ESP &lt;code&gt;/boot&lt;/code&gt;, a kernel upgrade can leave an old snapshot whose kernel modules no longer match the kernel on the ESP. It would boot, then fail to find its modules. This binary detects that mismatch and, by default, excludes the snapshot rather than offering you a boot that dies halfway, with &lt;code&gt;warn&lt;/code&gt;, &lt;code&gt;disable&lt;/code&gt; and &lt;code&gt;fallback&lt;/code&gt; options if you&#39;d rather handle it differently. Not silently.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;The correct config method.&lt;/strong&gt; It writes an include file rather than abusing &lt;code&gt;refind_linux.conf&lt;/code&gt; (which can only override &lt;code&gt;options&lt;/code&gt;, not the &lt;code&gt;volume&lt;/code&gt;/&lt;code&gt;loader&lt;/code&gt;/&lt;code&gt;initrd&lt;/code&gt; that btrfs-mode entries need), and it shows you a diff before it touches anything.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;And it auto-detects most of what matters: your ESP, each snapshot&#39;s boot mode, a standard Snapper layout. The defaults work with no configuration on a typical setup, so for most people it&#39;s install, enable the path unit, and forget it&#39;s there. Installs from the AUR (&lt;code&gt;refind-btrfs-snapshots-bin&lt;/code&gt;) or as a prebuilt binary, and &lt;code&gt;--dry-run&lt;/code&gt; is first-class. Repo on &lt;a href=&#34;https://github.com/jmylchreest/refind-btrfs-snapshots&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>Key Lights in the Top Bar</title>
    <id>https://blog.i0.pm/posts/keylightd-gnome/</id>
    <link href="https://blog.i0.pm/posts/keylightd-gnome/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-gnome-e0883d7eeaa8.wav" rel="enclosure" type="audio/wav" length="2525638"></link>
    <updated>2025-05-18T00:00:00Z</updated>
    <published>2025-05-18T00:00:00Z</published>
    <summary type="text">A GNOME Shell extension for keylightd puts the lights in your top bar: toggle, brightness and colour temperature, per light or per group, no terminal required.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;keylightd&lt;/a&gt; launched with a CLI and a REST API, which is exactly what you want for scripts and not at all what you want when you&#39;re on a call and the light is too bright. A keyboard shortcut to a shell command is fine; reaching for a slider is better.&lt;/p&gt;&#xA;&lt;p&gt;So keylightd now has a &lt;strong&gt;GNOME Shell extension&lt;/strong&gt;. It sits in the top bar, talks to the daemon over its HTTP API, and gives you the controls you actually reach for mid-task: toggle a light or a whole group on and off, and nudge brightness and colour temperature without leaving what you&#39;re doing. It&#39;s published on &lt;a href=&#34;https://extensions.gnome.org/&#34;&gt;extensions.gnome.org&lt;/a&gt; (extension 8185), so it installs like any other.&lt;/p&gt;&#xA;&lt;p&gt;I&#39;ll be honest: I only really use GNOME when I&#39;m not on my own machines; left to my own setup I&#39;m more of a terminal junkie on a tiling Wayland desktop. But there&#39;s something quietly nice about a control that feels &lt;em&gt;integrated&lt;/em&gt;, a proper top-bar applet that looks like it belongs there, rather than a keybind firing off a shell command. Native beats bolted-on, even on a desktop I barely touch. It&#39;s cheap to offer, too, since the extension is just a thin client over the same API the CLI uses, mostly buttons over a daemon that already does the hard part.&lt;/p&gt;&#xA;&lt;p&gt;Toggle from the bar, script from the shell, both driving the same daemon. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>A Daemon for the Key Lights</title>
    <id>https://blog.i0.pm/posts/keylightd/</id>
    <link href="https://blog.i0.pm/posts/keylightd/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-28f6984ad02e.wav" rel="enclosure" type="audio/wav" length="5892722"></link>
    <updated>2025-05-11T00:00:00Z</updated>
    <published>2025-05-11T00:00:00Z</published>
    <summary type="text">keylightd discovers, groups and controls Elgato Key Lights over the network, and exposes the lot through a CLI, a REST API and a Unix socket so anything can drive your lighting.</summary>
    <content type="html">&lt;p&gt;I have a couple of Elgato Key Lights, and controlling them was always slightly worse than it should be. A phone app, or a hard-coded IP in a script that broke the next time DHCP felt creative. And because there are &lt;em&gt;two&lt;/em&gt; of them, every adjustment was really two adjustments: two sliders, remembering which app entry was the left light and which was the right, nudging both and watching them drift out of step. What I actually wanted was to stop thinking of them as two lights and treat them as one: group them, and change them together. The lights speak a perfectly good HTTP API; what was missing was something to find them, remember them, group them, and give &lt;em&gt;me&lt;/em&gt; a clean way in.&lt;/p&gt;&#xA;&lt;p&gt;So I wrote &lt;strong&gt;&lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;keylightd&lt;/a&gt;&lt;/strong&gt;, a small Go daemon that owns the lights so nothing else has to.&lt;/p&gt;&#xA;&lt;p&gt;The daemon does three things. It &lt;strong&gt;discovers&lt;/strong&gt; Key Lights on the network over mDNS, so there are no IP addresses to hard-code: plug a light in and it appears. It lets you put lights into named &lt;strong&gt;groups&lt;/strong&gt; and drive a group as one unit, so the two lights either side of my desk turn on together, dim together and stay in step. Each light is still addressable on its own when you want it, but day to day I never touch them individually; &amp;quot;turn the office on&amp;quot; is one command and both come up matched. And it exposes that control through whichever interface suits the caller:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;a &lt;strong&gt;CLI&lt;/strong&gt; (&lt;code&gt;keylightctl&lt;/code&gt;) talking to the daemon over a low-latency Unix socket;&lt;/li&gt;&#xA;&lt;li&gt;a &lt;strong&gt;REST API&lt;/strong&gt; with token auth and an auto-generated OpenAPI spec, for anything remote or scripted;&lt;/li&gt;&#xA;&lt;li&gt;a small &lt;strong&gt;client library&lt;/strong&gt; so Go programs can skip the wire format entirely.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-sh&#34;&gt;keylightctl group add Office&#xA;keylightctl group set Office on true&#xA;keylightctl group set Office brightness 40&#xA;&lt;/code&gt;&lt;/pre&gt;&#xA;&lt;p&gt;The point of a daemon, rather than a pile of &lt;code&gt;curl&lt;/code&gt; one-liners, is that there&#39;s one thing that knows the current state, survives a light dropping off the network, and presents the same view to a script, a desktop applet or a status bar. Everything else stays thin and just asks the daemon.&lt;/p&gt;&#xA;&lt;p&gt;Because the control point is just &lt;code&gt;keylightctl&lt;/code&gt; (or the API), the lights slot into whatever automation you already have. The one I keep meaning to wire up properly: turn the key lights on when the webcam does. keylightd doesn&#39;t need to know anything about that; watch for the camera device opening, or a specific app launching, and call &lt;code&gt;keylightctl group set Office on true&lt;/code&gt;. I did try building the webcam-detection in natively, but &amp;quot;is the camera in use&amp;quot; is maddeningly sporadic across environments (different stacks, different permission models, PipeWire versus v4l2 versus whatever the app decides to do), so I left it as a hook for you to script rather than a half-working feature pretending to be reliable. If you&#39;ve wired something like this up, driving the lights off some local condition, drop an issue on the &lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;repo&lt;/a&gt;; I&#39;d genuinely love to hear how.&lt;/p&gt;&#xA;&lt;p&gt;It&#39;s day one and deliberately small. Repo and docs on &lt;a href=&#34;https://github.com/jmylchreest/keylightd&#34;&gt;GitHub&lt;/a&gt;; desktop control and a proper typed API come next.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>silicon valley season 3</title>
    <id>https://blog.i0.pm/posts/silicon-valley-season-3/</id>
    <link href="https://blog.i0.pm/posts/silicon-valley-season-3/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-silicon-valley-season-3-af2452c9a40b.wav" rel="enclosure" type="audio/wav" length="1592380"></link>
    <updated>2016-04-09T00:00:00Z</updated>
    <published>2016-04-09T00:00:00Z</published>
    <summary type="text">A stupidly accurate, satirical reflection of working in modern IT.</summary>
    <content type="html">&lt;p&gt;Since having a friend point out the HBO original &lt;a href=&#34;http://www.hbo.com/silicon-valley&#34;&gt;Silicon Valley&lt;/a&gt; I&#39;ve been hooked. Such a brilliant TV show, and despite certain attitudes and characters being overdramatised it&#39;s a stupidly accurate (albeit satirical) reflection of working in modern IT… at least in most places.&lt;/p&gt;&#xA;&lt;p&gt;I won&#39;t recap on the whole thing, but what I will say is I&#39;m actually, for the first time in a long time, very excited for a TV show to bring back a new season. &lt;em&gt;&lt;strong&gt;It premiers on HBO on the 24th&lt;/strong&gt;&lt;/em&gt;.&lt;/p&gt;&#xA;&lt;p&gt;If you&#39;re looking for a quirky version of the IT Crowd, or you just work in IT I recommend you take a watch. It&#39;s free to recap on HBO right now, assuming you&#39;re in region. Assuming you&#39;re not, you&#39;ll discover that you get a particularly inelegant CloudFront geo-blocking error when you click the &lt;a href=&#34;http://watch.hbo.com/#id=silicon-valley&#34;&gt;watch now for free&lt;/a&gt; link.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>igmpqd</title>
    <id>https://blog.i0.pm/posts/igmpqd/</id>
    <link href="https://blog.i0.pm/posts/igmpqd/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-igmpqd-ddea8dbc6920.wav" rel="enclosure" type="audio/wav" length="2774786"></link>
    <updated>2016-04-07T00:00:00Z</updated>
    <published>2016-04-07T00:00:00Z</published>
    <summary type="text">A small, standalone IGMPv2 querier, written because the existing options didn&#39;t fit.</summary>
    <content type="html">&lt;p&gt;I found myself with a bit of time on my hands these last few days, and have a need for an IGMPv2 Querier. Theres a few out there already, but they&#39;re either part of a much larger suite (like &lt;a href=&#34;http://www.xorp.org/&#34;&gt;XORP&lt;/a&gt;, a fantastic OSS routing platform), or generally unsuitable to run as a system service.&lt;/p&gt;&#xA;&lt;p&gt;The IGMPv2 Query message spec is actually very simple as indicated in &lt;a href=&#34;https://tools.ietf.org/html/rfc2236#section-2&#34;&gt;RFC2236&lt;/a&gt; so I thought, what the heck, I might as well have a stab myself.&lt;/p&gt;&#xA;&lt;p&gt;After a few hours I had the basic thing together and principally tested. Given that I can&#39;t be the only one in need of such a tool I figured I&#39;d also publish it somewhere for other people to use.&lt;/p&gt;&#xA;&lt;p&gt;To that end, the igmpqd project is now live on &lt;a href=&#34;https://github.com/jmylchreest/igmpqd&#34;&gt;github&lt;/a&gt;. If it proves useful to you, brilliant… if you find any issues or have any requests for it, please open an issue ticket on github or even better, fork it, hack on it, and put forth a merge request.&lt;/p&gt;&#xA;&lt;h3 id=&#34;using-igmpqd&#34;&gt;Using IGMPQD&lt;/h3&gt;&#xA;&lt;p&gt;Using IGMPQD is simple. Download a binary that matches your platform from the github releases page and run it with &lt;code&gt;./igmpqd run&lt;/code&gt;. By default, it&#39;ll do what you most likely want, and thats issue an IGMPv2 Query for all groups to all nodes listening on the all-hosts multicast group address every 30 seconds.&lt;/p&gt;&#xA;&lt;p&gt;If you want to tune any of those options, check out &lt;code&gt;./igmpqd run --help&lt;/code&gt; or the github page for more information.&lt;/p&gt;&#xA;</content>
  </entry>
  <entry>
    <title>back on the grid</title>
    <id>https://blog.i0.pm/posts/back-on-the-grid/</id>
    <link href="https://blog.i0.pm/posts/back-on-the-grid/" rel="alternate"></link>
    <link href="https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-back-on-the-grid-b0b5b130e471.wav" rel="enclosure" type="audio/wav" length="2311642"></link>
    <updated>2016-04-02T00:00:00Z</updated>
    <published>2016-04-02T00:00:00Z</published>
    <summary type="text">Yet another IT geek promising opinionated and likely pointless public posts.</summary>
    <content type="html">&lt;p&gt;I woke up this morning with a startling realization. Despite the internet being full of some of our species finest works, being an enabler for people to connect on all manner of levels, and to provide the typical amongst us with more than our fair share of exciting animated cat gifs and memes it&#39;s missing something.&lt;/p&gt;&#xA;&lt;p&gt;That&#39;s right. Yet another IT geek promising opinionated and likely pointless public posts. The kind that are likely to come thick and fast with no useful information what so ever till eventually they get bored. Well… Hopefully this won&#39;t be quite as bad as that.&lt;/p&gt;&#xA;&lt;h2 id=&#34;enough-what-should-i-expect&#34;&gt;Enough. What should I expect?&lt;/h2&gt;&#xA;&lt;p&gt;Well, I&#39;m glad you asked. What? You didn&#39;t? Well let me tell you anyway.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Badly written CSS.&lt;/li&gt;&#xA;&lt;li&gt;News (related to my own activities) regarding any of the public projects I may be working on.&lt;/li&gt;&#xA;&lt;li&gt;Guides on specific tuning/best practices as and when I have a need to do something that relates.&lt;/li&gt;&#xA;&lt;li&gt;Resolutions to problems I face, but hopefully only the complicated ones. Hopefully they might help you, and if not, they&#39;ll help me when I forget!&lt;/li&gt;&#xA;&lt;li&gt;Anything that crops up in the news that &lt;em&gt;really&lt;/em&gt; irks or excites me.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;So… lets see if I can get it started keep it up.&lt;/p&gt;&#xA;</content>
  </entry>
</feed>
