{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Ramblings of an aging IT geek",
  "home_page_url": "https://blog.i0.pm",
  "feed_url": "https://blog.i0.pm/feed.json",
  "authors": [
    {
      "name": "John Mylchreest"
    }
  ],
  "hubs": [
    {
      "type": "WebSub",
      "url": "https://pubsubhubbub.appspot.com/"
    }
  ],
  "items": [
    {
      "id": "https://blog.i0.pm/posts/decisions-weighted/",
      "url": "https://blog.i0.pm/posts/decisions-weighted/",
      "title": "Decisions, Weighted",
      "summary": "A blueprint for working in existing codebases needed a way to outrank modern best practice, so aide decisions now carry a precedence weight.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/aide\"\u003eaide\u003c/a\u003e keeps a project'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'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.\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"/posts/aide-blueprints/\"\u003eBlueprints\u003c/a\u003e 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. \u003ca href=\"/posts/decisions-inherited/\"\u003eEstates\u003c/a\u003e 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't invent its own.\u003c/p\u003e\n\u003cp\u003eUntil 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'd been meaning to get to.\u003c/p\u003e\n\u003ch2 id=\"precedence-and-one-threshold\"\u003ePrecedence, and One Threshold\u003c/h2\u003e\n\u003cp\u003eDecisions now carry an integer precedence, where higher gets injected earlier. For most of them that is all it does, it's a sort key and nothing more.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide decision set house-style \u0026#34;Follow the repo\u0026#34; --precedence=100\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe 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:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-markdown\"\u003e## Overriding Decisions\n\nThese take precedence over every decision below and over general best practice.\nWhere they conflict with anything else, these win:\n\n- **existing-codebase-precedence**: The repository is the authority…\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSo 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.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003ePrecedence\u003c/th\u003e\n\u003cth\u003eRenders in\u003c/th\u003e\n\u003cth\u003eClaims\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026gt;= 100\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e## Overriding Decisions\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u0026quot;where this conflicts with anything below, this wins\u0026quot;\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e1\u003c/code\u003e–\u003ccode\u003e99\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e## Project Decisions\u003c/code\u003e, above the defaults\u003c/td\u003e\n\u003ctd\u003enothing\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e0\u003c/code\u003e (default)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e## Project Decisions\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003enothing\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003enegative\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e## Project Decisions\u003c/code\u003e, last\u003c/td\u003e\n\u003ctd\u003enothing\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eThe 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.\u003c/p\u003e\n\u003cp\u003eThe 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's too.\u003c/p\u003e\n\u003cpre tabindex=\"0\" class=\"mermaid\"\u003eflowchart TD\n    L[local store] --\u0026gt; S{precedence}\n    P[parent estate] --\u0026gt; S\n    R[peer rings] --\u0026gt; S\n    S --\u0026gt;|100 and above| O[\u0026#34;## Overriding Decisions\u0026#34;]\n    S --\u0026gt;|below 100| D[\u0026#34;## Project Decisions\u0026#34;]\n\u003c/pre\u003e\n\u003cp\u003eOne useful property of the design is that an omitted precedence inherits whatever the current revision carries:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-go\"\u003efunc ResolvePrecedence(g DecisionGetter, topic string, requested *int) int {\n\tif requested != nil {\n\t\treturn *requested\n\t}\n\tif prev, err := g.GetDecision(topic); err == nil \u0026amp;\u0026amp; prev != nil {\n\t\treturn prev.Precedence\n\t}\n\treturn memory.PrecedenceDefault\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eDecision 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 \u003ccode\u003e0\u003c/code\u003e, 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.\u003c/p\u003e\n\u003cp\u003eI also fixed an existing bug I hadn't found until now. \u003ccode\u003esessionFetchContext\u003c/code\u003e was ranging a map:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-go\"\u003e// Ranging a map made this block reorder on every session, which is\n// noise in agent context and defeats prompt caching on the prefix.\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eGo 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'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.\u003c/p\u003e\n\u003ch2 id=\"the-blueprint-that-started-it\"\u003eThe Blueprint That Started It\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003eexisting-software-project\u003c/code\u003e is the blueprint that prompted all of the above, and the only one that ships above the threshold. It's a bundle of decisions about working in a codebase you didn't write, all at \u003ccode\u003edefault_precedence: 100\u003c/code\u003e, and unlike the language blueprints it includes nothing of its own, since it's meant to sit alongside whatever language blueprints you already have:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide blueprint import existing-software-project\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe \u003ccode\u003eexisting-*\u003c/code\u003e decisions are there to honour a project'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't impose something like Conventional Commits on a repository whose history has never used them.\u003c/p\u003e\n\u003ch2 id=\"and-some-python\"\u003eAnd Some Python\u003c/h2\u003e\n\u003cp\u003eI've been working on more Python projects recently, so those decisions are now a blueprint too: \u003ccode\u003epython\u003c/code\u003e, with \u003ccode\u003epython-django\u003c/code\u003e, \u003ccode\u003epython-api\u003c/code\u003e and \u003ccode\u003epython-github-actions\u003c/code\u003e on top. Detection also picks up \u003ccode\u003erequirements.txt\u003c/code\u003e and \u003ccode\u003euv.lock\u003c/code\u003e for Python, and \u003ccode\u003emanage.py\u003c/code\u003e for Django.\u003c/p\u003e\n\u003cp\u003eThe \u003ca href=\"https://github.com/jmylchreest/aide/blob/main/docs/docs/features/blueprints.md\"\u003eblueprints documentation\u003c/a\u003e has the per-decision breakdowns for each of the new bundles if you want the detail.\u003c/p\u003e\n",
      "date_published": "2026-08-01T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-decisions-weighted-df23ddaa22d6.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 10007314
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/decisions-inherited/",
      "url": "https://blog.i0.pm/posts/decisions-inherited/",
      "title": "Decisions, Inherited",
      "summary": "aide decisions now travel. Subprojects inherit ADRs from their parent superrepo, and teams can subscribe to each other's decisions over plain git.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/aide\"\u003eaide\u003c/a\u003e records the architectural decisions a project has made and injects them into every session. That works right up until your project \u003cem\u003econtains\u003c/em\u003e other projects: a superrepo full of submodules is really one estate with one set of opinions, but each subproject's store had no idea the others existed. As of v0.1.8, decisions travel: down from ancestors, and sideways between teams.\u003c/p\u003e\n\u003ch2 id=\"the-estate\"\u003eThe Estate\u003c/h2\u003e\n\u003cp\u003eaide now works out where it is (and what sits above it) from VCS evidence, with exactly one resolver:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide anchor --json\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEverything else reads that one answer, which also fixed a family of bugs where a session opened at the superrepo wrote into the wrong project's store. Sorry about those. Session context gains an \u003cstrong\u003eEstate\u003c/strong\u003e section (parents above, subprojects below), and \u003ca href=\"/posts/aide-web-dashboard/\"\u003eaide-web\u003c/a\u003e draws the whole tree.\u003c/p\u003e\n\u003ch2 id=\"inherit-from-ancestors\"\u003eInherit from Ancestors\u003c/h2\u003e\n\u003cp\u003eNothing to configure. At session start aide walks up the chain and overlays each ancestor's decisions onto your context, labelled with where they came from. A worked example: parent repo \u003ccode\u003etl\u003c/code\u003e decides \u003ccode\u003efav-colour = orange\u003c/code\u003e and \u003ccode\u003eteam-editor = vim\u003c/code\u003e; the nested repo \u003ccode\u003etl/webshop\u003c/code\u003e has its own store with \u003ccode\u003eapi-style = REST\u003c/code\u003e and \u003ccode\u003efav-colour = blue\u003c/code\u003e. A session inside webshop sees:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eapi-style:   REST   [local]\nfav-colour:  blue   [local]        \u0026lt;- shadows the parent\u0026#39;s orange\nteam-editor: vim    [parent: tl]   \u0026lt;- inherited\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eShadowing is by topic and nearest wins: webshop's blue means the parent'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't corrupt anything. It isn'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 \u003ccode\u003etl\u003c/code\u003e still answers orange. The labels carry the semantics, not just the values.\u003c/p\u003e\n\u003cp\u003eTo override a parent, then, you just set your own (theirs stays intact upstream):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide decision set fav-colour \u0026#34;blue\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eTo record a decision \u003cem\u003eat\u003c/em\u003e the estate level instead, aim the write upwards:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide --store parent decision set logging \u0026#34;slog, structured\u0026#34;   # nearest container\naide --store top decision set go-version \u0026#34;1.26\u0026#34;               # the estate root\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eAnd \u003ccode\u003eAIDE_CASCADE_DISABLED=1\u003c/code\u003e switches the whole cascade off.\u003c/p\u003e\n\u003ch2 id=\"subscribe-to-peers\"\u003eSubscribe to Peers\u003c/h2\u003e\n\u003cp\u003eFor teams that don't share a filesystem, subscriptions run over plain git. Name your sources in \u003ccode\u003e.aide/config/aide.json\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-json\"\u003e{ \u0026#34;subscriptions\u0026#34;: [\n    { \u0026#34;name\u0026#34;: \u0026#34;platform-team\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;git@host:platform/context.git\u0026#34;, \u0026#34;branch\u0026#34;: \u0026#34;main\u0026#34; },\n    { \u0026#34;name\u0026#34;: \u0026#34;proto-repo\u0026#34;,    \u0026#34;path\u0026#34;: \u0026#34;../protos\u0026#34; }\n] }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat's the setup done: the next session start fetches them, and peer decisions appear in your context as a read-only layer labelled \u003ccode\u003efrom peer platform-team\u003c/code\u003e. Where you \u003cem\u003eoverride\u003c/em\u003e a parent, you \u003cem\u003eadopt\u003c/em\u003e a peer:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide decision adopt api-style --from=platform-team\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003ewhich copies it into your store with adoption provenance. Add \u003ccode\u003e\u0026quot;publish\u0026quot;: true\u003c/code\u003e 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.\u003c/p\u003e\n\u003ch2 id=\"why-not-just-import\"\u003eWhy Not Just Import?\u003c/h2\u003e\n\u003cp\u003eaide already had two ways to get decisions into a store from outside: \u003ca href=\"/posts/aide-blueprints/\"\u003eblueprints\u003c/a\u003e (curated best-practice defaults you import when bootstrapping a project) and \u003ccode\u003eaide share import\u003c/code\u003e (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 \u003cem\u003eyours\u003c/em\u003e to keep or overturn; a share import is one team converging one project's stores.\u003c/p\u003e\n\u003cp\u003eCopies are the wrong tool for a living ADR owned by somebody else, though. The platform team's \u003ccode\u003eapi-style\u003c/code\u003e 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 \u003cem\u003ereference\u003c/em\u003e: you always read the owning team's current word, it arrives labelled with where it came from, and it never leaks back out of your store (you can't accidentally re-publish somebody else'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's the entire justification, and it's the same one that makes the ancestor cascade read at session start rather than syncing records downward.\u003c/p\u003e\n\u003ch2 id=\"the-session-is-the-scheduler\"\u003eThe Session Is the Scheduler\u003c/h2\u003e\n\u003cp\u003eMy 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.\u003c/p\u003e\n\u003cp\u003eDecisions are only ever made \u003cem\u003einside\u003c/em\u003e sessions. So session \u003cstrong\u003estart\u003c/strong\u003e pulls (any subscription cache older than an hour is refreshed, quietly, offline-silent) and session \u003cstrong\u003eend\u003c/strong\u003e 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's a bug report.\u003c/p\u003e\n\u003cp\u003eThe other cases need even less. Inside a superrepo there's nothing to sync at all: the cascade reads the ancestor'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 \u003ccode\u003egit pull\u003c/code\u003e is the import, \u003ccode\u003egit push\u003c/code\u003e 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't tell the difference. \u003ccode\u003eaide sync\u003c/code\u003e 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.\u003c/p\u003e\n\u003cp\u003ePrecedence in one line: local beats nearest ancestor, ancestors beat peers. Your project gets the last word; it just no longer starts the conversation ignorant.\u003c/p\u003e\n",
      "date_published": "2026-07-21T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-decisions-inherited-30d3a26bd828.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 11878514
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec-0-0-33/",
      "url": "https://blog.i0.pm/posts/rosec-0-0-33/",
      "title": "rosec 0.0.33: The Security Key Signs In",
      "summary": "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.",
      "content_html": "\u003cp\u003eThis is a pretty chunky release for \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e, big enough that I genuinely flirted with calling it 0.1.0 before coming to my senses.\u003c/p\u003e\n\u003cp\u003eThe star of the show is something I'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.\u003c/p\u003e\n\u003cp\u003eA passkey is really just a private key with some metadata, and since rosec'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's the \u0026quot;don't lose it\u0026quot; part handled.\u003c/p\u003e\n\u003cp\u003eThe tricky half was the \u0026quot;make the browser happy\u0026quot; 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. \u003ccode\u003e/dev/uhid\u003c/code\u003e lets userspace create one… but it's root-only, because the same interface could just as easily pretend to be a keyboard and type whatever it likes.\u003c/p\u003e\n\u003cp\u003eEnter the tiny socket-activated broker \u003ccode\u003erosec-uhid\u003c/code\u003e. 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's the digital equivalent of yanking the USB stick. It's off by default and desktop-only: install the broker and set \u003ccode\u003efido2 = true\u003c/code\u003e if you want it.\u003c/p\u003e\n\u003ch2 id=\"trust-you-can-actually-rotate\"\u003eTrust you can actually rotate\u003c/h2\u003e\n\u003cp\u003eThe 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's also a user-configurable trust anchor in \u003ccode\u003erosec.toml\u003c/code\u003e, so third-party authors can ship signed plugins without forcing people to turn off verification entirely.\u003c/p\u003e\n\u003cp\u003eWe also added proper tooling: \u003ccode\u003erosec-package-wasm\u003c/code\u003e for authors and \u003ccode\u003erosec provider validate\u003c/code\u003e for users. It even schema-validates the sandbox policy before signing, so any silly mistakes blow up on the author's machine instead of yours. Considerate, aren't we?\u003c/p\u003e\n\u003ch2 id=\"the-rest\"\u003eThe rest\u003c/h2\u003e\n\u003cp\u003eWe did a full security review on the new code and the wider daemon: the virtual authenticator's device node is now \u003ccode\u003e0600\u003c/code\u003e owned by you (no more leaning on seat \u003ccode\u003euaccess\u003c/code\u003e), 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.\u003c/p\u003e\n\u003cp\u003eThe prompt also gained a scrollable single-select mode, very handy when you have multiple passkeys that match the same site.\u003c/p\u003e\n\u003cp\u003eUsual caveat: these are software passkeys, not hardware-bound. So sites that demand hardware attestation will still turn up their noses. That's the trade-off for keys you can actually back up and move between machines.\u003c/p\u003e\n\u003cp\u003eRepo and docs are on \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-07-09T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-0-0-33-0348eaac5815.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 6363818
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/clipferry-an-x11-to-wayland-clipboard-bridge/",
      "url": "https://blog.i0.pm/posts/clipferry-an-x11-to-wayland-clipboard-bridge/",
      "title": "clipferry: An X11 to Wayland Clipboard Bridge",
      "summary": "A small Rust daemon that bridges X11 and Wayland clipboards under Xwayland (xwayland-satellite on niri, builtin on Hyprland). Lazy and without polling.",
      "content_html": "\u003cp\u003eClipferry solves the problem of X11 ↔ Wayland clipboard sync. Let me explain why I built this.\u003c/p\u003e\n\u003cp\u003eI often use \u003ca href=\"https://github.com/YaLTeR/niri\"\u003eniri\u003c/a\u003e or \u003ca href=\"https://hypr.land\"\u003eHyprland\u003c/a\u003e with Steam. Steam, Proton and Wine are all X11 clients: they reach Wayland via \u003ca href=\"https://github.com/Supreeeme/xwayland-satellite\"\u003exwayland-satellite\u003c/a\u003e on niri, and via Hyprland's builtin Xwayland on Hyprland.\u003c/p\u003e\n\u003cp\u003eCopying inside a Wayland app and pasting into a Wine window works. Copying \u003cem\u003eout\u003c/em\u003e of an X11 app doesn'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.\u003c/p\u003e\n\u003cp\u003eRecent xwayland-satellite releases have closed some of these gaps, but apps that don't play the usual focus games, and less-common MIME types, still fall through on both stacks.\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/dnut/clipboard-sync\"\u003ednut/clipboard-sync\u003c/a\u003e hasn't been touched since 2023 and has a much broader scope than I wanted. Everything else was bash around \u003ccode\u003ewl-copy\u003c/code\u003e and \u003ccode\u003exclip\u003c/code\u003e, 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.\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/clipferry\"\u003e\u003cstrong\u003eclipferry\u003c/strong\u003e\u003c/a\u003e is a small Rust daemon that speaks both clipboard protocols directly and shuttles selections across on demand.\u003c/p\u003e\n\u003cp\u003eThe design is deliberately narrow.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eLazy by default.\u003c/strong\u003e 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAll the MIME types, not just text.\u003c/strong\u003e Images, HTML, \u003ccode\u003etext/uri-list\u003c/code\u003e, and a small translation table for the X11-isms (\u003ccode\u003ex-special/gnome-copied-files\u003c/code\u003e, a couple of WeChat and Wine quirks), plus INCR in both directions for the awkwardly large ones.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eLoop prevention by ownership identity, not hashing.\u003c/strong\u003e clipferry knows which selection claims are its own, so it never chases its own tail. No content hashes, no sleeps, no races.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSandboxed.\u003c/strong\u003e It applies \u003ca href=\"https://landlock.io/\"\u003eLandlock\u003c/a\u003e to itself: no filesystem, no TCP. Plus the usual systemd hardening. Clipboard content is never logged, and payload buffers are zeroed when they die.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSmall.\u003c/strong\u003e One process, a ~1 MiB binary, under 5 MiB resident.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eBy default it runs in \u003cem\u003ebackstop\u003c/em\u003e 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's builtin sync handles a case, clipferry stays out of the way. Clipboard history managers (\u003ca href=\"https://github.com/sentriz/cliphist\"\u003ecliphist\u003c/a\u003e, \u003ca href=\"https://github.com/savedra1/clipse\"\u003eclipse\u003c/a\u003e) coexist as designed.\u003c/p\u003e\n\u003cp\u003eInstall is short:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003e# Arch\nparu -S clipferry\n\n# any distro\ncargo install --git https://github.com/jmylchreest/clipferry\nsudo cp contrib/clipferry.service /usr/lib/systemd/user/\nsystemctl --user enable --now clipferry.service\nclipferry --oneshot-check    # sanity: both displays reachable?\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThere isn't much to configure, but there are a handful of flags to deal with edge cases. For most people the defaults will work. The \u003ca href=\"https://github.com/jmylchreest/clipferry\"\u003eREADME\u003c/a\u003e documents them.\u003c/p\u003e\n\u003cp\u003eIf copy-out-of-X11 has been annoying you, give it a go. Issues and patches welcome on the \u003ca href=\"https://github.com/jmylchreest/clipferry\"\u003erepo\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-07-04T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-clipferry-an-x11-to-wayland-clipboard-brid-391a636d6f81.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 6777114
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/es/posts/i-wrote-my-own-site-generator-should-you-use-it/",
      "url": "https://blog.i0.pm/es/posts/i-wrote-my-own-site-generator-should-you-use-it/",
      "title": "Escribí mi propio generador de sitios. ¿Deberías usarlo?",
      "summary": "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.",
      "content_html": "\u003cp\u003eConstruí \u003ca href=\"https://github.com/jmylchreest/colophon\"\u003ecolophon\u003c/a\u003e, 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.\u003c/p\u003e\n\u003cp\u003eLa 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ó.\u003c/p\u003e\n\u003ch2 id=\"los-consagrados\"\u003eLos consagrados\u003c/h2\u003e\n\u003cp\u003eEstas son las herramientas a las que deberías recurrir por defecto.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eHerramienta\u003c/th\u003e\n\u003cth\u003eEntorno\u003c/th\u003e\n\u003cth\u003eForma\u003c/th\u003e\n\u003cth\u003eMejor para\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eHugo\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eGo, binario único\u003c/td\u003e\n\u003ctd\u003eEnorme, rápido, maduro\u003c/td\u003e\n\u003ctd\u003eCasi cualquier blog o sitio: velocidad, un catálogo enorme de temas y extensiones, una década de respuestas en internet\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eJekyll\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eRuby\u003c/td\u003e\n\u003ctd\u003eEl original, el motor nativo de GitHub Pages\u003c/td\u003e\n\u003ctd\u003ePublicar sin configuración directamente desde un repositorio de GitHub\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eZola\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eRust, binario único\u003c/td\u003e\n\u003ctd\u003eCon pilas incluidas, sin extensiones por diseño\u003c/td\u003e\n\u003ctd\u003eUn binario único y rápido con Sass, búsqueda y resaltado incluidos, si no necesitas extensiones\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eEleventy\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eNode\u003c/td\u003e\n\u003ctd\u003eMínimo, infinitamente flexible\u003c/td\u003e\n\u003ctd\u003eGente de JavaScript que quiere control total y diez lenguajes de plantillas para elegir\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eAstro\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eNode\u003c/td\u003e\n\u003ctd\u003eFramework de contenido con islas interactivas\u003c/td\u003e\n\u003ctd\u003eSitios de contenido que además necesitan componentes interactivos de verdad\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eHugo es la respuesta sensata para la mayoría, y es con lo que \u003ca href=\"/posts/rebuilt-the-blog-again/\"\u003efuncionaba este blog al principio\u003c/a\u003e. 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.\u003c/p\u003e\n\u003cp\u003eNinguno 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.\u003c/p\u003e\n\u003ch2 id=\"en-qu-se-diferencia-colophon-de-verdad\"\u003eEn qué se diferencia colophon de verdad\u003c/h2\u003e\n\u003cp\u003ecolophon 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á:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eImágenes de portada generadas con IA a partir de una referencia \u003ccode\u003egen:\u003c/code\u003e, y lecturas de audio (TTS) habladas de cada entrada, direccionadas por contenido y cacheadas\u003c/li\u003e\n\u003cli\u003eFontanería de la IndieWeb: microformatos, webmentions y sindicación POSSE a Bluesky y Mastodon, incluidos\u003c/li\u003e\n\u003cli\u003eUn glosario con enlaces de referencia, presentaciones derivadas de la entrada, y un almacén \u003ca href=\"https://obsidian.md\"\u003eObsidian\u003c/a\u003e como fuente de primera clase\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eLa 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ó \u003ca href=\"https://github.com/Pagefind/pagefind\"\u003ePagefind\u003c/a\u003e, 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 \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/design/search.md\"\u003enotas de diseño\u003c/a\u003e 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 \u003ca href=\"https://github.com/jmylchreest/colophon/tree/main/search\"\u003emódulo independiente\u003c/a\u003e con una \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/search/SPEC.md\"\u003eespecificación neutral respecto al lenguaje\u003c/a\u003e y vectores de prueba dorados, así que indexa cualquier documento \u003ccode\u003e{id, url, title, body}\u003c/code\u003e y cualquiera puede escribir un lector conforme en otro lenguaje. Estoy contento con cómo quedó, pero la idea no es mía.\u003c/p\u003e\n\u003cp\u003eEse paquete es toda la propuesta.\u003c/p\u003e\n\u003ch2 id=\"accesibilidad-e-idiomas\"\u003eAccesibilidad e idiomas\u003c/h2\u003e\n\u003cp\u003eDos cosas en las que colophon se esfuerza de verdad, con resultados muy distintos hasta ahora.\u003c/p\u003e\n\u003cp\u003eLa 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.\u003c/p\u003e\n\u003cp\u003eLa internacionalización es la parte que todavía se mueve. Los cimientos llevan tiempo ahí: una etiqueta de idioma por entrada que fluye a \u003ccode\u003e\u0026lt;html lang\u0026gt;\u003c/code\u003e, 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 \u003ccode\u003emi-entrada.es.md\u003c/code\u003e junto a la inglesa y obtienes una versión en español en \u003ccode\u003e/es/posts/mi-entrada/\u003c/code\u003e, enlazada por un slug compartido, con alternativas \u003ccode\u003ehreflang\u003c/code\u003e, 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.\u003c/p\u003e\n\u003ch2 id=\"la-parte-honesta\"\u003eLa parte honesta\u003c/h2\u003e\n\u003cp\u003eNo 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 \u003ca href=\"https://micro.blog/\"\u003eMicro.blog\u003c/a\u003e, 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.\u003c/p\u003e\n\u003cp\u003ecolophon 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 (\u003ca href=\"https://github.com/jmylchreest/aide\"\u003eaide\u003c/a\u003e), y si tú también las usas heredarás las mismas decisiones capturadas y los mismos estándares de implementación.\u003c/p\u003e\n\u003cp\u003ePara ser justos en el otro sentido: tus \u003cem\u003epalabras\u003c/em\u003e 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í.\u003c/p\u003e\n\u003ch2 id=\"entonces-cundo-es-colophon-lo-correcto-si-es-que-alguna-vez-lo-es\"\u003eEntonces, ¿cuándo es colophon lo correcto, si es que alguna vez lo es?\u003c/h2\u003e\n\u003cp\u003eElige 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!\u003c/p\u003e\n\u003cp\u003ecolophon 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.\u003c/p\u003e\n\u003cp\u003eEs especialmente útil si escribes en \u003ca href=\"https://obsidian.md\"\u003eObsidian\u003c/a\u003e, 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.\u003c/p\u003e\n\u003ch2 id=\"relacionado\"\u003eRelacionado\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/a-colophon-blog-on-cloudflare-pages-and-r2/\"\u003eUn blog colophon en Cloudflare Pages y R2\u003c/a\u003e: cómo está construido y alojado este blog en realidad.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/wiring-the-blog-into-the-indieweb/\"\u003eConectando el blog a la IndieWeb\u003c/a\u003e: la mitad de federación y POSSE, en la práctica.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/the-post-is-the-slide-deck/\"\u003eLa entrada es la presentación\u003c/a\u003e: una de las funciones incluidas, presentaciones derivadas de una entrada.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/rebuilt-the-blog-again/\"\u003eReconstruí el blog. Otra vez.\u003c/a\u003e: por qué me fui de Hugo en primer lugar.\u003c/li\u003e\n\u003c/ul\u003e\n",
      "date_published": "2026-06-28T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/es-posts-i-wrote-my-own-site-generator-should-yo-8f4e2bc4d2cd.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 20778408
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/i-wrote-my-own-site-generator-should-you-use-it/",
      "url": "https://blog.i0.pm/posts/i-wrote-my-own-site-generator-should-you-use-it/",
      "title": "I wrote my own site generator. Should you use it?",
      "summary": "An honest look at colophon next to Hugo, Jekyll, Zola, Eleventy and Astro: what each is good at, and when colophon is and isn't the right choice.",
      "content_html": "\u003cp\u003eI built \u003ca href=\"https://github.com/jmylchreest/colophon\"\u003ecolophon\u003c/a\u003e, the generator this blog runs on, so treat everything here as the opinion of a man reviewing his own homework. I've tried to keep it to facts and features rather than affection. Where colophon is the wrong choice I'll say so, because pretending otherwise helps nobody, least of all me.\u003c/p\u003e\n\u003cp\u003eThe 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.\u003c/p\u003e\n\u003ch2 id=\"the-grown-ups\"\u003eThe grown-ups\u003c/h2\u003e\n\u003cp\u003eThese are the tools you should reach for by default.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eTool\u003c/th\u003e\n\u003cth\u003eRuntime\u003c/th\u003e\n\u003cth\u003eShape\u003c/th\u003e\n\u003cth\u003eBest at\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eHugo\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eGo, single binary\u003c/td\u003e\n\u003ctd\u003eHuge, fast, mature\u003c/td\u003e\n\u003ctd\u003eAlmost any blog or site: speed, a vast theme and plugin ecosystem, a decade of answers online\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eJekyll\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eRuby\u003c/td\u003e\n\u003ctd\u003eThe original, GitHub Pages' native engine\u003c/td\u003e\n\u003ctd\u003eZero-config publishing straight from a GitHub repo\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eZola\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eRust, single binary\u003c/td\u003e\n\u003ctd\u003eBatteries-ish, no plugins by design\u003c/td\u003e\n\u003ctd\u003eA fast single binary with Sass, search and highlighting built in, if you don't need extensions\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eEleventy\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eNode\u003c/td\u003e\n\u003ctd\u003eMinimal, endlessly flexible\u003c/td\u003e\n\u003ctd\u003eJavaScript people who want full control and ten templating languages to choose from\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eAstro\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eNode\u003c/td\u003e\n\u003ctd\u003eContent framework with interactive islands\u003c/td\u003e\n\u003ctd\u003eContent sites that also need real interactive components\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eHugo is the sensible answer for most people, and it's what this blog \u003ca href=\"/posts/rebuilt-the-blog-again/\"\u003eoriginally ran on\u003c/a\u003e. 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 \u0026quot;blog\u0026quot; is really an app wearing a blog's clothes.\u003c/p\u003e\n\u003cp\u003eNone of them ship AI media generation, spoken-audio readings, IndieWeb webmentions or POSSE syndication in the box. That isn't a criticism: it's a scope decision, and you can add most of it with plugins and a few external services.\u003c/p\u003e\n\u003ch2 id=\"where-colophon-is-actually-different\"\u003eWhere colophon is actually different\u003c/h2\u003e\n\u003cp\u003ecolophon is also a single Go binary that turns Markdown into a static site, so on the core job it's unremarkable. The difference is what it folds in as first-party features, the parts you'd otherwise assemble from a plugin here and a SaaS there:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eAI-generated hero images from a \u003ccode\u003egen:\u003c/code\u003e prompt, and spoken text-to-speech readings of each post, content-addressed and cached\u003c/li\u003e\n\u003cli\u003eIndieWeb plumbing: microformats, webmentions, and POSSE syndication to Bluesky and Mastodon, built in\u003c/li\u003e\n\u003cli\u003eA glossary with reference links, slide decks derived from the post, and an \u003ca href=\"https://obsidian.md\"\u003eObsidian\u003c/a\u003e vault as a first-class source\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe on-site search deserves a specific mention, and a specific honesty. The architecture isn't original: it's the model \u003ca href=\"https://github.com/Pagefind/pagefind\"\u003ePagefind\u003c/a\u003e popularised, a sharded index built from the rendered output, with the browser fetching only the shards and fragments a query actually touches, and colophon's \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/design/search.md\"\u003edesign notes\u003c/a\u003e say exactly that. What's different is that it'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's Rust toolchain. It lives as a \u003ca href=\"https://github.com/jmylchreest/colophon/tree/main/search\"\u003estandalone module\u003c/a\u003e with a \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/search/SPEC.md\"\u003elanguage-neutral spec\u003c/a\u003e and golden test vectors, so it indexes any \u003ccode\u003e{id, url, title, body}\u003c/code\u003e documents and anyone can write a conformant reader in another language. I'm pleased with how it turned out, but I'm not claiming the idea.\u003c/p\u003e\n\u003cp\u003eThat bundle is the whole pitch.\u003c/p\u003e\n\u003ch2 id=\"accessibility-and-languages\"\u003eAccessibility, and languages\u003c/h2\u003e\n\u003cp\u003eTwo things colophon tries genuinely hard at, with very different results so far.\u003c/p\u003e\n\u003cp\u003eAccessibility is treated as the engine'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's baked into the engine, not left to you.\u003c/p\u003e\n\u003cp\u003eInternationalisation is the part still in flux. The foundations have been there a while: a per-post language tag that flows into \u003ccode\u003e\u0026lt;html lang\u0026gt;\u003c/code\u003e, 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 \u003ccode\u003emy-post.es.md\u003c/code\u003e beside the English one and you get a Spanish version at \u003ccode\u003e/es/posts/my-post/\u003c/code\u003e, linked by a shared slug, with \u003ccode\u003ehreflang\u003c/code\u003e 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's a Spanish version, and the selector at the top will take you to it. That's an experiment in the feature rather than a change of language here. I'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's early, and Hugo's multilingual is still more complete and more proven, but the bones are good, and it's exactly the sort of thing where help would be very welcome.\u003c/p\u003e\n\u003ch2 id=\"the-honest-part\"\u003eThe honest part\u003c/h2\u003e\n\u003cp\u003eThere 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 \u003ca href=\"https://micro.blog/\"\u003eMicro.blog\u003c/a\u003e, which is a lovely thing but a hosted, paid platform rather than a generator you run. colophon's particular combination doesn't exist elsewhere in one box, and that cuts both ways: it's the reason to look at it, and the reason not to.\u003c/p\u003e\n\u003cp\u003ecolophon is a work in progress and right now it'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's what makes getting your thoughts down quick, and I'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 (\u003ca href=\"https://github.com/jmylchreest/aide\"\u003eaide\u003c/a\u003e), and if you do too you'll inherit the same captured decisions and implementation standards.\u003c/p\u003e\n\u003cp\u003eTo be fair in the other direction: your \u003cem\u003ewords\u003c/em\u003e aren'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's the bit I care about most, and it's true of all the tools here.\u003c/p\u003e\n\u003ch2 id=\"so-when-is-colophon-the-right-thing-if-ever\"\u003eSo when is colophon the right thing, if ever\u003c/h2\u003e\n\u003cp\u003ePick 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!\u003c/p\u003e\n\u003cp\u003ecolophon 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.\u003c/p\u003e\n\u003cp\u003eIt's especially useful if you write in \u003ca href=\"https://obsidian.md\"\u003eObsidian\u003c/a\u003e, and you can happily live close to the edge of a young, one-maintainer project. That's a real reason. It'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'm content to be my own support desk.\u003c/p\u003e\n\u003ch2 id=\"related\"\u003eRelated\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/a-colophon-blog-on-cloudflare-pages-and-r2/\"\u003eA Colophon Blog on Cloudflare Pages and R2\u003c/a\u003e: how this blog is actually built and hosted.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/wiring-the-blog-into-the-indieweb/\"\u003eWiring the Blog into the IndieWeb\u003c/a\u003e: the federation and POSSE half, in practice.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/the-post-is-the-slide-deck/\"\u003eThe Post Is the Slide Deck\u003c/a\u003e: one of the bundled features, decks derived from a post.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/rebuilt-the-blog-again/\"\u003eRebuilt the Blog. Again.\u003c/a\u003e: why I moved off Hugo in the first place.\u003c/li\u003e\n\u003c/ul\u003e\n",
      "date_published": "2026-06-28T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-i-wrote-my-own-site-generator-should-you-u-b8a70541dc34.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 16132800
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/the-post-is-the-slide-deck/",
      "url": "https://blog.i0.pm/posts/the-post-is-the-slide-deck/",
      "title": "The Post Is the Slide Deck",
      "summary": "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.",
      "content_html": "\u003cp\u003eThis 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 \u003ccode\u003e/\u0026lt;slug\u0026gt;/slides/\u003c/code\u003e and linked from its Downloads box, with the deck \u003cem\u003ederived\u003c/em\u003e from the post rather than maintained beside it. This post is one: open the Slides link and present it.\u003c/p\u003e\n\u003ch2 id=\"one-document-more-than-one-medium\"\u003eOne document, more than one medium\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eThe same material, read or presented\u003c/li\u003e\n\u003cli\u003eNo second copy to keep in sync\u003c/li\u003e\n\u003cli\u003eThe deck updates when the post does\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eA 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'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.\u003c/p\u003e\n\u003ch2 id=\"turn-it-on\"\u003eTurn it on\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# colophon.yaml: the site default (off, here)\nslides:\n  enabled: false\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# any post: opt in, in the frontmatter\nslides: true\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe 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.\u003c/p\u003e\n\u003ch2 id=\"what-becomes-what\"\u003eWhat becomes what\u003c/h2\u003e\n\u003cpre tabindex=\"0\" class=\"mermaid\"\u003eflowchart LR\n  P[\u0026#34;The post\u0026#34;] --\u0026gt; S[\u0026#34;Headings: slides\u0026#34;]\n  P --\u0026gt; N[\u0026#34;Prose: presenter notes\u0026#34;]\n  P --\u0026gt; B[\u0026#34;Code, images, tables, maths, diagrams: on the slide\u0026#34;]\n\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003eHeadings split the deck into slides\u003c/li\u003e\n\u003cli\u003eParagraphs drop into the presenter notes\u003c/li\u003e\n\u003cli\u003eEverything else stays on the slide: code, images, tables, maths, diagrams, callouts, pull-quotes\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eHere 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 \u003cem\u003eon\u003c/em\u003e 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.\u003c/p\u003e\n\u003ch2 id=\"where-the-breaks-go\"\u003eWhere the breaks go\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eslides:\n  enabled: true\n  split: [h2]        # h2 opens a slide; h3 and deeper fold into bullets\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003eHeading levels: \u003ccode\u003eh1\u003c/code\u003e through \u003ccode\u003eh6\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eStructural: \u003ccode\u003ehr\u003c/code\u003e, \u003ccode\u003esplitslide\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003eBlock kinds: \u003ccode\u003eimage\u003c/code\u003e, \u003ccode\u003etable\u003c/code\u003e, \u003ccode\u003ecode\u003c/code\u003e, \u003ccode\u003emath\u003c/code\u003e, \u003ccode\u003ediagram\u003c/code\u003e, \u003ccode\u003eaudio\u003c/code\u003e, \u003ccode\u003evideo\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003etext:\u0026lt;match\u0026gt;\u003c/code\u003e: break before a block whose text starts with the match\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eBy default every heading opens a new slide. Narrow it with \u003ccode\u003esplit\u003c/code\u003e when you'd rather fold the deeper headings into bullets. This post sets \u003ccode\u003esplit: [h2]\u003c/code\u003e, which is exactly why each of these sections is its own slide.\u003c/p\u003e\n\u003ch2 id=\"three-inline-markers\"\u003eThree inline markers\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e\u0026lt;splitslide\u0026gt;\u003c/code\u003e forces a break mid-section\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e\u0026lt;slide\u0026gt;…\u0026lt;/slide\u0026gt;\u003c/code\u003e makes one verbatim slide\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e\u0026lt;noslide\u0026gt;…\u0026lt;/noslide\u0026gt;\u003c/code\u003e keeps content in the post but out of the deck\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThese mirror the \u003ccode\u003e\u0026lt;tts\u0026gt;\u003c/code\u003e family, and they're escape hatches rather than the main event. I reach for \u003ccode\u003e\u0026lt;noslide\u0026gt;\u003c/code\u003e 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 \u003ccode\u003e\u0026lt;slide\u0026gt;\u003c/code\u003e, not a screenshot of one.\u003c/p\u003e\n\n\u003cdiv class=\"callout callout-tip\" data-callout=\"tip\"\u003e\n\u003cdiv class=\"callout-title\"\u003eOne idea per slide\u003c/div\u003e\n\u003cdiv class=\"callout-body\"\u003e\n\u003cp\u003eSometimes a slide should be a single line and nothing else. \u003ccode\u003e\u0026lt;slide\u0026gt;\u003c/code\u003e gives you exactly that, verbatim.\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\n\u003ch2 id=\"present-it-or-read-it\"\u003ePresent it, or read it\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ckbd\u003e←\u003c/kbd\u003e / \u003ckbd\u003e→\u003c/kbd\u003e or swipe to move\u003c/li\u003e\n\u003cli\u003e\u003ckbd\u003eP\u003c/kbd\u003e presenter notes, \u003ckbd\u003eF\u003c/kbd\u003e fullscreen\u003c/li\u003e\n\u003cli\u003e\u003ckbd\u003eEnter\u003c/kbd\u003e plays the slide's media, \u003ckbd\u003eEsc\u003c/kbd\u003e closes the deck\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eWith JavaScript it'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's no separate export and no PDF to email round, and it degrades to something useful rather than a blank frame.\u003c/p\u003e\n\u003ch2 id=\"on-in-preview-off-in-prod\"\u003eOn in preview, off in prod\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eenvironments:\n  - name: preview\n    slides: { enabled: true }    # decks on while I\u0026#39;m drafting\n  - name: production\n    # inherits the site default (off)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eA 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's still coming together, and only switches on in production once it's actually any good.\u003c/p\u003e\n\u003cfigure class=\"pullquote\"\u003e\n\u003cblockquote\u003e\n\u003cp\u003eWrite the talk once. Let the deck be a view of it, not a second copy.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003c/figure\u003e\n\n\u003ch2 id=\"related\"\u003eRelated\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/a-colophon-blog-on-cloudflare-pages-and-r2/\"\u003eA Colophon Blog on Cloudflare Pages and R2\u003c/a\u003e: where this blog, and the build that now derives these decks, were first set up.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/elevenlabs-voices-and-pronunciation-dictionaries/\"\u003eElevenLabs Voices and Pronunciation Dictionaries\u003c/a\u003e: another thing a post grows on its own, a spoken reading.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/wiring-the-blog-into-the-indieweb/\"\u003eWiring the Blog into the IndieWeb\u003c/a\u003e: more derived-from-the-post plumbing, feeds and webmentions.\u003c/li\u003e\n\u003c/ul\u003e\n\n",
      "date_published": "2026-06-28T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-the-post-is-the-slide-deck-319ceba35148.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 9221114
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/elevenlabs-voices-and-pronunciation-dictionaries/",
      "url": "https://blog.i0.pm/posts/elevenlabs-voices-and-pronunciation-dictionaries/",
      "title": "ElevenLabs Voices and Pronunciation Dictionaries",
      "summary": "colophon now offers ElevenLabs alongside MiniMax for spoken posts, plus pronunciation dictionaries that fix the words a technical blog mispronounces, on either provider.",
      "content_html": "\u003cp\u003eEvery post on this blog has a spoken reading, generated text-to-speech (TTS), sitting under the title for anyone who'd rather listen. It's been driven by \u003ca href=\"https://platform.minimax.io/\"\u003eMiniMax\u003c/a\u003e since I added it. Two things have changed recently, and I think they're worth writing down. A caveat first: this is all new and still moving, so treat the specifics as a snapshot that may change.\u003c/p\u003e\n\u003cp\u003eThe short version: there's now a \u003cstrong\u003esecond voice provider, ElevenLabs\u003c/strong\u003e, and \u003cstrong\u003epronunciation dictionaries\u003c/strong\u003e that work across both. Plus a quiet format change underneath.\u003c/p\u003e\n\u003ch2 id=\"pronunciation-dictionaries\"\u003ePronunciation dictionaries\u003c/h2\u003e\n\u003cp\u003eThe 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 \u0026quot;raowter\u0026quot; for \u003cem\u003erouter\u003c/em\u003e, gives \u003cem\u003ecache\u003c/em\u003e an extra syllable, and has its own ideas about \u003cem\u003ealuminium\u003c/em\u003e. Re-recording isn't an option when the audio is generated, so the fix is to tell the voice how to say specific words.\u003c/p\u003e\n\u003cp\u003ecolophon now takes a \u003cstrong\u003epronunciation dictionary\u003c/strong\u003e, and it applies to whichever provider you're on. The cheapest version is the bundled British one:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  speech:\n    pronunciation_dict: en_GB     # the dictionary shipped with colophon\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eOr point it at your own file, relative to the site root:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  speech:\n    pronunciation_dict: pronunciation.yaml\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe format is small on purpose. Each entry is a \u003ccode\u003eword\u003c/code\u003e plus \u003cstrong\u003eone\u003c/strong\u003e of two ways to fix it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epronunciations:\n  - word: router\n    ipa: ˈruːtə        # IPA: precise; rendered to each provider\u0026#39;s phoneme mechanism\n  - word: nginx\n    say: engine x      # respelling: substituted as plain text, works on any provider\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe two knobs are worth understanding:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eipa\u003c/code\u003e\u003c/strong\u003e is the International Phonetic Alphabet (IPA) transcription, precise and unambiguous. colophon renders it to each provider's own phoneme mechanism (more on the ElevenLabs side of that below).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003esay\u003c/code\u003e\u003c/strong\u003e is a plain respelling, substituted as ordinary text before synthesis. It's less exact but completely portable, any voice can read \u0026quot;engine x\u0026quot;, no phoneme support required.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eOne piece of hard-won advice that's baked into the bundled dictionary: keep it small. Overriding a word the voice \u003cem\u003ealready\u003c/em\u003e says correctly almost always makes it worse. Add a word only after you've heard it get one wrong, and audition the fix before you keep it.\u003c/p\u003e\n\u003ch2 id=\"a-second-voice-elevenlabs\"\u003eA second voice: ElevenLabs\u003c/h2\u003e\n\u003cp\u003eMiniMax is cheap and fine, but \u003ca href=\"https://elevenlabs.io/\"\u003eElevenLabs\u003c/a\u003e is the one people reach for when they want a specific, higher-fidelity voice, including a clone of their own. colophon now has an \u003ccode\u003eelevenlabs\u003c/code\u003e speech driver alongside the MiniMax one. Switching is a config change:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  speech:\n    provider: elevenlabs\n    model: eleven_multilingual_v2          # the default; override if you like\n    voice: \u0026#34;\u0026lt;your ElevenLabs voice id\u0026gt;\u0026#34;    # a premade voice, or your own cloned one\n    api_key: \u0026#34;{env:ELEVENLABS_API_KEY}\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eAs with every secret in colophon, the key only ever arrives through the environment, never written into the config. It's read from \u003ccode\u003eELEVENLABS_API_KEY\u003c/code\u003e (or \u003ccode\u003eCOLOPHON_ELEVENLABS_API_KEY\u003c/code\u003e), so it lives in your keyring or CI secrets exactly like the deploy tokens.\u003c/p\u003e\n\u003ch3 id=\"the-api-key-permissions\"\u003eThe API key permissions\u003c/h3\u003e\n\u003cp\u003eElevenLabs lets you scope an API key to specific capabilities, and colophon needs:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eText to Speech\u003c/strong\u003e: to generate the readings. This is the only permission you need if you're not using a pronunciation dictionary.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePronunciation Dictionaries (read and write)\u003c/strong\u003e: only if you set \u003ccode\u003epronunciation_dict\u003c/code\u003e. ElevenLabs doesn't take IPA inline; it applies pronunciation through an \u003cem\u003euploaded, versioned dictionary\u003c/em\u003e tied to your account. So colophon syncs your \u003ccode\u003eipa\u003c/code\u003e 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.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eSo: a plain ElevenLabs reading needs \u003cstrong\u003eText to Speech\u003c/strong\u003e alone; add \u003cstrong\u003ePronunciation Dictionaries (read/write)\u003c/strong\u003e the moment you want your \u003ccode\u003eipa\u003c/code\u003e overrides to take effect. The \u003ccode\u003esay\u003c/code\u003e respellings, being plain text substitution, need no extra permission on either provider.\u003c/p\u003e\n\u003ch2 id=\"the-quiet-change-wav-now-opus-later\"\u003eThe quiet change: WAV now, Opus later\u003c/h2\u003e\n\u003cp\u003eUnderneath 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 \u003cstrong\u003eWAV\u003c/strong\u003e file. Two nice consequences fall out of that. The waveform you see under each reading is computed from that same PCM, so there'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's gone). And WAV is trivially decodable, so the peaks come for free.\u003c/p\u003e\n\u003cp\u003eThe obvious objection is size: WAV is uncompressed. That'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 (\u003ccode\u003e.opus\u003c/code\u003e is a recognised type), it just isn'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.\u003c/p\u003e\n\u003ch2 id=\"putting-it-together\"\u003ePutting it together\u003c/h2\u003e\n\u003cp\u003eSo 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 \u0026quot;raowter\u0026quot;. The dictionary is provider-agnostic; the voice and the key are provider-specific; the output is a WAV and a waveform.\u003c/p\u003e\n\u003cp\u003eAll of this is recent and still settling, so the field names and defaults may move. If you're wiring it up from this post, sanity-check against your own \u003ccode\u003ecolophon.yaml\u003c/code\u003e and the current build.\u003c/p\u003e\n\u003ch2 id=\"related\"\u003eRelated\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/a-colophon-blog-on-cloudflare-pages-and-r2/\"\u003eA Colophon Blog on Cloudflare Pages and R2\u003c/a\u003e: where this blog, and its MiniMax media generation, were first set up.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/tinct-minimax-input/\"\u003eA Palette from a Prompt\u003c/a\u003e: the same MiniMax provider, generating desktop wallpapers instead of speech.\u003c/li\u003e\n\u003c/ul\u003e\n",
      "date_published": "2026-06-25T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-elevenlabs-voices-and-pronunciation-dictio-338e7f78e745.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 11067628
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/wiring-the-blog-into-the-indieweb/",
      "url": "https://blog.i0.pm/posts/wiring-the-blog-into-the-indieweb/",
      "title": "Wiring the Blog into the IndieWeb",
      "summary": "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.",
      "content_html": "\u003cp\u003eHaving \u003ca href=\"/a-colophon-blog-on-cloudflare-pages-and-r2/\"\u003eput the blog on Cloudflare Pages and R2\u003c/a\u003e,\nthe next itch was to stop it being an island. A static site is a lovely thing to own, but on\nits own it just sits there: no \u0026quot;someone replied\u0026quot;, no way for anyone to follow it without an RSS\nreader they probably don't have. The \u003ca href=\"https://indieweb.org/\"\u003eIndieWeb\u003c/a\u003e answer to that is a\nhandful of small, open standards, and \u003ca href=\"https://github.com/jmylchreest/colophon\"\u003ecolophon\u003c/a\u003e speaks\nthem. This is what I turned on, roughly in order of how much faff each one is.\u003c/p\u003e\n\u003cp\u003eOne note on scope before we start: colophon does the whole loop now, sending these interactions,\ncollecting them, and rendering the replies under your posts. So this isn't plumbing for a someday\nfeature; by the end the conversation actually shows up on the page. The order of operations is what\nmatters, and a couple of the steps are easy to get subtly wrong.\u003c/p\u003e\n\u003ch2 id=\"websub-real-time-feeds-no-account\"\u003eWebSub: real-time feeds, no account\u003c/h2\u003e\n\u003cp\u003eThe cheapest win. A normal RSS/Atom feed is polled, so a reader finds your new post whenever it\nnext gets round to checking. \u003ca href=\"https://www.w3.org/TR/websub/\"\u003eWebSub\u003c/a\u003e flips that: you ping a hub\non publish, the hub pushes to every subscriber at once. Pure config, no signup, in \u003ccode\u003ecolophon.yaml\u003c/code\u003e\nunder your site:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  feeds: [rss, atom, json]\n  websub:\n    hubs:\n      - https://pubsubhubbub.appspot.com/   # Google\u0026#39;s public hub\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe ping only fires on a real public deploy, not a local build,\u003cnotts\u003e so check the publish log for\n\u003ccode\u003eWEBSUB … ping ok\u003c/code\u003e, and confirm there's a \u003ccode\u003erel=\u0026quot;hub\u0026quot;\u003c/code\u003e link in \u003ccode\u003erss.xml\u003c/code\u003e/\u003ccode\u003eatom.xml\u003c/code\u003e and a \u003ccode\u003ehubs\u003c/code\u003e\narray in \u003ccode\u003efeed.json\u003c/code\u003e.\u003c/notts\u003e A slow or down hub logs a failure and is otherwise ignored; it never breaks\nthe publish.\u003c/p\u003e\n\u003ch2 id=\"sending-webmentions-also-no-account\"\u003eSending webmentions: also no account\u003c/h2\u003e\n\u003cp\u003eA \u003ca href=\"https://www.w3.org/TR/webmention/\"\u003ewebmention\u003c/a\u003e is the open web's version of an @-mention: when\nI link to your post, my site POSTs yours a note saying \u0026quot;this URL mentions you\u0026quot;, and your site can\nfetch mine back to verify it. Sending needs nothing set up, but it has to run \u003cem\u003eafter\u003c/em\u003e a publish,\nbecause the source URLs have to be live for the receiver to verify them:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon webmention send --env production --dry-run   # report, don\u0026#39;t POST\ncolophon webmention send --env production             # actually send\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIt walks the built output, takes each page's canonical URL as the source, collects the cross-origin\noutbound links as targets, discovers each target's endpoint, and POSTs. It keeps a sent-cache\u003cnotts\u003e at\n\u003ccode\u003e.colophon/cache/webmention-sent.json\u003c/code\u003e\u003c/notts\u003e, so re-runs only notify \u003cem\u003enew\u003c/em\u003e links and re-ping ones you've\n\u003cem\u003eremoved\u003c/em\u003e (so the receiver re-checks and drops a mention you deleted). I bolted it onto the end of\nmy publish wrapper, so it fires on every deploy.\u003c/p\u003e\n\u003ch2 id=\"receiving-webmentionio\"\u003eReceiving: webmention.io\u003c/h2\u003e\n\u003cp\u003eTo collect replies, likes and reposts, point a receiver at \u003ca href=\"https://webmention.io\"\u003ewebmention.io\u003c/a\u003e,\na hosted endpoint that catches them for you:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  indieweb:\n    webmention:\n      receiver: https://webmention.io/blog.i0.pm/webmention\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eWith that set, colophon emits a \u003cnotts\u003e\u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e\u003c/notts\u003e discovery tag site-wide, which is how\nanyone (and Bridgy Fed, if you go that way) knows where to send mentions. The endpoint keys on\nyour domain, so the path stays \u003ccode\u003eblog.i0.pm\u003c/code\u003e regardless of which page is mentioned.\u003c/p\u003e\n\u003ch2 id=\"showing-the-responses\"\u003eShowing the responses\u003c/h2\u003e\n\u003cp\u003eCollecting them is only half of it; the point is to \u003cem\u003eshow\u003c/em\u003e the replies and likes under the post.\ncolophon does this two ways, and the choice is a proper trade-off:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  indieweb:\n    webmention:\n      receiver: https://webmention.io/blog.i0.pm/webmention\n      display: { mode: live }            # or: asset\n      token: \u0026#34;{env:WEBMENTION_IO_TOKEN}\u0026#34; # only used by asset mode\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003easset\u003c/code\u003e\u003c/strong\u003e self-hosts the data: a \u003ccode\u003ecolophon webmention fetch\u003c/code\u003e pulls your mentions back through\nthe API token, caches them, and bakes a small JSON file per post that ships with the site. The\nresponses are served from your own origin, work without hammering anyone else, and degrade\ngracefully. The cost is that they only refresh when you fetch and redeploy (a cron job, or a\ndecoupled \u003ccode\u003ewebmention publish\u003c/code\u003e that pushes just the mentions without rebuilding the site).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003elive\u003c/code\u003e\u003c/strong\u003e skips all of that: the reader's browser fetches the receiver directly on each page\nload, so a new reply shows up the next time anyone opens the post, no rebuild, no token, no\ncron. The cost is honesty about two things, it needs JavaScript (so the responses don't exist\nfor a no-JS reader), and it points your visitors' browsers at a third party.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThere's a third path you don't pick directly: a no-JS theme. The engine can \u003cstrong\u003ebake the responses\ninto the HTML at build time\u003c/strong\u003e from the same asset-mode data, so a theme that ships no JavaScript\n(the bundled \u003ccode\u003eminimal\u003c/code\u003e does exactly this) still renders replies and likes, server-side, no client\nscript. The JS themes use a placeholder the script fills; the no-JS ones get it pre-rendered. Same\ndata, the theme decides how it arrives.\u003c/p\u003e\n\u003cp\u003eI went with \u003cstrong\u003elive\u003c/strong\u003e. This is a personal blog, not a fortress; I'd rather a reply appear the\nmoment it lands than run a cron to refresh a cache, and the responses are an enhancement below the\narticle, not the article itself. I've left the \u003ccode\u003etoken:\u003c/code\u003e line in the config anyway,\u003cnotts\u003e pointing at\n\u003ccode\u003eWEBMENTION_IO_TOKEN\u003c/code\u003e (keyring \u003ccode\u003eCOLOPHON_WEBMENTION_IO_TOKEN\u003c/code\u003e, exported by the publish wrapper),\u003c/notts\u003e so\nflipping to \u003ccode\u003easset\u003c/code\u003e later is a one-word change rather than a dig through the docs.\u003c/p\u003e\n\u003ch2 id=\"posse-publish-here-syndicate-everywhere\"\u003ePOSSE: publish here, syndicate everywhere\u003c/h2\u003e\n\u003cp\u003eWebmentions handle the conversation; the other half of owning your content is\n\u003ca href=\"https://indieweb.org/POSSE\"\u003ePOSSE\u003c/a\u003e, Publish on your Own Site, Syndicate Elsewhere. The canonical\ncopy lives here, and a copy goes out to the silos where people actually are, each one linking back.\ncolophon has a \u003ccode\u003esyndicate\u003c/code\u003e step for it, deliberately separate from \u003ccode\u003epublish\u003c/code\u003e so the original is\nalways live \u003cem\u003efirst\u003c/em\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  syndication:\n    - id: bsky\n      driver: bluesky\n      handle: \u0026#34;{env:BLUESKY_HANDLE:-}\u0026#34;\n      app_password: \u0026#34;{env:BLUESKY_APP_PASSWORD}\u0026#34;   # an app password, not your login\n# and on the environment:\nenvironments:\n  - name: production\n    syndicate: [bsky]      # only listed targets fire; preview/draft envs omit it, so they never post\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThere are four drivers, and the choice comes down to who holds the keys: \u003cstrong\u003emastodon\u003c/strong\u003e and\n\u003cstrong\u003ebluesky\u003c/strong\u003e post directly to your account with a token (or a Bluesky app password) you control;\n\u003cstrong\u003ebridgy\u003c/strong\u003e posts on your behalf via \u003ca href=\"https://brid.gy\"\u003eBridgy\u003c/a\u003e so colophon holds no silo\ncredentials at all; and \u003cstrong\u003ecommand\u003c/strong\u003e runs any program you like for anything without a built-in\ndriver. I started with Bluesky, direct, my app password, one account to reason about.\u003c/p\u003e\n\u003cp\u003eThe flow mirrors the webmention one, run it \u003cem\u003eafter\u003c/em\u003e a publish so the canonical URL is live:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon syndicate --env production --dry-run        # preview; posts nothing, writes nothing\ncolophon syndicate --env production --allow-publish    # post the copies, record the ledger\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe ledger\u003cnotts\u003e at \u003ccode\u003e.colophon/syndication.json\u003c/code\u003e\u003c/notts\u003e (commit it) is the idempotency guard: it remembers what\nwent where, so re-running never double-posts, and it feeds the \u0026quot;Also posted on…\u0026quot; links back on the\npost. Per-post you can opt out with \u003cnotts\u003e\u003ccode\u003esyndicate: false\u003c/code\u003e\u003c/notts\u003e, narrow to a subset with \u003cnotts\u003e\u003ccode\u003esyndicate: [bsky]\u003c/code\u003e\u003c/notts\u003e,\nor write a custom blurb with \u003cnotts\u003e\u003ccode\u003esyndicate_text:\u003c/code\u003e\u003c/notts\u003e.\u003cnotts\u003e Secrets, as ever, only arrive via \u003ccode\u003e{env:VAR}\u003c/code\u003e.\u003c/notts\u003e\u003c/p\u003e\n\u003cp\u003eOne thing not to confuse: this is \u003cem\u003esyndication\u003c/em\u003e (push a copy out). It's a different job from making\nthe site itself followable from the fediverse, that's Bridgy \u003cstrong\u003eFed\u003c/strong\u003e, which hangs off the webmention\nreceiver, not the syndication list. Same neighbourhood, different houses.\u003c/p\u003e\n\u003ch2 id=\"signing-in-as-your-own-domain\"\u003eSigning in as your own domain\u003c/h2\u003e\n\u003cp\u003eOne detail worth getting right. webmention.io doesn't have its own password; you sign in \u003cem\u003eas your\nown website\u003c/em\u003e using \u003ca href=\"https://indieweb.org/IndieAuth\"\u003eIndieAuth\u003c/a\u003e, via\n\u003ca href=\"https://indielogin.com\"\u003eindielogin.com\u003c/a\u003e. For that to work, the URL you log in with and an identity\nprovider it trusts (GitHub is the easy one) have to link to \u003cstrong\u003eeach other\u003c/strong\u003e with \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e, at the\n\u003cem\u003esame\u003c/em\u003e URL on both ends.\u003c/p\u003e\n\u003cp\u003ecolophon emits the \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e link from your author's \u003ccode\u003eurls:\u003c/code\u003e, so set that in\n\u003cnotts\u003e\u003ccode\u003eauthors/\u0026lt;id\u0026gt;.yaml\u003c/code\u003e\u003c/notts\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# authors/jmylchreest.yaml\nurls:\n  - https://github.com/jmylchreest\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eGitHub does the other half for you: the \u003cstrong\u003ewebsite\u003c/strong\u003e field on your profile is published with\n\u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e automatically. So your site points at GitHub, GitHub points back at your site, the loop\ncloses, and indielogin.com is happy to authenticate you through GitHub.\u003c/p\u003e\n\u003cp\u003eThe catch is \u003cem\u003ewhich page\u003c/em\u003e carries the \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e. The link rides on colophon's author h-card, and\nthe loop only closes if the page you log in with is the same one GitHub links back to. Make sure\nyour GitHub website field and the URL you sign in with are the \u003cstrong\u003esame\u003c/strong\u003e, and that it's a page that\nactually renders the author card. On current colophon (v0.0.8) the author page does, so:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eGitHub profile website:\u003c/strong\u003e \u003cnotts\u003e\u003ccode\u003ehttps://blog.i0.pm/authors/jmylchreest/\u003c/code\u003e\u003c/notts\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSign in to webmention.io with:\u003c/strong\u003e the same \u003cnotts\u003e\u003ccode\u003ehttps://blog.i0.pm/authors/jmylchreest/\u003c/code\u003e\u003c/notts\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMatch those two and the login goes straight through. Mismatch them, even by a trailing path, and\nyou get \u0026quot;we couldn't find any way to authenticate you\u0026quot;, which just means the two pages didn't point\nat each other.\u003c/p\u003e\n\u003ch2 id=\"what-you-get\"\u003eWhat you get\u003c/h2\u003e\n\u003cp\u003eSo, the full loop: feeds that push instead of waiting to be polled, outgoing webmentions firing on\nevery publish, a receiver banking every reply and like against the domain, and those responses\nrendering live under each post, the moment someone opens it. If you've replied to this from your\nown site or boosted it on the fediverse (via Bridgy Fed), you should be looking at the evidence a\nlittle further down the page.\u003c/p\u003e\n\u003cp\u003eIt's a strange and rather nice feeling, wiring a static site you fully own into a conversation with\nthe rest of the web, without renting a single feature from anyone. More of the web should work like\nthis.\u003c/p\u003e\n\u003ch2 id=\"related\"\u003eRelated\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/posts/a-colophon-blog-on-cloudflare-pages-and-r2/\"\u003eA Colophon Blog on Cloudflare Pages and R2\u003c/a\u003e — the Pages + R2 setup this builds on.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/posts/rebuilt-the-blog-again/\"\u003eRebuilt the Blog. Again.\u003c/a\u003e — why the blog runs on colophon in the first place.\u003c/li\u003e\n\u003c/ul\u003e\n",
      "date_published": "2026-06-22T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-wiring-the-blog-into-the-indieweb-3157f8576d8a.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 16946290
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/a-colophon-blog-on-cloudflare-pages-and-r2/",
      "url": "https://blog.i0.pm/posts/a-colophon-blog-on-cloudflare-pages-and-r2/",
      "title": "A Colophon Blog on Cloudflare Pages and R2",
      "summary": "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.",
      "content_html": "\u003cp\u003eI \u003ca href=\"/rebuilt-the-blog-again/\"\u003erebuilt this blog on colophon\u003c/a\u003e a few days ago and said it\ndeploys with one command. This is the post that earns that claim: standing up a brand new\n\u003ca href=\"https://github.com/jmylchreest/colophon\"\u003ecolophon\u003c/a\u003e site on Cloudflare Pages, with the\nimages and search index living in R2 rather than shipping with the deployment. Nothing here\nis secret sauce, but the order of operations matters, and a couple of the steps are easy to\nget subtly wrong.\u003c/p\u003e\n\u003cp\u003eThe shape of it: \u003cstrong\u003ePages serves the HTML, R2 serves the heavy assets.\u003c/strong\u003e Pages has a file\ncount and size budget; a blog with a few hundred generated hero images will blow through it.\nSo colophon routes anything matching \u003ccode\u003e**/assets/**\u003c/code\u003e and the search index to an R2 bucket and\nrewrites the URLs to point there. The reader's browser fetches pages from one origin and\nimages from another, and neither you nor they have to think about it.\u003c/p\u003e\n\u003ch2 id=\"what-you-need-from-cloudflare\"\u003eWhat you need from Cloudflare\u003c/h2\u003e\n\u003cp\u003eMake a \u003ca href=\"https://dash.cloudflare.com/sign-up\"\u003eCloudflare\u003c/a\u003e account if you haven't, then collect\nthree things.\u003c/p\u003e\n\u003ch3 id=\"account-id\"\u003eAccount ID\u003c/h3\u003e\n\u003cp\u003eIt's in the dashboard URL once you're logged in\n(\u003ccode\u003edash.cloudflare.com/\u0026lt;account-id\u0026gt;/...\u003c/code\u003e), and on the right-hand sidebar of any zone's overview\npage. This is not a secret, but colophon needs it for both the Pages and R2 publishers.\u003c/p\u003e\n\u003ch3 id=\"api-token\"\u003eAPI Token\u003c/h3\u003e\n\u003cp\u003eProfile → API Tokens → \u003cem\u003eCreate Token\u003c/em\u003e → \u003cem\u003eCreate Custom Token\u003c/em\u003e. The\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md#secrets-and-permissions\"\u003epublishing docs\u003c/a\u003e\nlist the exact permissions, and they are tighter than the \u0026quot;Edit Cloudflare Workers\u0026quot; template\nyou'll be tempted to grab. For one token that does both jobs:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eAccount → Cloudflare Pages → Edit\u003c/strong\u003e: deploys the site.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAccount → Workers R2 Storage → Edit\u003c/strong\u003e: reads and writes objects, and creates the bucket.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThat second one is the gotcha. The S3-style \u003ccode\u003ecolophon publish --create\u003c/code\u003e step also flips the\nbucket to public and sets a CORS policy, and that needs R2 \u003cstrong\u003eAdmin\u003c/strong\u003e read and write, not just\nobject read and write. If \u003ccode\u003e--create\u003c/code\u003e later complains it \u0026quot;needs R2 Admin Read \u0026amp; Write\u0026quot;, this is\nwhy. You can use a narrower object-only token for day-to-day publishing once the bucket exists.\u003c/p\u003e\n\u003ch3 id=\"an-r2-access-key-pair\"\u003eAn R2 access key pair\u003c/h3\u003e\n\u003cp\u003eR2 → \u003cem\u003eManage R2 API Tokens\u003c/em\u003e → create one. You get an\n\u003ccode\u003eR2_ACCESS_KEY_ID\u003c/code\u003e and an \u003ccode\u003eR2_SECRET_ACCESS_KEY\u003c/code\u003e; R2 speaks S3, so these are the S3 credentials\nthe publisher uses, separate from the Cloudflare API token above.\u003c/p\u003e\n\u003ch3 id=\"dns-and-a-custom-domain\"\u003eDNS and a custom domain\u003c/h3\u003e\n\u003cp\u003eTwo domains, ideally: one for the site, one for the assets. I serve the blog at \u003ccode\u003eblog.i0.pm\u003c/code\u003e\nand assets at \u003ccode\u003eassets.i0.pm\u003c/code\u003e.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSite:\u003c/strong\u003e in the Pages project, \u003cem\u003eCustom domains\u003c/em\u003e → add \u003ccode\u003eblog.example.com\u003c/code\u003e. If the zone is on\nCloudflare, the CNAME is created for you; if not, add a CNAME to \u003ccode\u003e\u0026lt;project\u0026gt;.pages.dev\u003c/code\u003e and let\nit verify.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAssets:\u003c/strong\u003e in the R2 bucket, \u003cem\u003eSettings\u003c/em\u003e → \u003cem\u003ePublic access\u003c/em\u003e → \u003cem\u003eCustom domains\u003c/em\u003e → add\n\u003ccode\u003eassets.example.com\u003c/code\u003e. That public hostname is what you'll hand colophon as the R2 \u003ccode\u003epublic_url\u003c/code\u003e,\nand until it resolves, the asset routing stays dormant and builds keep assets co-located. Handy\nfor local work.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"scaffold-the-project\"\u003eScaffold the project\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003einit\u003c/code\u003e writes the project; \u003ccode\u003epublish --create\u003c/code\u003e provisions the destinations later. Don't conflate\nthe two.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon init blog \u0026amp;\u0026amp; cd blog\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat gives you a \u003ccode\u003ecolophon.yaml\u003c/code\u003e, a \u003ccode\u003econtent/\u003c/code\u003e directory (which is also an Obsidian vault, if\nyou want it), an author, and a persona. Open \u003ccode\u003ecolophon.yaml\u003c/code\u003e and wire up the two publishers and\na production environment. The key point, and the thing the\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md#configuration-and-interpolation\"\u003epublishing docs\u003c/a\u003e\nlean on hard: \u003cstrong\u003eno secret ever goes in this file.\u003c/strong\u003e Non-secret settings use \u003ccode\u003e{env:VAR}\u003c/code\u003e\ninterpolation; credentials are read straight from the environment and never touch the YAML.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    title: \u0026#34;Your blog\u0026#34;\n    base_url: \u0026#34;{env:SITE_URL:-http://localhost:8080}\u0026#34;\n    routing:\n      - match: \u0026#34;**/assets/**\u0026#34;   # heavy assets → R2, not the Pages bundle\n        publisher: r2\n      - match: \u0026#34;_search/**\u0026#34;     # search index fetched cross-origin → R2 (needs CORS)\n        publisher: r2\n\npublishers:\n  - id: cf\n    driver: cloudflare-pages\n    project: \u0026#34;{env:CF_PAGES_PROJECT:-my-blog}\u0026#34;\n    account_id: \u0026#34;{env:CLOUDFLARE_ACCOUNT_ID}\u0026#34;\n  - id: r2\n    driver: cloudflare-r2\n    bucket: \u0026#34;{env:R2_BUCKET:-my-blog-assets}\u0026#34;\n    account_id: \u0026#34;{env:CLOUDFLARE_ACCOUNT_ID}\u0026#34;\n    public_url: \u0026#34;{env:R2_PUBLIC_URL:-}\u0026#34;   # https://assets.example.com; empty keeps routing inert\n\nenvironments:\n  - name: production\n    publish: [cf, r2]\n    allow_publish: false        # safety latch: deploy needs --allow-publish\n    base_url: \u0026#34;https://blog.example.com\u0026#34;\n  - name: preview\n    publish: [cf, r2]\n    include_drafts: true        # drafts visible here, never in production\n    overrides:\n      cf:\n        branch: preview         # its own Pages branch / preview URL\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003ecolophon env\u003c/code\u003e lists every \u003ccode\u003e{env:VAR}\u003c/code\u003e the project references, set or not, which is the quickest\nway to see what you still owe it.\u003c/p\u003e\n\u003ch2 id=\"sites-publishers-environments-why-three-nouns\"\u003eSites, publishers, environments: why three nouns\u003c/h2\u003e\n\u003cp\u003ecolophon deliberately splits the job three ways, and it's worth understanding because it's what\nmakes the rest painless. A \u003cstrong\u003esite\u003c/strong\u003e is the content and its identity (title, theme, what gets\nwritten). A \u003cstrong\u003epublisher\u003c/strong\u003e is pure \u003cem\u003emechanism\u003c/em\u003e: how to ship bytes somewhere (to Pages, to R2, to a\nlocal folder), with no opinion about when or why. An \u003cstrong\u003eenvironment\u003c/strong\u003e is the \u003cem\u003epolicy\u003c/em\u003e that ties\nthem together: a named build-and-deploy profile that says which publishers to use, whether to\ninclude drafts, and any overrides. The\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md\"\u003epublishing docs\u003c/a\u003e put it as\npublishers being \u003cem\u003ehow\u003c/em\u003e and environments being \u003cem\u003ewhat and where\u003c/em\u003e.\u003c/p\u003e\n\u003cp\u003eThat separation is why I run \u003cstrong\u003etwo environments off the one site\u003c/strong\u003e: \u003ccode\u003eproduction\u003c/code\u003e and \u003ccode\u003epreview\u003c/code\u003e.\nThey share the same content, the same publishers, the same R2 bucket; they differ only in policy.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eproduction\u003c/code\u003e\u003c/strong\u003e ships only finished posts (\u003ccode\u003edraft: false\u003c/code\u003e), to \u003ccode\u003eblog.example.com\u003c/code\u003e, on the\n\u003ccode\u003emain\u003c/code\u003e Pages branch, and it's behind the \u003ccode\u003eallow_publish: false\u003c/code\u003e safety latch so a deploy is\nnever accidental.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003epreview\u003c/code\u003e\u003c/strong\u003e sets \u003ccode\u003einclude_drafts: true\u003c/code\u003e and overrides the Pages branch to \u003ccode\u003epreview\u003c/code\u003e, so it\nbuilds the work-in-progress and Cloudflare serves it at its own preview URL. I can read a draft\non my phone, exactly as it'll render, before anyone else sees it.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eI publish both because they answer different questions. Preview is \u0026quot;does this actually look right,\nlive, with the real images and search index?\u0026quot; Production is \u0026quot;the world can read this now.\u0026quot;\nSplitting them means a draft can be deployed somewhere real and private without ever risking the\npublic site, and promoting it is just flipping \u003ccode\u003edraft: false\u003c/code\u003e and publishing production. Same\ncontent, two policies, one command each:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon publish --env preview --allow-publish      # drafts, preview branch\ncolophon publish --env production --allow-publish    # finished posts only\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eNothing about either environment is special or built in; you could add a \u003ccode\u003estaging\u003c/code\u003e, a\nsecond-site mirror, or a local \u003ccode\u003edist\u003c/code\u003e target for diffing output. Two is just the smallest set\nthat gives me \u0026quot;private dress rehearsal\u0026quot; and \u0026quot;live\u0026quot; without duplicating a scrap of content.\u003c/p\u003e\n\u003ch2 id=\"where-the-secrets-live\"\u003eWhere the secrets live\u003c/h2\u003e\n\u003cp\u003eThe interpolated \u003ccode\u003e{env:VAR}\u003c/code\u003e values split cleanly into two piles:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eNot secret\u003c/strong\u003e (\u003ccode\u003eCLOUDFLARE_ACCOUNT_ID\u003c/code\u003e, \u003ccode\u003eCF_PAGES_PROJECT\u003c/code\u003e, \u003ccode\u003eR2_BUCKET\u003c/code\u003e, \u003ccode\u003eR2_PUBLIC_URL\u003c/code\u003e,\n\u003ccode\u003eSITE_URL\u003c/code\u003e): commit these to a \u003ccode\u003e.env.defaults\u003c/code\u003e in the repo. colophon loads it automatically.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSecret\u003c/strong\u003e (\u003ccode\u003eCLOUDFLARE_API_TOKEN\u003c/code\u003e, \u003ccode\u003eR2_ACCESS_KEY_ID\u003c/code\u003e, \u003ccode\u003eR2_SECRET_ACCESS_KEY\u003c/code\u003e,\n\u003ccode\u003eMINIMAX_API_KEY\u003c/code\u003e): never in the repo, never in config. These come from the environment at\npublish time.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eHow they reach the environment depends on where you are:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eLocally:\u003c/strong\u003e out of the Secret Service. I keep mine in \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e\n(the keyring replacement I wrote about in \u003ca href=\"/posts/rosec/\"\u003eSecrets, Minus the Keyring\u003c/a\u003e),\nnamespaced \u003ccode\u003eCOLOPHON_*\u003c/code\u003e, and pull them with \u003ccode\u003esecret-tool\u003c/code\u003e at publish time so the tokens never sit\nin a plaintext \u003ccode\u003e.env\u003c/code\u003e or in my shell history. A small wrapper resolves them to the bare names\ncolophon wants, runs \u003ccode\u003ecolophon doctor\u003c/code\u003e as a preflight, then publishes:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003e# store once: secret-tool store --label=\u0026#39;colophon: …\u0026#39; service colophon key COLOPHON_\u0026lt;NAME\u0026gt;\nfor name in CLOUDFLARE_API_TOKEN R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY MINIMAX_API_KEY; do\n  export \u0026#34;$name=$(secret-tool lookup service colophon key \u0026#34;COLOPHON_$name\u0026#34;)\u0026#34;\ndone\ncolophon doctor \u0026amp;\u0026amp; colophon publish --env production --allow-publish\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eIn CI:\u003c/strong\u003e as encrypted \u003ca href=\"https://docs.github.com/actions/security-guides/encrypted-secrets\"\u003eGitHub Actions\u003c/a\u003e\nsecrets, exposed to the job as env vars. colophon scaffolds a workflow that reads\n\u003ccode\u003eCLOUDFLARE_API_TOKEN\u003c/code\u003e, \u003ccode\u003eCLOUDFLARE_ACCOUNT_ID\u003c/code\u003e, \u003ccode\u003eR2_ACCESS_KEY_ID\u003c/code\u003e and \u003ccode\u003eR2_SECRET_ACCESS_KEY\u003c/code\u003e\nstraight from \u003ccode\u003esecrets.*\u003c/code\u003e. Same variable names, different source.\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe whole design is that the names are constant and only the \u003cem\u003esource\u003c/em\u003e changes, so the same\n\u003ccode\u003ecolophon publish\u003c/code\u003e runs unmodified on your laptop and in Actions.\u003c/p\u003e\n\u003ch2 id=\"provision-then-publish\"\u003eProvision, then publish\u003c/h2\u003e\n\u003cp\u003eFirst time only, let colophon create the Pages project and the R2 bucket (idempotent, so it's\nsafe to leave in):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon publish --env production --create --allow-publish\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003e--create\u003c/code\u003e makes the Pages project, creates the bucket, and sets the bucket's CORS policy so the\ncross-origin \u003ccode\u003efetch()\u003c/code\u003e of the search index works. A cross-origin \u003ccode\u003e\u0026lt;img\u0026gt;\u003c/code\u003e needs no CORS; a\n\u003ccode\u003efetch()\u003c/code\u003e or an ES \u003ccode\u003eimport()\u003c/code\u003e does, which is why the search index specifically needs it. After\nthat, the daily incantation is just:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon publish --env production --allow-publish\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"generative-images-and-a-spoken-reading\"\u003eGenerative images and a spoken reading\u003c/h2\u003e\n\u003cp\u003eThis is the part I enjoy. colophon can generate hero images from a text prompt and a spoken\nreading of each post. The provider is your choice: image generation can go through Google\nGenAI or \u003ca href=\"https://openrouter.ai/\"\u003eOpenRouter\u003c/a\u003e just as happily, and you're not obliged to use\nany of it. I picked \u003ca href=\"https://platform.minimax.io/\"\u003eMiniMax\u003c/a\u003e (the same provider\n\u003ca href=\"/a-palette-from-a-prompt/\"\u003etinct uses for palettes\u003c/a\u003e) because it's cheap and effective, and one\nkey, \u003ccode\u003eMINIMAX_API_KEY\u003c/code\u003e, drives both the images and the speech. Swap the \u003ccode\u003eprovider:\u003c/code\u003e lines below\nif you'd rather use something else.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  image:\n    provider: minimax          # image-01; api_key falls back to MINIMAX_API_KEY\n    system_prompt: \u0026#34;editorial tech illustration, dark muted palette, full-bleed, no text\u0026#34;\n    defaults: { aspect: \u0026#34;16:9\u0026#34; }\n  speech:\n    provider: minimax          # speech-2.6-hd; same key\n    enabled: true              # every post gets a reading unless it sets audio: false\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe clever bit, documented in\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/image-generation.md\"\u003eimage-generation.md\u003c/a\u003e,\nis that \u003cstrong\u003ethe repo is the cache.\u003c/strong\u003e Any \u003ccode\u003ehero:\u003c/code\u003e or inline image whose value starts with \u003ccode\u003egen:\u003c/code\u003e is\ngenerated from the prompt, content-addressed, and the result is committed to the repo. A normal\n\u003ccode\u003ecolophon build\u003c/code\u003e ships whatever's cached and never calls the API; you only spend tokens when you\nexplicitly ask:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon build --generate-ai      # or: colophon publish ... --generate-ai\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSo a hero is just a sentence in the frontmatter:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003ehero: \u0026#34;gen:a worn mechanical keyboard lit by a single monitor in a dark room\u0026#34;\nhero_alt: A worn mechanical keyboard lit by a single monitor in a dark room\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eChange the \u003ccode\u003esystem_prompt\u003c/code\u003e and you change the cache identity, so the next \u003ccode\u003e--generate-ai\u003c/code\u003e\nregenerates everything. Useful to know before you tweak it at midnight and wonder why your whole\nback catalogue is redrawing.\u003c/p\u003e\n\u003cp\u003eThe reading uses one of the provider's stock voices by default, but you don't have to settle for\na stranger reading your words. MiniMax (and the others) let you \u003cstrong\u003eclone a voice\u003c/strong\u003e from a short\nsample of your own, which gives you a voice profile ID you can point colophon at. Set it once on\nthe author or persona as \u003ccode\u003evoice:\u003c/code\u003e, or per post with \u003ccode\u003eaudio_voice:\u003c/code\u003e, and every reading goes out in\nyour own voice rather than a generic one:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eaudio_voice: \u0026#34;my-cloned-voice-id\u0026#34;   # frontmatter: this post in your own voice\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat's on my list: the default here is a placeholder until my own clone is trained.\u003c/p\u003e\n\u003ch2 id=\"the-frontmatter-that-actually-matters\"\u003eThe frontmatter that actually matters\u003c/h2\u003e\n\u003cp\u003eYou don't write most of this by hand, and that's the point. \u003ccode\u003ecolophon new post \u0026quot;Title\u0026quot;\u003c/code\u003e picks a\nunique pinned \u003ccode\u003eslug:\u003c/code\u003e, stamps the \u003ccode\u003edate:\u003c/code\u003e, sets the byline \u003ccode\u003eauthor:\u003c/code\u003e and the writing \u003ccode\u003epersona:\u003c/code\u003e,\nand leaves the post \u003ccode\u003edraft: true\u003c/code\u003e. The fields worth understanding, all detailed in\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/content.md\"\u003econtent.md\u003c/a\u003e and\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/seo.md\"\u003eseo.md\u003c/a\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eslug:\u003c/code\u003e\u003c/strong\u003e: pinned at creation so the URL never drifts when you retitle. Leave it alone.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003edraft:\u003c/code\u003e\u003c/strong\u003e: \u003ccode\u003etrue\u003c/code\u003e keeps it out of production but visible in the \u003ccode\u003epreview\u003c/code\u003e environment.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003edescription:\u003c/code\u003e\u003c/strong\u003e: the one-line summary used in listings and as an SEO fallback.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ehero:\u003c/code\u003e / \u003ccode\u003ehero_alt:\u003c/code\u003e\u003c/strong\u003e: the banner image (a path, an \u003ccode\u003e![[embed]]\u003c/code\u003e, or a \u003ccode\u003egen:\u003c/code\u003e prompt) and\nits alt text. Always write real alt text for a meaningful image; an empty \u003ccode\u003ehero_alt:\u003c/code\u003e marks a\npurely decorative one.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eseo:\u003c/code\u003e\u003c/strong\u003e: an optional block for a distinct page title, keywords, and social-card copy when\nthe defaults aren't enough.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eaudio: false\u003c/code\u003e\u003c/strong\u003e: opt a single post out of the spoken reading.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"writing-it-with-an-llm-in-your-own-voice\"\u003eWriting it with an LLM, in your own voice\u003c/h2\u003e\n\u003cp\u003eIf you're writing with an LLM or an agent harness, this is where it gets genuinely pleasant, and\nit's the part I'm most quietly pleased with. colophon ships a set of\n\u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/skills.md\"\u003eagent skills\u003c/a\u003e, the most\nimportant being \u003cem\u003ewrite\u003c/em\u003e. There are companions for metadata, cross-linking and publishing too, but\n\u003cem\u003ewrite\u003c/em\u003e is the one that matters, because it solves the thing that makes LLM-written blogs so\nobviously LLM-written: they all sound the same.\u003c/p\u003e\n\u003cp\u003eThe trick is that the model never starts from a blank prompt. Before it writes a word, the skill\nruns:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon persona context default --topic \u0026#34;what the post is about\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat does two things. It hands over the persona's \u003cstrong\u003estyle guide\u003c/strong\u003e (the explicit rules: British\nspelling, no em dashes, lead with the point, the tone), and then it reaches into the\n\u003cstrong\u003ecorpus of your past posts\u003c/strong\u003e and pulls back the handful most relevant to this topic, ranked with\nBM25. So a post about distributed systems gets fed your \u003cem\u003eactual\u003c/em\u003e previous writing on distributed\nsystems; a hardware teardown gets your teardowns. The model isn't told \u0026quot;write like a thoughtful\nBritish engineer\u0026quot; in the abstract, it's shown concrete examples of how \u003cem\u003eyou\u003c/em\u003e actually write,\nabout \u003cem\u003ethis kind of thing\u003c/em\u003e, and asked to continue in that vein. The voice it produces is a\nreflection of your own corpus, not a generic pastiche of one, and it sharpens as the corpus\ngrows: every post you publish becomes an exemplar the next one can learn from.\u003c/p\u003e\n\u003cp\u003eWith the voice loaded, \u003ccode\u003ecolophon new post\u003c/code\u003e scaffolds the file with the frontmatter contract\nalready correct, and the model fills the body and the prose-level fields (description, alt text,\ntags), told not to touch the pinned slug or invent links. The harness handles the bookkeeping;\nyou (or the model in your voice) handle the words. This very post was drafted that way.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon new post \u0026#34;Raft Leader Election\u0026#34; --author you --persona default --tag distributed-systems\ncolophon serve --open=latest    # preview before you flip draft: false\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eFlip \u003ccode\u003edraft: false\u003c/code\u003e, run the publish command, and it's live. One command, as promised. The\ninfrastructure is your own fault now, which is rather the appeal.\u003c/p\u003e\n",
      "date_published": "2026-06-21T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-a-colophon-blog-on-cloudflare-pages-and-r2-1792f6f1c9cd.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 28427472
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/tinct-minimax-input/",
      "url": "https://blog.i0.pm/posts/tinct-minimax-input/",
      "title": "A Palette from a Prompt",
      "summary": "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.",
      "content_html": "\u003cp\u003eA 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 \u003ca href=\"https://openrouter.ai/\"\u003eOpenRouter\u003c/a\u003e already. v0.4.0 adds a third, \u003cstrong\u003eminimax\u003c/strong\u003e, which generates an image from a prompt through \u003ca href=\"https://platform.minimax.io/\"\u003eMiniMax\u003c/a\u003e's Hailuo model (\u003ccode\u003eimage-01\u003c/code\u003e) 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.\u003c/p\u003e\n\u003cp\u003eThe nice part is that it's one step, not two. The same \u003ccode\u003etinct generate\u003c/code\u003e 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 \u003cem\u003eand\u003c/em\u003e the wallpaper that matches it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eexport MINIMAX_API_KEY=\u0026#34;…\u0026#34;\ntinct generate -i minimax \\\n  --ai.prompt \u0026#34;cyberpunk city street with neon signs at night\u0026#34; \\\n  -o hyprland,hyprpaper,kitty\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eGenerated images are cached under \u003ccode\u003e~/.cache/tinct/generated/minimax/\u003c/code\u003e, 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.\u003c/p\u003e\n\u003cp\u003eIt'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 \u003ccode\u003etinct-bin\u003c/code\u003e; the full input-plugin list is on the \u003ca href=\"https://jmylchreest.github.io/tinct/\"\u003etinct site\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-06-20T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-minimax-input-5741923b96b0.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 3207604
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/aide-blueprints/",
      "url": "https://blog.i0.pm/posts/aide-blueprints/",
      "title": "Best Practices, Bootstrapped",
      "summary": "aide blueprints seed a new project's decision store with curated, composable best-practice decisions, auto-detected from project markers and tracked by version.",
      "content_html": "\u003cp\u003eA fresh project starts with an empty head. \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eaide\u003c/a\u003e 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're back to either re-litigating the same conventions you settle on in every repo, or letting the agent quietly invent its own.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eBlueprints\u003c/strong\u003e are the fix: curated bundles of best-practice decisions you seed a project with in one command.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eaide blueprint import go\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat drops a set of idiomatic-Go decisions straight into the project's store: error handling, context use, table-driven tests, \u003ccode\u003eslog\u003c/code\u003e, \u003ccode\u003egolangci-lint\u003c/code\u003e, and so on. From that point they behave exactly like decisions you recorded by hand. They're injected into every session's context and enforced by all agents, so the conventions are present before the first line is written rather than discovered halfway through review.\u003c/p\u003e\n\u003ch2 id=\"composable-not-monolithic\"\u003eComposable, not monolithic\u003c/h2\u003e\n\u003cp\u003eThe thing I like most is that blueprints stack. A blueprint can \u003ccode\u003einclude\u003c/code\u003e others, so they compose along the grain of how projects are actually built:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003e# go, plus the CI/CD practices, plus the universals they both build on\naide blueprint import go-github-actions\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003ego\u003c/code\u003e includes \u003ccode\u003egeneral\u003c/code\u003e (commits, PRs, dependency hygiene, secrets), \u003ccode\u003egeneral\u003c/code\u003e includes the documentation core, and \u003ccode\u003ego-github-actions\u003c/code\u003e 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.\u003c/p\u003e\n\u003ch2 id=\"it-detects-what-youre-building\"\u003eIt detects what you're building\u003c/h2\u003e\n\u003cp\u003eYou don't even have to name them. \u003ccode\u003e--detect\u003c/code\u003e reads the project's markers and imports what fits:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003e$ aide blueprint import --detect\nDetected: go, github-actions, go-github-actions\n\n  general            5 new\n  go                18 new\n  github-actions     7 new\n  go-github-actions  5 new\n\n35 imported, 0 updated\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eA \u003ccode\u003ego.mod\u003c/code\u003e triggers the \u003ccode\u003ego\u003c/code\u003e pack; a \u003ccode\u003e.github/workflows/\u003c/code\u003e directory triggers the \u003ccode\u003egithub-actions\u003c/code\u003e label; the two together resolve the compound \u003ccode\u003ego-github-actions\u003c/code\u003e blueprint. It runs on the same project-marker index the grammar system uses, so a custom marker in your own \u003ccode\u003e.aide/grammars/\u003c/code\u003e can trigger an org-specific blueprint automatically. Bootstrapping the house style across a fleet of repos becomes one command per repo.\u003c/p\u003e\n\u003ch2 id=\"versioned-and-it-knows-what-it-set\"\u003eVersioned, and it knows what it set\u003c/h2\u003e\n\u003cp\u003eSeeding 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 \u003cem\u003eyou\u003c/em\u003e set by hand is left alone. A decision a blueprint set earlier is upgraded only if the blueprint's version is newer and the content actually changed. Every imported decision is stamped \u003ccode\u003edecided_by: blueprint:\u0026lt;name\u0026gt;@\u0026lt;version\u0026gt;\u003c/code\u003e, so you can always tell the curated baseline from the calls you made yourself.\u003c/p\u003e\n\u003cp\u003eThat provenance is what makes the whole thing safe to re-run. \u003ccode\u003eaide blueprint import --detect\u003c/code\u003e 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.\u003c/p\u003e\n\u003ch2 id=\"roll-your-own-share-them-across-the-team\"\u003eRoll your own, share them across the team\u003c/h2\u003e\n\u003cp\u003eThe 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's conventions, the house style, the \u0026quot;we always do it \u003cem\u003ethis\u003c/em\u003e way, and here's the reasoning\u0026quot; rules that currently live in a wiki page nobody reads twice. Drop one in \u003ccode\u003e.aide/blueprints/\u003c/code\u003e to override a shipped blueprint locally, or publish a whole set for everyone.\u003c/p\u003e\n\u003cp\u003eSharing is deliberately boring. A registry is just a base URL that serves \u003ccode\u003e\u0026lt;name\u0026gt;.json\u003c/code\u003e files, so any static host works: an internal web server, an S3 bucket, a GitLab repo on the intranet. Point a project's config at it once,\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-json\"\u003e// .aide/config/aide.json\n{ \u0026#34;blueprints\u0026#34;: { \u0026#34;registries\u0026#34;: [\u0026#34;https://blueprints.corp.internal\u0026#34;] } }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eand \u003ccode\u003eaide blueprint import acme-standards\u003c/code\u003e pulls your org's rules exactly the way \u003ccode\u003ego\u003c/code\u003e pulls the built-in ones. (There's a \u003ccode\u003e--registry=\u003c/code\u003e flag for one-offs, and you can import a bare URL too.)\u003c/p\u003e\n\u003cp\u003eThe point, for a team, is that \u0026quot;the way we do things here\u0026quot; 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 \u003cem\u003eand\u003c/em\u003e every agent on the project works from the same rules without anyone having to remember to bring them up.\u003c/p\u003e\n\u003ch2 id=\"why-bother\"\u003eWhy bother\u003c/h2\u003e\n\u003cp\u003eThe honest value isn't the import command, it's the curation. Writing down \u0026quot;use \u003ccode\u003eerrors.Is\u003c/code\u003e, not string matching\u0026quot; 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 \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-06-18T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-blueprints-596270995887.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 9922980
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rebuilt-the-blog-again/",
      "url": "https://blog.i0.pm/posts/rebuilt-the-blog-again/",
      "title": "Rebuilt the Blog. Again.",
      "summary": "Moving off Hugo and onto colophon, and why the old site never had anything on it anyway.",
      "content_html": "\u003cp\u003eThe 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 \u0026quot;Lorem ipsum dolor sit amet\u0026quot; and an about page extolling Bootstrap 5. A monument to good intentions, lovingly version-controlled and never once written in.\u003c/p\u003e\n\u003cp\u003eSo this is less a migration than a fresh start. There was nothing to port, just demo content and a title I'm rather fond of.\u003c/p\u003e\n\u003cp\u003eThis time it runs on \u003ca href=\"https://github.com/jmylchreest/colophon\"\u003ecolophon\u003c/a\u003e, a static-site generator I'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's no JavaScript required to read anything, and the whole thing deploys with one command.\u003c/p\u003e\n\u003cp\u003eWhether \u003cem\u003ethis\u003c/em\u003e version accumulates more than one post remains to be seen. But the infrastructure is, at least, my own fault now.\u003c/p\u003e\n",
      "date_published": "2026-06-18T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rebuilt-the-blog-again-55de8f6ad78d.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 1847984
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/aide-instinct/",
      "url": "https://blog.i0.pm/posts/aide-instinct/",
      "title": "Learning From the Session",
      "summary": "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.",
      "content_html": "\u003cp\u003eaide'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 \u003cem\u003edidn't\u003c/em\u003e think to write down. That's what the \u003cstrong\u003einstinct\u003c/strong\u003e system is for, and it leans on a quieter piece of plumbing that landed first: observability.\u003c/p\u003e\n\u003cp\u003eaide 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've made three times, a tool that keeps failing the same way (friction is a strong signal that something'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.\u003c/p\u003e\n\u003cp\u003eThe mechanism is a Stop hook called \u003cstrong\u003ereflect\u003c/strong\u003e: at the end of a session it runs the instinct parser over that session's observe events and surfaces the proposals. It's deliberately opt-in and still experimental, so it's off by default; you turn it on with \u003ccode\u003eAIDE_REFLECT=1\u003c/code\u003e or \u003ccode\u003e\u0026quot;reflect\u0026quot;: { \u0026quot;enabled\u0026quot;: true }\u003c/code\u003e in \u003ccode\u003e.aide/config/aide.json\u003c/code\u003e. 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.\u003c/p\u003e\n\u003cp\u003eIt's the part of aide I'm least sure how far to push, which is exactly why it's behind a flag. Memory you write is predictable; memory the tool proposes from watching you is powerful and occasionally presumptuous, and I'd rather earn that trust than assume it. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-06-15T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-instinct-d886c006b00d.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 3761316
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/razer-naga-duplicate-buttons/",
      "url": "https://blog.i0.pm/posts/razer-naga-duplicate-buttons/",
      "title": "Two Razer Naga Buttons, One Keycode",
      "summary": "input-remapper couldn't tell two of my mouse's side buttons apart. No Linux tool can; the fix is a firmware mode toggle, not a remapper.",
      "content_html": "\u003cp\u003eI 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 \u003ca href=\"https://github.com/sezanzeb/input-remapper\"\u003einput-remapper\u003c/a\u003e, expecting a five-minute job.\u003c/p\u003e\n\u003cp\u003eIt went wrong immediately. Button 11 did exactly what button 6 did; button 12 did exactly what button 2 did. Not \u0026quot;similar\u0026quot;: \u003ccode\u003eevtest\u003c/code\u003e showed them emitting \u003cstrong\u003ebyte-for-byte identical\u003c/strong\u003e scancodes (\u003ccode\u003e7002d\u003c/code\u003e → \u003ccode\u003eKEY_MINUS\u003c/code\u003e for both 6 and 11). input-remapper saw them as the same key, so there was nothing to remap.\u003c/p\u003e\n\u003cp\u003eThe instinct is to go looking for a better remapper. That instinct is wrong, and it's worth seeing why. \u003ccode\u003eMSC_SCAN\u003c/code\u003e 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's no hidden serial number. evdev hands userspace \u003ccode\u003e(type, code, value)\u003c/code\u003e plus that scancode hint, and that's the \u003cem\u003eentire\u003c/em\u003e vocabulary. If two events are identical across all of it, \u003cstrong\u003eany\u003c/strong\u003e userspace remapper is downstream of the collision. You can't branch on information that doesn't exist.\u003c/p\u003e\n\u003cp\u003eSo the duplicate isn't made in software; it'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 \u003ccode\u003eShift+3\u003c/code\u003e, and two pairs of buttons collided onto one key). Normally you'd rewrite those slots with Razer Synapse on Windows. Nothing on Linux can: OpenRazer doesn't do button remapping, libratbag refuses Razer over the EULA, and the web tools don't cover this model.\u003c/p\u003e\n\u003ch2 id=\"the-fix-driver-mode\"\u003eThe fix: driver mode\u003c/h2\u003e\n\u003cp\u003eRazer mice expose a \u003ccode\u003edevice_mode\u003c/code\u003e switch through the OpenRazer kernel driver. Normal mode (\u003ccode\u003e0x00\u003c/code\u003e) replays the onboard profile, my broken one. \u003cstrong\u003eDriver mode\u003c/strong\u003e (\u003ccode\u003e0x03\u003c/code\u003e) makes the firmware ignore the profile and emit its raw factory layout: twelve unique keys. One write does it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eecho -n -e \u0026#39;\\x03\\x00\u0026#39; | sudo tee /sys/bus/hid/devices/*1532*00B4*/device_mode\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eAnd \u003ccode\u003eevtest\u003c/code\u003e immediately goes clean: button 6 is \u003ccode\u003eKEY_6\u003c/code\u003e, button 11 is \u003ccode\u003eKEY_MINUS\u003c/code\u003e. The collision is gone because the firmware stopped lying.\u003c/p\u003e\n\u003cp\u003eIt 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 \u003cem\u003eand\u003c/em\u003e resume, by setting \u003ccode\u003edriver_mode = true\u003c/code\u003e per device in \u003ccode\u003e~/.config/openrazer/razer.conf\u003c/code\u003e. (One trap: the section is keyed by serial, and this unit's serial read is flaky, so I declared both the real serial and the \u003ccode\u003eUNKNOWN_…\u003c/code\u003e fallback.) A udev rule on \u003ccode\u003ebind\u003c/code\u003e works too, but won't survive suspend.\u003c/p\u003e\n\u003cp\u003eThat 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 \u0026quot;fix my mouse\u0026quot; bind. No \u003ccode\u003esudo\u003c/code\u003e this time: I'm in the \u003ccode\u003eopenrazer\u003c/code\u003e group, so the udev-owned sysfs is writable directly, and a tiny script forces driver mode then re-applies my DPI:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-ini\"\u003ebindd = $mainMod, Pause, Naga driver mode, exec, ~/.local/bin/naga-driver-mode\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003e#!/bin/sh\n# ~/.local/bin/naga-driver-mode\nfor dm in /sys/bus/hid/devices/*1532*00B4*/device_mode; do\n    [ -e \u0026#34;$dm\u0026#34; ] || continue\n    printf \u0026#39;\\003\\000\u0026#39; \u0026gt; \u0026#34;$dm\u0026#34;\ndone\n~/.config/hypr/scripts/naga-dpi-cycle.sh apply   # a wake can reset DPI too\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eA button starts lying, I hit the chord, and the mouse is honest again before I've lost the fight.\u003c/p\u003e\n\u003cp\u003eA 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 \u003ccode\u003edevice_mode\u003c/code\u003e 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 (\u003ccode\u003echarge_level\u003c/code\u003e in sysfs, 0–255, or over its D-Bus). None are deal breakers, just the tax for making the hardware honest.\u003c/p\u003e\n\u003ch2 id=\"getting-dpi-back\"\u003eGetting DPI back\u003c/h2\u003e\n\u003cp\u003eDriver mode has one cost worth its own section: the onboard DPI button stops cycling, because the cycling \u003cem\u003ewas\u003c/em\u003e the onboard profile. OpenRazer can still set DPI by writing the \u003ccode\u003edpi\u003c/code\u003e sysfs, though, so I moved the whole thing into Hyprland.\u003c/p\u003e\n\u003cp\u003eThe two profile buttons either side of the wheel emit \u003ccode\u003eF13\u003c/code\u003e and \u003ccode\u003eF14\u003c/code\u003e, which Hyprland sees as \u003ccode\u003ecode:191\u003c/code\u003e and \u003ccode\u003ecode:192\u003c/code\u003e. I bind those to a small stage-cycler:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-ini\"\u003ebindd = , code:191, Naga DPI down, exec, ~/.config/hypr/scripts/naga-dpi-cycle.sh down\nbindd = , code:192, Naga DPI up,   exec, ~/.config/hypr/scripts/naga-dpi-cycle.sh up\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe 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 \u003ccode\u003edpi\u003c/code\u003e sysfs as four big-endian bytes (X-hi, X-lo, Y-hi, Y-lo). It never reads DPI back, because that's the part that lies; it only writes.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003e#!/usr/bin/env bash\n# naga-dpi-cycle.sh [up|down|apply]: step the Naga through DPI stages.\n# (Trimmed here of the device-not-found guard and the notify-send.)\nset -eu\nSTAGES=(400 800 1000 1200 1600 3200)\nstate=\u0026#34;${XDG_STATE_HOME:-$HOME/.local/state}/naga-dpi-stage\u0026#34;\ndpi=$(ls /sys/bus/hid/devices/*1532*00B4*/dpi | head -1)\nlast=$(( ${#STAGES[@]} - 1 ))\n\nidx=$(cat \u0026#34;$state\u0026#34; 2\u0026gt;/dev/null || echo 3)        # default: stage 4 (1200 dpi)\ncase \u0026#34;${1:-apply}\u0026#34; in\n    up)   idx=$(( idx \u0026lt; last ? idx + 1 : last )) ;;\n    down) idx=$(( idx \u0026gt; 0    ? idx - 1 : 0    )) ;;\nesac\nprintf \u0026#39;%s\u0026#39; \u0026#34;$idx\u0026#34; \u0026gt; \u0026#34;$state\u0026#34;\n\nval=${STAGES[idx]}; hi=$(( (val \u0026gt;\u0026gt; 8) \u0026amp; 0xff )); lo=$(( val \u0026amp; 0xff ))\nprintf \u0026#34;$(printf \u0026#39;\\\\x%02x\\\\x%02x\\\\x%02x\\\\x%02x\u0026#39; \u0026#34;$hi\u0026#34; \u0026#34;$lo\u0026#34; \u0026#34;$hi\u0026#34; \u0026#34;$lo\u0026#34;)\u0026#34; \u0026gt; \u0026#34;$dpi\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eOne \u003ccode\u003eexec-once\u003c/code\u003e restores the last stage on login, since a wireless wake can reset that too:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-ini\"\u003eexec-once = ~/.config/hypr/scripts/naga-dpi-cycle.sh apply\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe short version: if two buttons send identical evdev events, stop blaming the remapper and go fix the mouse.\u003c/p\u003e\n\u003cp\u003eFor 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'm not holding my breath. I would, though, love to be wrong.\u003c/p\u003e\n",
      "date_published": "2026-06-15T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-razer-naga-duplicate-buttons-4a7698cc736b.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 10965918
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/refind-btrfs-one-core-many-bootloaders/",
      "url": "https://blog.i0.pm/posts/refind-btrfs-one-core-many-bootloaders/",
      "title": "One Core, Many Bootloaders",
      "summary": "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.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/refind-btrfs-snapshots\"\u003erefind-btrfs-snapshots\u003c/a\u003e started life answering one question, \u0026quot;how do I make rEFInd offer my btrfs snapshots?\u0026quot;, but the interesting work, snapshot discovery, kernel inspection and fstab alignment, was never really about rEFInd. It was about figuring out \u003cem\u003ehow a given snapshot wants to boot\u003c/em\u003e. rEFInd was just the first thing I rendered that into.\u003c/p\u003e\n\u003cp\u003eSo the project has been refactored into a shared core with a focused binary per boot story:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003erefind-btrfs-snapshots\u003c/code\u003e\u003c/strong\u003e: rEFInd submenu entries (where it began).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ebls-btrfs-snapshots\u003c/code\u003e\u003c/strong\u003e: Boot Loader Spec entries for systemd-boot and BLS-aware GRUB.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003euki-btrfs-snapshots\u003c/code\u003e\u003c/strong\u003e: per-snapshot Unified Kernel Images (more on this below).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003epeseal\u003c/code\u003e\u003c/strong\u003e: a pure-Go Authenticode signer for PE/UKI binaries, \u003ccode\u003esbctl\u003c/code\u003e-compatible, so the UKI path stays CGO-free.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ekernel-spy\u003c/code\u003e\u003c/strong\u003e: a read-only diagnostic that dumps every kernel, initramfs, microcode and UKI the discovery layer can see.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eOne core, many front-ends: the same shape I keep coming back to, because the hard part is the analysis, not the output format.\u003c/p\u003e\n\u003ch2 id=\"the-uki-problem\"\u003eThe UKI problem\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://uapi-group.org/specifications/specs/unified_kernel_image/\"\u003eUnified Kernel Images\u003c/a\u003e are where this gets genuinely tricky, and they're the \u0026quot;support other formats more generically\u0026quot; goal I'd been circling. A UKI bundles kernel, initramfs, cmdline and os-release into one signed EFI binary, and crucially the cmdline lives \u003cem\u003einside\u003c/em\u003e the image, in its \u003ccode\u003e.cmdline\u003c/code\u003e section. Under Secure Boot that embedded cmdline is authoritative: the boot loader can't override it with an external \u003ccode\u003eoptions=\u003c/code\u003e string.\u003c/p\u003e\n\u003cp\u003eThat's fatal for snapshot booting, because a snapshot-bootable cmdline \u003cem\u003ehas\u003c/em\u003e to differ per snapshot: it carries \u003ccode\u003erootflags=subvol=\u0026lt;snapshot\u0026gt;\u003c/code\u003e. 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 \u003cem\u003einside\u003c/em\u003e an image the firmware will actually run.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eWhat ships today\u003c/strong\u003e is Mode 1, \u003cem\u003ecloned UKIs\u003c/em\u003e. For each bootable snapshot, clone the source UKI into \u003ccode\u003e\u0026lt;esp\u0026gt;/EFI/Linux/\u003c/code\u003e, copying the kernel and initrd straight across and rewriting only the \u003ccode\u003e.cmdline\u003c/code\u003e to target that snapshot's subvolume. It costs a full UKI's worth of space per snapshot, but it boots anywhere that can launch an \u003ccode\u003e.efi\u003c/code\u003e, and \u003ccode\u003epeseal\u003c/code\u003e signs each one so Secure Boot stays intact.\u003c/p\u003e\n\u003ch2 id=\"whats-still-in-design\"\u003eWhat's still in design\u003c/h2\u003e\n\u003cp\u003eThe cheaper approach is Mode 2, a single \u003cstrong\u003emulti-profile UKI\u003c/strong\u003e per kernel, with one \u003ccode\u003e.profile\u003c/code\u003e section per snapshot sharing the kernel and initrd and carrying only its own cmdline. It's a lovely idea with a sharp edge: today, passing a profile selector at boot is essentially a systemd-boot story, so it isn't yet the universal answer cloning is. That, plus a proper \u003cstrong\u003ereconciliation engine\u003c/strong\u003e (compute the desired set of entries from scratch each run, diff against what'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's wishlist and is what I'm building toward next.\u003c/p\u003e\n\u003cp\u003eThe throughline: a snapshot you can't boot is just a backup you haven't tested. The point of all five binaries is to make \u0026quot;boot last Tuesday\u0026quot; work whatever your firmware, bootloader and Secure Boot setup happen to be. Repo and wishlist on \u003ca href=\"https://github.com/jmylchreest/refind-btrfs-snapshots\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-05-29T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-refind-btrfs-one-core-many-bootloaders-b9c6fa0276f7.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 7121570
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/aide-swarm/",
      "url": "https://blog.i0.pm/posts/aide-swarm/",
      "title": "Many Agents, One Repo",
      "summary": "aide'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.",
      "content_html": "\u003cp\u003e\u0026quot;Run a bunch of agents in parallel\u0026quot; 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's keeping them from standing on each other. That's what aide's \u003cstrong\u003eswarm\u003c/strong\u003e is actually about, and it just grew a proper coordination layer.\u003c/p\u003e\n\u003cp\u003eTwo design choices do most of the work.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eIsolated git worktrees.\u003c/strong\u003e 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.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eAtomic task claiming.\u003c/strong\u003e Work is a queue of tasks, and an agent \u003cem\u003eclaims\u003c/em\u003e one atomically before touching it: no two agents pick up the same task, and a crashed agent's task is reclaimable rather than lost. With the new coordination layer that's gRPC streaming over the daemon, an agent CLI to drive it, and a dashboard to watch it happen.\u003c/p\u003e\n\u003cp\u003eThe other half is that the swarm runs work through an actual \u003cstrong\u003eSDLC pipeline\u003c/strong\u003e (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.\u003c/p\u003e\n\u003cp\u003eNone of this is \u0026quot;AGI writes your app while you sleep.\u0026quot; It's the unglamorous orchestration (worktrees, queues, claims, stages) that makes parallel agents \u003cem\u003esafe\u003c/em\u003e enough to be useful. Which, predictably, was far more work than the demo version. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-05-25T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-swarm-bbb2337ae718.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2909052
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/awob-another-wayland-osd-bar/",
      "url": "https://blog.i0.pm/posts/awob-another-wayland-osd-bar/",
      "title": "awob: Another Wayland Overlay Bar",
      "summary": "A drop-in replacement for wob with KDL theming, typed IPC, and a small ecosystem of self-supervising event listeners.",
      "content_html": "\u003cblockquote\u003e\n\u003cp\u003eIf \u003ca href=\"https://github.com/francma/wob\"\u003ewob\u003c/a\u003e covers what you need, \u003cstrong\u003euse wob\u003c/strong\u003e. It's tiny, fast,\nand battle-tested. awob exists for the moment where you want one more thing wob doesn't try\nto give you.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eI'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's the entire shape of it, and most days it's exactly the right shape.\u003c/p\u003e\n\u003cp\u003eBut the moments where it isn'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's name when the volume changed. I wanted a different style for \u0026quot;muted\u0026quot; versus \u0026quot;loud\u0026quot;. I wanted a battery OSD to fire on AC plug \u003cem\u003eimmediately\u003c/em\u003e, not twenty seconds later when UPower's polling timer caught up.\u003c/p\u003e\n\u003cp\u003eEach of those was solvable in shell glue: a \u003ccode\u003ewob\u003c/code\u003e call wrapped in a script that read the audio state and rendered text with \u003ccode\u003enotify-send\u003c/code\u003e. But that's not really wob's fault. It's me wanting something wob doesn't try to be. So I built \u003cstrong\u003eawob\u003c/strong\u003e, \u003cem\u003eAnother Wayland Overlay Bar\u003c/em\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cvideo class=\"post-video\" controls preload=\"metadata\" playsinline aria-label=\"awob cycling through its shipped themes\"\u003e\u003csource src=\"https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/posts/assets/awob-demo.mp4\" type=\"video/mp4\"\u003e\u003c/video\u003e\u003c/p\u003e\n\u003ch2 id=\"whats-different\"\u003eWhat's different\u003c/h2\u003e\n\u003cp\u003eSame shape as wob: a transient overlay that appears, draws something, fades out. Same Wayland surface model (\u003ccode\u003ewlr-layer-shell-v1\u003c/code\u003e, no GTK, no Qt). What changed:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eThe theme is a data file, not a soup of CLI flags.\u003c/strong\u003e The whole visual is described in a \u003ca href=\"https://kdl.dev\"\u003eKDL\u003c/a\u003e 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eListeners are processes, not shell glue.\u003c/strong\u003e 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's supervisor auto-discovers them on \u003ccode\u003ePATH\u003c/code\u003e and respawns crashes.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eIPC is typed, not positional.\u003c/strong\u003e Instead of writing \u003ccode\u003e78 critical\u003c/code\u003e into a FIFO you send \u003ccode\u003eawob send --preempt --icon audio-volume-high volume 78 100\u003c/code\u003e. The wob FIFO format still works through a shim listener, so existing keybinds keep working.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"a-theme-is-one-file\"\u003eA theme is one file\u003c/h2\u003e\n\u003cp\u003eThe default theme is deliberately \u0026quot;every concept the engine supports, in one self-contained file\u0026quot;. It doubles as documentation:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-kdl\"\u003epalette {\n    bg     \u0026#34;rgba(28,28,35,0.85)\u0026#34;\n    fg     \u0026#34;#f3e8d7\u0026#34;\n    track  \u0026#34;rgba(255,255,255,0.08)\u0026#34;\n    low    \u0026#34;#8fdc55\u0026#34;\n    normal \u0026#34;#baea96\u0026#34;\n    warn   \u0026#34;#e89a49\u0026#34;\n    crit   \u0026#34;#dc8855\u0026#34;\n}\n\nsurface {\n    width 360 height 64 anchor \u0026#34;bottom\u0026#34; offset 0 -56\n    fade-in \u0026#34;150ms\u0026#34;  show \u0026#34;2000ms\u0026#34;  fade-out \u0026#34;150ms\u0026#34;\n}\n\nscene {\n    rect z=0 width=\u0026#34;100%\u0026#34; height=\u0026#34;100%\u0026#34; radius=12 fill=\u0026#34;$bg\u0026#34; shadow=\u0026#34;0 8 24 rgba(0,0,0,0.4)\u0026#34;\n    image z=1 src=\u0026#34;{$icon ?? icon($event)}\u0026#34; x=14 y=\u0026#34;center\u0026#34; width=22 height=22\n    text z=1 value=\u0026#34;{truncate($app ?? label($event), 24)}\u0026#34; x=46 y=14 font=\u0026#34;Inter 14 500\u0026#34; colour=\u0026#34;$fg\u0026#34;\n    text z=1 anchor=\u0026#34;top-right\u0026#34; value=\u0026#34;{int($progress * 100)}%\u0026#34; x=14 y=14 font=\u0026#34;Inter 14 500\u0026#34; colour=\u0026#34;$fg\u0026#34;\n    bar z=2 x=46 y=42 width=\u0026#34;100%-60\u0026#34; height=8 radius=999 fill=\u0026#34;$accent\u0026#34; min=0 max=\u0026#34;$max\u0026#34; value=\u0026#34;$value\u0026#34; from=\u0026#34;{$lastValue ?? $value}\u0026#34;\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003e$value\u003c/code\u003e, \u003ccode\u003e$max\u003c/code\u003e, \u003ccode\u003e$lastValue\u003c/code\u003e, \u003ccode\u003e$event\u003c/code\u003e, \u003ccode\u003e$app\u003c/code\u003e, \u003ccode\u003e$icon\u003c/code\u003e are bindings populated at send-time; \u003ccode\u003e$lastValue\u003c/code\u003e is what makes the bar tween smoothly between consecutive sends. A second shipped theme, \u003ccode\u003ewob\u003c/code\u003e, is a pixel-faithful clone of upstream (so existing rices keep working), and you switch between them at runtime with \u003ccode\u003eawob theme set wob\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"the-listener-that-justified-the-design\"\u003eThe listener that justified the design\u003c/h2\u003e\n\u003cp\u003eThe interesting case is the battery listener. The first cut used UPower over D-Bus, because that'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't fire the \u0026quot;charging\u0026quot; OSD for up to half a minute.\u003c/p\u003e\n\u003cp\u003eBut sysfs has the data already. \u003ccode\u003e/sys/class/power_supply/BAT0/status\u003c/code\u003e flips from \u003ccode\u003eDischarging\u003c/code\u003e to \u003ccode\u003eCharging\u003c/code\u003e the instant the kernel fires a \u003ccode\u003epower_supply\u003c/code\u003e uevent. So: drop UPower, subscribe to udev, read sysfs directly. AC plug to \u0026quot;charging\u0026quot; OSD now lands in a few hundred milliseconds, and the zbus dependency falls out of the listener entirely.\u003c/p\u003e\n\u003cp\u003eThere'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 \u003cem\u003eold\u003c/em\u003e 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.\u003c/p\u003e\n\u003ch2 id=\"five-minute-install-on-arch\"\u003eFive-minute install on Arch\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003eparu -S awob-bin awob-listeners-all\nsystemctl --user enable --now awob.service\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat's enough anywhere systemd wires up \u003ccode\u003egraphical-session.target\u003c/code\u003e (Hyprland with uwsm, GNOME, KDE). On Hyprland without uwsm, add \u003ccode\u003eexec-once = awob-daemon\u003c/code\u003e to \u003ccode\u003ehyprland.conf\u003c/code\u003e instead.\u003c/p\u003e\n\u003ch2 id=\"what-it-deliberately-isnt\"\u003eWhat it deliberately isn't\u003c/h2\u003e\n\u003cp\u003eIt's not a notification daemon: for toast notifications run \u003ca href=\"https://github.com/emersion/mako\"\u003emako\u003c/a\u003e, \u003ca href=\"https://github.com/ErikReider/SwayNotificationCenter\"\u003eswaync\u003c/a\u003e, or \u003ca href=\"https://github.com/jmylchreest/histui\"\u003ehistui\u003c/a\u003e (the one I run alongside it). It's not cross-platform; Wayland and Linux only. And it's not a kitchen-sink OSD framework: rectangles, text, icons, a bar, a shadow, a value tween. That's the lot.\u003c/p\u003e\n\u003cp\u003ePre-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 \u003ca href=\"https://github.com/jmylchreest/awob\"\u003eGitHub\u003c/a\u003e; issues and PRs welcome.\u003c/p\u003e\n",
      "date_published": "2026-05-04T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-awob-another-wayland-osd-bar-e680ccb443e9.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 9982734
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/tinct-terminal-tooling/",
      "url": "https://blog.i0.pm/posts/tinct-terminal-tooling/",
      "title": "Filling Out the Terminal",
      "summary": "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.",
      "content_html": "\u003cp\u003etinct 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.\u003c/p\u003e\n\u003cp\u003eSo a batch of output plugins just landed to fill that in:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eTerminals\u003c/strong\u003e: wezterm and foot join kitty, alacritty and ghostty.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMultiplexer\u003c/strong\u003e: tmux.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTUIs\u003c/strong\u003e: yazi (files), btop (system monitor), helix (editor).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eLaunchers\u003c/strong\u003e: rofi alongside the existing fuzzel and wofi.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eWith these, a \u003ccode\u003etinct generate … -o all\u003c/code\u003e recolours the whole terminal workflow in one shot (emulator, multiplexer, file manager, monitor, launcher) from the same palette that'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 \u0026quot;does tinct theme X?\u0026quot; is usually \u0026quot;yes\u0026quot;.\u003c/p\u003e\n\u003cp\u003eAUR \u003ccode\u003etinct-bin\u003c/code\u003e; the current plugin roster is in the \u003ca href=\"https://jmylchreest.github.io/tinct/\"\u003edocs\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-05-03T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-terminal-tooling-438814dd7436.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2353624
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec-keepassxc/",
      "url": "https://blog.i0.pm/posts/rosec-keepassxc/",
      "title": "KeePassXC Joins the Collection",
      "summary": "rosec gains a KeePassXC provider, reading a .kdbx file straight onto the Secret Service bus, SSH keys and TOTP seeds included.",
      "content_html": "\u003cp\u003eThe provider list keeps growing, and this one I'd wanted from the start. \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e can now read a \u003cstrong\u003eKeePassXC \u003ccode\u003e.kdbx\u003c/code\u003e file\u003c/strong\u003e 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't care which one answered.\u003c/p\u003e\n\u003cp\u003eThe nice part is what comes along for the ride. KeePassXC stores more than passwords, and rosec picks up the rest of it:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSSH keys\u003c/strong\u003e in the database, including KeePassXC's own KeeAgent attachments, register with rosec's built-in SSH agent automatically. The keys you already keep in your \u003ccode\u003e.kdbx\u003c/code\u003e just \u003cem\u003ework\u003c/em\u003e with \u003ccode\u003essh\u003c/code\u003e, no \u003ccode\u003essh-add\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTOTP seeds\u003c/strong\u003e surface as live files under \u003ccode\u003e$XDG_RUNTIME_DIR/rosec/totp/\u003c/code\u003e, same as every other provider.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eyay -S rosec-provider-keepassxc-file-bin\nrosec provider add keepassxc-file\nrosec unlock\nexport SSH_AUTH_SOCK=\u0026#34;$XDG_RUNTIME_DIR/rosec/agent.sock\u0026#34;\nssh-add -l            # keys straight out of your .kdbx\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIt's marked experimental: the \u003ccode\u003e.kdbx\u003c/code\u003e format has corners, and I'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 \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-05-02T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-keepassxc-4fe9ac79b8e0.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2427930
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec-totp/",
      "url": "https://blog.i0.pm/posts/rosec-totp/",
      "title": "Two-Factor Codes You Can `cat`",
      "summary": "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.",
      "content_html": "\u003cp\u003eBack when \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e grew an \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003eSSH agent and a FUSE filesystem\u003c/a\u003e for keys, the pattern underneath it was the interesting bit: take a secret the daemon already holds and expose it as a \u003cem\u003efile\u003c/em\u003e, so any tool that can read a file can use it. This is that same trick, pointed at two-factor codes.\u003c/p\u003e\n\u003cp\u003eA TOTP seed is just another secret. So if an item in any provider carries one, rosec now surfaces it as a live file under \u003ccode\u003e$XDG_RUNTIME_DIR/rosec/totp/\u003c/code\u003e, and \u003ccode\u003ecat\u003c/code\u003e-ing the file gives you the current six-digit code, regenerated every window:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003ecat \u0026#34;$XDG_RUNTIME_DIR/rosec/totp/GitHub\u0026#34;\n# 481922\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eNo app to open, no phone to reach for, no copy-paste dance: the code is a file, which means it's also a shell pipeline, a \u003ccode\u003ewofi\u003c/code\u003e entry, a keybind. Seeds go in either by storing them on an item directly or with the \u003cstrong\u003ebuilt-in QR scanner\u003c/strong\u003e, which reads the setup QR a site shows you and stores the seed in the provider of your choice. Like the SSH filesystem it's behind a toggle (\u003ccode\u003etotp_fuse\u003c/code\u003e), off until you ask for it, and the prompt helper auto-clears the clipboard after a paste so a code doesn't linger.\u003c/p\u003e\n\u003ch2 id=\"is-that-safe\"\u003eIs that safe?\u003c/h2\u003e\n\u003cp\u003eA live 2FA code sitting in a file sounds alarming, so it's worth spelling out, because there's a real trade-off hiding in it.\u003c/p\u003e\n\u003cp\u003eThe mechanics first. The mount lives under \u003ccode\u003e$XDG_RUNTIME_DIR/rosec/totp/\u003c/code\u003e, which on Linux is \u003ccode\u003e/run/user/\u0026lt;your-uid\u0026gt;/\u003c/code\u003e: a per-user tmpfs, mode \u003ccode\u003e0700\u003c/code\u003e, owned by you, that never touches the disk. rosec insists on \u003ccode\u003eXDG_RUNTIME_DIR\u003c/code\u003e; if it isn't set the filesystem simply doesn't mount, rather than falling back somewhere world-readable. The mount itself is read-only, \u003ccode\u003enosuid\u003c/code\u003e, \u003ccode\u003enodev\u003c/code\u003e, \u003ccode\u003enoexec\u003c/code\u003e, 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 \u003ccode\u003e0400\u003c/code\u003e, owner-read and nothing else.\u003c/p\u003e\n\u003cp\u003eThere's also nothing to steal at rest. It's a virtual filesystem, so there is no \u003ccode\u003eGitHub\u003c/code\u003e 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's memory behind its provider, not in the mount.\u003c/p\u003e\n\u003cp\u003eSo 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 \u003cem\u003eas you\u003c/em\u003e can read it, and nothing else can.\u003c/p\u003e\n\u003cp\u003eThat last clause is the trade-off, stated plainly. \u0026quot;Readable only by your user\u0026quot; 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 \u003ccode\u003ecat\u003c/code\u003e the file as easily as you can. For most secrets that's the accepted bargain. For TOTP it deserves a second's thought, because the whole point of a TOTP is to be a \u003cem\u003esecond\u003c/em\u003e factor on a \u003cem\u003eseparate\u003c/em\u003e device. Serve it from the same machine that holds the password and you've folded both factors into one trust boundary: own the machine as that user, and you have the password and the current code together.\u003c/p\u003e\n\u003cp\u003eFor me, and I'd guess most people, that'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 \u0026quot;my own machine might be running something hostile\u0026quot;, a code on your phone buys you something a code in a file cannot, and that's exactly why the whole thing stays behind the \u003ccode\u003etotp_fuse\u003c/code\u003e toggle, off until you decide it's worth it.\u003c/p\u003e\n\u003cp\u003eIt's a small feature that I use constantly, and it's the clearest example of why the provider model was worth the effort: unify the secrets once, and \u0026quot;current 2FA code\u0026quot; becomes \u003ccode\u003ecat\u003c/code\u003e of a path. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-04-12T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-totp-4ac0280f06d1.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 8511886
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/aide-not-a-claude-code-plugin/",
      "url": "https://blog.i0.pm/posts/aide-not-a-claude-code-plugin/",
      "title": "aide Is Not a Claude Code Plugin",
      "summary": "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.",
      "content_html": "\u003cp\u003eWhen I \u003ca href=\"/posts/aide-resurrected/\"\u003ebrought aide back\u003c/a\u003e, the headline was \u0026quot;context as code\u0026quot;: your project's memory and decisions live in the repo as reviewable records rather than in some vendor's hosted store. That's a nice slogan right up until your tool only works with one vendor's assistant, at which point you've just moved the lock-in by one box.\u003c/p\u003e\n\u003cp\u003eSo this is the part that makes the slogan true: \u003cstrong\u003eaide isn't tied to Claude Code.\u003c/strong\u003e It started there, picked up \u003ca href=\"https://opencode.ai\"\u003eOpenCode\u003c/a\u003e support a couple of days after the first release, and as of this week runs on \u003cstrong\u003eCodex CLI\u003c/strong\u003e too. Three harnesses, one knowledge store.\u003c/p\u003e\n\u003cp\u003eThe 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's events onto one shared set of core functions; the Go binary doing the actual work (storage, search, the MCP server) doesn't know or care which assistant is on the other end. Add a harness, write an adapter, done.\u003c/p\u003e\n\u003ch2 id=\"what-the-store-actually-holds\"\u003eWhat the store actually holds\u003c/h2\u003e\n\u003cp\u003ePortability only matters because of \u003cem\u003ewhat\u003c/em\u003e moves with you, and that's the part I care about most. The failure mode of an AI assistant isn't that it's stupid, it'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'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.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDecisions are constitutional.\u003c/strong\u003e A decision in aide is an ADR: a topic, a choice, a rationale. It behaves like a clause in a constitution, injected into \u003cem\u003eevery\u003c/em\u003e new session automatically, so the assistant starts already knowing \u0026quot;we use JWT with refresh tokens, because…\u0026quot;. When you change your mind you don'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.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eMemories are scoped.\u003c/strong\u003e Not everything should be shouted into every session. A memory carries a scope: \u003ccode\u003escope:global\u003c/code\u003e rides along in every project, \u003ccode\u003eproject:\u0026lt;name\u0026gt;\u003c/code\u003e 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's context and leaves the rest in the index, searchable but quiet. The session gets the facts relevant to \u003cem\u003ehere\u003c/em\u003e, not the union of everything aide has ever heard.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eThe code index is fact on demand.\u003c/strong\u003e 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.\u003c/p\u003e\n\u003cp\u003eThe through-line is that every one of these surfaces \u003cem\u003ecurrent, material fact\u003c/em\u003e and nothing else. That's the antidote to context poisoning: you can't be dragged off course by stale context if the store only hands over what'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 \u0026quot;context\u0026quot; would.\u003c/p\u003e\n\u003ch2 id=\"which-is-why-portability-matters\"\u003eWhich is why portability matters\u003c/h2\u003e\n\u003cp\u003ePut those together and the reason to keep it vendor-neutral becomes obvious. This isn't a chat history you'd shrug off losing; it's your team's accumulated, curated, current understanding of a codebase. Switch assistant, or run two side by side, and the \u003ccode\u003e.aide/\u003c/code\u003e directory comes with you: same decisions, same scoped memories, same code map. Records you can read, diff and move aren't just tidier than embeddings in someone's cloud, they're the only version of \u0026quot;memory\u0026quot; that survives you changing your mind about which AI you use. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-04-09T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-not-a-claude-code-plugin-5972dc01e0cf.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 7858808
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/aide-web-dashboard/",
      "url": "https://blog.i0.pm/posts/aide-web-dashboard/",
      "title": "Watching Where the Tokens Go",
      "summary": "aide grew a web dashboard, aide-web, to make its context injection observable, tracking tokens spent versus avoided per tool and over time.",
      "content_html": "\u003cp\u003eaide spends its life doing things you can't see: injecting memories and decisions at session start, answering the assistant'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, \u003ccode\u003eaide-web\u003c/code\u003e, to make it observable.\u003c/p\u003e\n\u003cp\u003eThe 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't spend re-explaining your auth strategy; the precise code-index answer is the wad of source you didn't have to paste in. The dashboard tracks both sides, spent versus avoided, per tool and over time, so \u0026quot;is this actually helping?\u0026quot; stops being a vibe and starts being a chart.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eaide dashboard\u003c/code\u003e launches the web UI; \u003ccode\u003eaide status\u003c/code\u003e gives you the same picture in the terminal if you'd rather not leave it.\u003c/p\u003e\n\u003cp\u003eOne honest caveat, and it's why the feature is labelled experimental: the token counts are \u003cem\u003eestimates\u003c/em\u003e. They come from calibrated per-language character ratios, not a real tokeniser, so they'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 \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-03-30T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-web-dashboard-edc04a78bed2.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2579508
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec-gnome-keyring/",
      "url": "https://blog.i0.pm/posts/rosec-gnome-keyring/",
      "title": "rosec Can Read the Old Keyring",
      "summary": "A read-only GNOME Keyring provider lets rosec surface everything already in your keyring, so you can migrate off it at your own pace.",
      "content_html": "\u003cp\u003eThe awkward part of replacing GNOME Keyring is that years of secrets are already \u003cem\u003ein\u003c/em\u003e it: Wi-Fi passwords, app tokens, the odd thing you forgot you saved. Switching the Secret Service over to \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e shouldn't mean leaving all of that behind, or doing a big-bang export the night you flip the switch.\u003c/p\u003e\n\u003cp\u003eSo rosec now has a \u003cstrong\u003eread-only GNOME Keyring provider\u003c/strong\u003e. 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're ready.\u003c/p\u003e\n\u003cp\u003eRead-only is deliberate. The keyring is the thing you're migrating \u003cem\u003eaway\u003c/em\u003e from; rosec treats it as a source to drain, not a destination to keep writing to. Nothing new lands back in it.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eyay -S rosec-provider-gnome-keyring-bin\nrosec provider add gnome-keyring\nsecret-tool search --all          # your old keyring secrets, now served by rosec\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eOne backend closer to rosec being the only Secret Service on the machine. KeePassXC is next on the list.\u003c/p\u003e\n",
      "date_published": "2026-03-04T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-gnome-keyring-220436bc88d1.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2082044
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec-local-vault/",
      "url": "https://blog.i0.pm/posts/rosec-local-vault/",
      "title": "rosec Grows a Vault of Its Own",
      "summary": "rosec gains a local encrypted vault provider: secrets at rest on your own disk, unlocked at login through PAM, no cloud account required.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e launched reading from Bitwarden, which is the case I built it for. But tying your whole Secret Service to a cloud account isn't for everyone, and \u0026quot;read my secrets from somewhere else\u0026quot; shouldn't \u003cem\u003erequire\u003c/em\u003e a somewhere-else.\u003c/p\u003e\n\u003cp\u003eSo rosec now has a provider of its own: a \u003cstrong\u003elocal encrypted vault\u003c/strong\u003e. Secrets live encrypted on your own disk, and the daemon serves them on the bus exactly like any other backend; \u003ccode\u003esecret-tool\u003c/code\u003e and friends can't tell the difference. It's the answer for anyone who wants the multi-provider Secret Service without a remote dependency in the loop.\u003c/p\u003e\n\u003cp\u003eThe piece that makes it pleasant is \u003cstrong\u003ePAM unlock\u003c/strong\u003e, which landed alongside it: log in once and the vault opens with your login password, so there's no second prompt before your first secret lookup of the session.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003erosec provider add local\nrosec unlock\nrosec item add --provider local --label \u0026#34;GitHub\u0026#34; --attr username=alice --secret \u0026#39;hunter2\u0026#39;\nsecret-tool lookup label \u0026#34;GitHub\u0026#34;     # → hunter2, from your own disk\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThis is the provider I'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.\u003c/p\u003e\n",
      "date_published": "2026-02-28T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-local-vault-26d6ddc9df91.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2067180
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec-ssh-agent/",
      "url": "https://blog.i0.pm/posts/rosec-ssh-agent/",
      "title": "SSH Keys That Mount Themselves",
      "summary": "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.",
      "content_html": "\u003cp\u003eThe day after \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e started serving secrets on the bus, the most obvious \u0026quot;wait, these are just secrets too\u0026quot; feature landed: SSH keys.\u003c/p\u003e\n\u003cp\u003eAn SSH key is a secret like any other, so why keep it in a separate \u003ccode\u003e~/.ssh/\u003c/code\u003e pile and run \u003ccode\u003essh-add\u003c/code\u003e by hand? rosec now ships a \u003cstrong\u003ebuilt-in SSH agent\u003c/strong\u003e. Any private key stored in any provider (a first-class key item, a PEM blob dropped in a note) gets registered automatically at \u003ccode\u003e$XDG_RUNTIME_DIR/rosec/agent.sock\u003c/code\u003e. Point \u003ccode\u003eSSH_AUTH_SOCK\u003c/code\u003e at it and your keys are simply \u003cem\u003ethere\u003c/em\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eexport SSH_AUTH_SOCK=\u0026#34;$XDG_RUNTIME_DIR/rosec/agent.sock\u0026#34;\nssh-add -l        # every key in your vault, no ssh-add ever run\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe part I'm more pleased with is the \u003cstrong\u003eFUSE filesystem\u003c/strong\u003e 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 \u003ccode\u003e$XDG_RUNTIME_DIR/rosec/ssh/\u003c/code\u003e: each key's \u003cem\u003epublic\u003c/em\u003e half under \u003ccode\u003ekeys/\u003c/code\u003e, and a generated config snippet under \u003ccode\u003econfig.d/\u003c/code\u003e. Nothing private ever touches the disk.\u003c/p\u003e\n\u003ch2 id=\"the-right-key-every-time\"\u003eThe right key, every time\u003c/h2\u003e\n\u003cp\u003eThe config is the part that earns its keep, and it's driven by two custom fields you can set on any key item:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003essh-host\u003c/code\u003e\u003c/strong\u003e is the host pattern (or patterns, one per line) the key is for: \u003ccode\u003egithub.com\u003c/code\u003e, \u003ccode\u003e*.internal.example.com\u003c/code\u003e, and so on. Each becomes a \u003ccode\u003eHost\u003c/code\u003e block.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003essh-user\u003c/code\u003e\u003c/strong\u003e is the login user, and if you leave it off rosec falls back to the item's username field. It becomes the \u003ccode\u003eUser\u003c/code\u003e line.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eFrom those, each item gets its own \u003ccode\u003econfig.d/\u0026lt;name\u0026gt;.conf\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sshconfig\"\u003eHost git.internal.example.com\n    User deploy\n    IdentityFile \u0026#34;/run/user/1000/rosec/ssh/keys/by-name/prod-deploy.pub\u0026#34;\n    IdentityAgent \u0026#34;/run/user/1000/rosec/agent.sock\u0026#34;\n    IdentitiesOnly yes\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eYou wire that into SSH once. I keep a one-line static file so my real \u003ccode\u003e~/.ssh/config\u003c/code\u003e never has to change, and it pulls in the live mount:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sshconfig\"\u003e# ~/.ssh/config\nInclude configs.d/*.conf\n\n# ~/.ssh/configs.d/10-rosecd.conf\nInclude /run/user/1000/rosec/ssh/config.d/*.conf\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat last directive, \u003ccode\u003eIdentitiesOnly yes\u003c/code\u003e, is the quiet hero. By default \u003ccode\u003essh\u003c/code\u003e offers the agent every key it holds, one after another, until something works; against a server with \u003ccode\u003eMaxAuthTries 6\u003c/code\u003e a well-stocked agent gets you kicked off for \u0026quot;Too many authentication failures\u0026quot; before it ever reaches the right key. Here each host is pinned to exactly its key and told to offer \u003cem\u003eonly\u003c/em\u003e 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 \u003ccode\u003essh-host\u003c/code\u003e, and the config to use it writes itself.\u003c/p\u003e\n\u003cp\u003eIt's optional and behind a config toggle (\u003ccode\u003essh_fuse\u003c/code\u003e), because a self-mounting filesystem is exactly the sort of thing you want to opt into rather than have appear. But with it on, \u0026quot;I added a new SSH key\u0026quot; and \u0026quot;I can use that key everywhere\u0026quot; become the same step.\u003c/p\u003e\n\u003cp\u003eThis 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 \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003e2FA codes\u003c/a\u003e got the same treatment later. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-02-25T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-ssh-agent-d8d73b6269cf.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 6939986
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/rosec/",
      "url": "https://blog.i0.pm/posts/rosec/",
      "title": "Secrets, Minus the Keyring",
      "summary": "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.",
      "content_html": "\u003cp\u003eOn Linux, \u003ccode\u003eorg.freedesktop.secrets\u003c/code\u003e is how desktop applications store passwords: anything using \u003ccode\u003elibsecret\u003c/code\u003e or \u003ccode\u003esecret-tool\u003c/code\u003e 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't; they're in Bitwarden. I wanted the Secret Service on my machine to read from \u003cem\u003ethere\u003c/em\u003e, transparently, without every app needing to know.\u003c/p\u003e\n\u003cp\u003eSo \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003erosec\u003c/a\u003e 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.\u003c/p\u003e\n\u003cp\u003eOnce every secret lives somewhere the daemon can reach, more falls out of it than password lookups: an SSH agent that needs no \u003ccode\u003essh-add\u003c/code\u003e, two-factor codes you can \u003ccode\u003ecat\u003c/code\u003e. 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.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eyay -S rosec-bin\nrosec enable        # installs the D-Bus activation, masks gnome-keyring-daemon\nsecret-tool lookup label \u0026#34;GitHub\u0026#34;     # → reads from Bitwarden via rosec\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe point of the design is the \u003cem\u003eprovider\u003c/em\u003e 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'll post as those land.)\u003c/p\u003e\n\u003cp\u003eThis 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's young. Repo and docs are on \u003ca href=\"https://github.com/jmylchreest/rosec\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-02-24T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-rosec-13ef7614c5fe.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 3648228
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/keylightd-typed-api/",
      "url": "https://blog.i0.pm/posts/keylightd-typed-api/",
      "title": "Giving keylightd a Real API",
      "summary": "keylightd'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.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003ekeylightd\u003c/a\u003e shipped its HTTP API on the standard library's \u003ccode\u003ehttp.ServeMux\u003c/code\u003e, hand-marshalling JSON in each handler. That's fine until it isn't: the routes drift from the docs, every handler repeats the same decode/encode dance, and \u0026quot;what does this endpoint actually return?\u0026quot; is answered by reading the source. So the API got a proper rebuild.\u003c/p\u003e\n\u003cp\u003eThe handlers now run on \u003cstrong\u003e\u003ca href=\"https://github.com/go-chi/chi\"\u003eChi\u003c/a\u003e + \u003ca href=\"https://huma.rocks/\"\u003eHuma\u003c/a\u003e\u003c/strong\u003e, which buys three things at once:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eTyped requests and responses.\u003c/strong\u003e 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAn OpenAPI spec for free.\u003c/strong\u003e Because the types \u003cem\u003eare\u003c/em\u003e the contract, the spec is generated from them: it can't drift from the implementation, because it's derived from it. Anything that speaks OpenAPI can now generate a client.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA middleware ecosystem.\u003c/strong\u003e Rate limiting, structured logging and the rest drop in as Chi middleware instead of bespoke wrappers.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe rule I held myself to: \u003cstrong\u003eall thirteen existing routes preserved, byte-for-byte.\u003c/strong\u003e 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.\u003c/p\u003e\n\u003cp\u003eThe same pass added \u003cstrong\u003eWebSocket events\u003c/strong\u003e: instead of polling the daemon to ask \u0026quot;is the light on yet?\u0026quot;, 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 \u003cem\u003etell\u003c/em\u003e you rather than wait to be asked.\u003c/p\u003e\n\u003cp\u003eLogging moved to structured \u003ccode\u003eslog\u003c/code\u003e with hot-reloadable filters in the same sweep: the boring, load-bearing stuff. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2026-02-10T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-typed-api-f108fa6d403e.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 3651716
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/aide-resurrected/",
      "url": "https://blog.i0.pm/posts/aide-resurrected/",
      "title": "aide, Resurrected: Minus the Vector Database",
      "summary": "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.",
      "content_html": "\u003cp\u003eI had a tool called \u003cem\u003eaide memoire\u003c/em\u003e, a scratchpad of memory and context for an AI coding assistant, built the way everyone builds these: a \u003cstrong\u003elocal vector database\u003c/strong\u003e, 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 \u003cstrong\u003e\u003ca href=\"https://github.com/jmylchreest/aide\"\u003eaide\u003c/a\u003e\u003c/strong\u003e, and the thing I was most certain about going in was that the vector database wasn't coming with it.\u003c/p\u003e\n\u003cp\u003eThat sounds like a step backwards. Embeddings-in-a-vector-store is the default for coding-assistant memory. But the more I'd lived with the old design, the more the vector DB felt like the wrong tool for \u003cem\u003ethis\u003c/em\u003e 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's better for it.\u003c/p\u003e\n\u003ch2 id=\"why-no-vector-db\"\u003eWhy no vector DB\u003c/h2\u003e\n\u003cp\u003eThe pitch for embeddings is \u003cem\u003esemantic\u003c/em\u003e recall: find the chunk that's vibes-similar to your query. But what an agent needs from a project usually isn't vibes; it's \u003cstrong\u003efacts\u003c/strong\u003e. Where is this symbol defined? What calls it? What did we decide about retries, and why? What'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's bet is that the power is in factual context, not semantic similarity, and once you take that bet, the wins stack up:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eNo model in the loop.\u003c/strong\u003e Embeddings have to be \u003cem\u003egenerated\u003c/em\u003e, 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's API for permission.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAccuracy.\u003c/strong\u003e A factual lookup returns \u003cem\u003ethe\u003c/em\u003e answer, not the five chunks that scored closest. \u0026quot;Where is \u003ccode\u003eFoo\u003c/code\u003e defined\u0026quot; has a correct result; cosine similarity only has a ranking. For code, exact beats approximate nearly every time.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFewer tokens.\u003c/strong\u003e Because the answer is precise it's also \u003cem\u003esmall\u003c/em\u003e: 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.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eContext as code.\u003c/strong\u003e Everything aide knows (memories, decisions, findings) lives as plain records in a per-project \u003ccode\u003e.aide/\u003c/code\u003e directory and exports to markdown you can commit. It's PR-reviewable, diffable, and you can read \u003cem\u003ewhy\u003c/em\u003e something surfaced. A blob of floats is none of those.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAnd it's where the field went.\u003c/strong\u003e 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.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eFor 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 \u003cem\u003eknowledge\u003c/em\u003e, full-text over short human-written records beats cosine similarity over chunks. Neither needs a GPU.\u003c/p\u003e\n\u003ch2 id=\"what-it-actually-does\"\u003eWhat it actually does\u003c/h2\u003e\n\u003cp\u003eaide is an MCP plugin that gives an assistant (Claude Code today; more shortly) the things a new contributor would kill for:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eMemory + decisions\u003c/strong\u003e: preferences, context and architectural choices that persist across sessions, injected at startup and enforceable when you write code that contradicts them.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCode intelligence\u003c/strong\u003e: tree-sitter symbol search, references and call graphs.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFindings\u003c/strong\u003e: static analysis for complexity, coupling, secrets, duplication and dead code.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSurvey\u003c/strong\u003e: a map of modules, entry points, tech stack and churn hotspots.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSkills + multi-agent orchestration\u003c/strong\u003e: keyword-triggered workflows, and parallel agents working in isolated git worktrees.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe throughline is that it serves \u003cem\u003ecompact, token-budgeted answers\u003c/em\u003e instead of whole files, so a long-running agent spends its context window on the work rather than rediscovering the project every session.\u003c/p\u003e\n\u003cp\u003eIt's early and opinionated, which is the way I like a v0. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/aide\"\u003eGitHub\u003c/a\u003e. More on the individual pieces, and the opinions behind them, to come.\u003c/p\u003e\n",
      "date_published": "2026-02-07T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-aide-resurrected-da33ec84e136.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 8766662
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/histui/",
      "url": "https://blog.i0.pm/posts/histui/",
      "title": "The Notifications You Missed",
      "summary": "histui is a themeable GTK4 notification daemon for Wayland that keeps a persistent history, with a TUI browser and CLI for querying past notifications.",
      "content_html": "\u003cp\u003eNotifications on Wayland are a solved problem right up until you miss one. A toast appears for five seconds while you're looking at another monitor, fades out, and is gone, and there's no good way to ask \u0026quot;what did that say?\u0026quot; \u003ca href=\"https://dunst-project.org/\"\u003edunst\u003c/a\u003e and \u003ca href=\"https://github.com/emersion/mako\"\u003emako\u003c/a\u003e are excellent at \u003cem\u003eshowing\u003c/em\u003e notifications; they're just not built to \u003cem\u003ekeep\u003c/em\u003e them.\u003c/p\u003e\n\u003cp\u003eSo \u003ca href=\"https://github.com/jmylchreest/histui\"\u003ehistui\u003c/a\u003e 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.\u003c/p\u003e\n\u003cp\u003eThe history is what I built histui \u003cem\u003efor\u003c/em\u003e. There'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 \u003ca href=\"https://github.com/Alexays/Waybar\"\u003ewaybar\u003c/a\u003e module showing live counts.\u003c/p\u003e\n\u003cp\u003eBut it turned out not to be the part I appreciate most. What won me over day to day is the \u003cem\u003epresentation\u003c/em\u003e 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 \u003ca href=\"https://github.com/jmylchreest/tinct\"\u003etinct\u003c/a\u003e 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.\u003c/p\u003e\n\u003cp\u003eIt'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't start from an empty slate.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003eyay -S histui-bin\nsystemctl --user enable --now histuid\nhistui            # browse your history\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEarly days, and themeable enough that I expect most of the interesting work to happen in other people's CSS. Repo, a demo video and docs are on \u003ca href=\"https://github.com/jmylchreest/histui\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2025-12-26T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-histui-1d230fccde0d.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 4176756
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/tvarr/",
      "url": "https://blog.i0.pm/posts/tvarr/",
      "title": "Wrangling IPTV into One Playlist",
      "summary": "tvarr is a self-hosted IPTV relay; it aggregates M3U/Xtream sources, merges EPG, and repackages or transcodes streams on demand.",
      "content_html": "\u003cp\u003eIf you self-host live TV, you'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't mine.\u003c/p\u003e\n\u003cp\u003eSo \u003ca href=\"https://github.com/jmylchreest/tvarr\"\u003etvarr\u003c/a\u003e 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.\u003c/p\u003e\n\u003ch2 id=\"one-origin-many-players\"\u003eOne origin, many players\u003c/h2\u003e\n\u003cp\u003eMost IPTV origins allow a \u003cem\u003esingle\u003c/em\u003e 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.\u003c/p\u003e\n\u003cp\u003eSo tvarr holds \u003cstrong\u003eone origin connection per channel\u003c/strong\u003e 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 \u003cem\u003eelementary streams\u003c/em\u003e, the raw H.264 or H.265 video and AAC or AC3 audio with their timing intact, written into a per-channel \u003ccode\u003eSharedESBuffer\u003c/code\u003e. 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.\u003c/p\u003e\n\u003cp\u003eIf a client needs a codec the source doesn't carry, transcoding happens \u003cstrong\u003eon demand and once\u003c/strong\u003e. 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.\u003c/p\u003e\n\u003ch2 id=\"distributed-transcoding-over-grpc\"\u003eDistributed transcoding over gRPC\u003c/h2\u003e\n\u003cp\u003eTranscoding is the expensive part, so tvarr can push it off the box doing the relaying entirely. A separate worker daemon, \u003cstrong\u003effmpegd\u003c/strong\u003e, connects to tvarr (the coordinator) over \u003cstrong\u003egRPC\u003c/strong\u003e, 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.\u003c/p\u003e\n\u003cp\u003eWhen 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.\u003c/p\u003e\n\u003ch2 id=\"filters-and-data-mapping\"\u003eFilters and data mapping\u003c/h2\u003e\n\u003cp\u003eThe other half of taming five providers' 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. \u003cstrong\u003eData mapping\u003c/strong\u003e runs first and rewrites messy fields; \u003cstrong\u003efilters\u003c/strong\u003e then include or exclude channels on the now-clean values, so you tidy before you decide.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003e# data mapping (first): normalise the mess\nSET group_title = \u0026#34;UK Sports\u0026#34;   where  group_title matches \u0026#34;.*UK.*Sport.*\u0026#34;\n\n# filters (second): include/exclude on the clean values\nINCLUDE  group_title contains \u0026#34;Sports\u0026#34;\nEXCLUDE  channel_name matches \u0026#34;.*(Test|Backup|XXX).*\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eMap 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.\u003c/p\u003e\n\u003cp\u003eIt runs as a single container with a web UI for managing sources, rules and output playlists:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003edocker run -d -p 8080:8080 -v tvarr-data:/data \\\n  -e TVARR_SERVER_BASE_URL=http://your-host:8080 \\\n  ghcr.io/jmylchreest/tvarr:release\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIf you've used Threadfin or StreamMaster it lives in the same neighbourhood; this is just my take on the shape of the problem. It's early and moving quickly. Repo and docs are on \u003ca href=\"https://github.com/jmylchreest/tvarr\"\u003eGitHub\u003c/a\u003e; if it's useful, brilliant, and if it isn't quite right, open an issue.\u003c/p\u003e\n",
      "date_published": "2025-12-05T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tvarr-5e667db6023a.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 8709820
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/keylightd-tray/",
      "url": "https://blog.i0.pm/posts/keylightd-tray/",
      "title": "A Tray Icon, and a Migration on Hold",
      "summary": "keylightd gains a cross-platform system-tray app via Wails, and an honest account of the Wails v3 migration that's parked waiting on an upstream bug.",
      "content_html": "\u003cp\u003eNot everyone lives in GNOME, and not everyone wants a shell extension. So \u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003ekeylightd\u003c/a\u003e now has a \u003cstrong\u003esystem-tray app\u003c/strong\u003e: a small \u003ca href=\"https://wails.io\"\u003eWails\u003c/a\u003e desktop application, on Linux and Windows alike, that puts your lights one click away in the system tray.\u003c/p\u003e\n\u003cp\u003ekeylightd 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's a thin client over the daemon'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.\u003c/p\u003e\n\u003cp\u003eAnd of course it's themed. The window is plain CSS with hot reload, and \u003ca href=\"https://github.com/jmylchreest/tinct\"\u003etinct\u003c/a\u003e 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.\u003c/p\u003e\n\u003cp\u003eThat's the good news. Here's the honest part, because I'd rather write it down than pretend.\u003c/p\u003e\n\u003cp\u003eI tried to move the tray app to \u003cstrong\u003eWails v3\u003c/strong\u003e, which has a much nicer native systray story and cleaner bindings, and the migration is \u003cem\u003ecode-complete\u003c/em\u003e but \u003cstrong\u003eon hold\u003c/strong\u003e. Wails v3 is still in alpha, and the build segfaults during GTK initialisation on Linux (a \u003ccode\u003eSIGSEGV\u003c/code\u003e in the alpha's GTK path, not in keylightd's code). I could paper over it, but a tray app that crashes on launch for half my users isn't a release; it'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.\u003c/p\u003e\n\u003cp\u003eThis 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 \u003cem\u003enot ship it\u003c/em\u003e. The branch isn't abandoned; it'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's actually waiting on it.\u003c/p\u003e\n\u003cp\u003eRepo and docs on \u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2025-11-21T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-tray-9b0628e65cf2.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 4265094
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/tinct-whole-desktop/",
      "url": "https://blog.i0.pm/posts/tinct-whole-desktop/",
      "title": "A Palette for the Whole Desktop",
      "summary": "tinct now themes GTK and Qt (GTK3/4, libadwaita, Qt5/6, GNOME Shell, KDE Plasma) so the palette reaches the apps that don't read a config file you control.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/tinct\"\u003etinct\u003c/a\u003e 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's still wearing last month's colours. The toolkit apps were the conspicuous gap: the browser chrome, the file picker, half of GNOME and KDE, all stubbornly default.\u003c/p\u003e\n\u003cp\u003eThat gap is now closed. tinct has output plugins for the desktop toolkits themselves:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eGTK\u003c/strong\u003e: GTK3, GTK4 and libadwaita, so GNOME apps and anything GTK-based pick up the palette.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eQt\u003c/strong\u003e: Qt5 and Qt6, plus \u003cstrong\u003eKDE Plasma\u003c/strong\u003e and \u003cstrong\u003ekonsole\u003c/strong\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGNOME Shell\u003c/strong\u003e: the shell, not just the apps under it.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eWhich means \u003ccode\u003etinct generate -i image -p wallpaper.jpg -o all\u003c/code\u003e now recolours the \u003cem\u003ewhole\u003c/em\u003e 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's semantic role-assignment and WCAG checks do it beats hand-templating.\u003c/p\u003e\n\u003cp\u003eStill early, still moving. AUR is \u003ccode\u003etinct-bin\u003c/code\u003e; the full output-plugin list lives in the \u003ca href=\"https://jmylchreest.github.io/tinct/\"\u003edocs\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2025-11-18T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-whole-desktop-f645af467c13.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2605372
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/tinct/",
      "url": "https://blog.i0.pm/posts/tinct/",
      "title": "One Palette, Many Configs",
      "summary": "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.",
      "content_html": "\u003cp\u003eI 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 \u003ca href=\"https://github.com/dylanaraps/pywal\"\u003ePywal\u003c/a\u003e or \u003ca href=\"https://github.com/InioX/matugen\"\u003eMatugen\u003c/a\u003e: extract colours from a wallpaper, template them into a pile of config files. They're good tools and I used them for years. But I kept bumping into the same two walls: the inputs I wanted weren't there, and adding a new application meant another bespoke shell template bolted on the side.\u003c/p\u003e\n\u003cp\u003eSo \u003ca href=\"https://github.com/jmylchreest/tinct\"\u003etinct\u003c/a\u003e is my answer: an extensible colour-palette generator and theme manager, built around plugins at \u003cem\u003eboth\u003c/em\u003e ends. A palette is something you produce from an input plugin and apply through output plugins, and either side is open.\u003c/p\u003e\n\u003cp\u003eOn the input side, a palette can come from an image (the Pywal case), from a remote theme (point it at Catppuccin'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't end up with unreadable text on a bad day'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. \u003ccode\u003e-o all\u003c/code\u003e writes the lot in one pass, and a new application is a new output plugin, not a fork of the tool.\u003c/p\u003e\n\u003cp\u003eIt's early; this is the \u0026quot;scratch my own itch, then realise other people have the same itch\u0026quot; stage, so expect rough edges and shifting defaults. It's written in Go, installs from the AUR (\u003ccode\u003eyay -S tinct-bin\u003c/code\u003e) or \u003ccode\u003ego install\u003c/code\u003e, and the repo and docs are on \u003ca href=\"https://github.com/jmylchreest/tinct\"\u003eGitHub\u003c/a\u003e. More inputs and a lot more output plugins are where it goes from here.\u003c/p\u003e\n",
      "date_published": "2025-11-04T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-tinct-719ddacbefa7.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 3469898
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/refind-btrfs-snapshots/",
      "url": "https://blog.i0.pm/posts/refind-btrfs-snapshots/",
      "title": "Booting Into a Snapshot",
      "summary": "A single static Go binary that generates rEFInd boot entries for your btrfs snapshots, auto-detecting each snapshot's boot mode and quietly handling stale kernels.",
      "content_html": "\u003cp\u003ebtrfs snapshots are a wonderful safety net right up to the moment you actually need one, at which point you discover that \u0026quot;roll back to this morning\u0026quot; means booting into a snapshot, and your boot manager has never heard of it. The excellent \u003ca href=\"https://github.com/Venom1991/refind-btrfs\"\u003erefind-btrfs\u003c/a\u003e solves this, but it's Python with a dependency tree, and I wanted something I could drop onto a machine as one file and forget about.\u003c/p\u003e\n\u003cp\u003eSo \u003cstrong\u003e\u003ca href=\"https://github.com/jmylchreest/refind-btrfs-snapshots\"\u003erefind-btrfs-snapshots\u003c/a\u003e\u003c/strong\u003e is a single static Go binary that discovers your btrfs snapshots and generates \u003ca href=\"https://www.rodsbooks.com/refind/\"\u003erEFInd\u003c/a\u003e boot-menu entries for them. Run it as a one-off whenever you like; it's a plain binary, so systemd isn'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.\u003c/p\u003e\n\u003cp\u003eThe part I care about is that it gets the \u003cem\u003edetails\u003c/em\u003e right, because boot is exactly where you don't want surprises:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePer-snapshot boot-mode detection.\u003c/strong\u003e Whether a snapshot boots in ESP mode (kernel on a separate \u003ccode\u003e/boot\u003c/code\u003e) or btrfs mode (kernel inside the snapshot) is read from \u003cem\u003ethat snapshot's own\u003c/em\u003e \u003ccode\u003e/etc/fstab\u003c/code\u003e, not assumed globally. So snapshots taken before you changed your disk layout still boot correctly, and one menu can mix both kinds.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eStale-kernel handling, the feature other tools skip.\u003c/strong\u003e With a separate ESP \u003ccode\u003e/boot\u003c/code\u003e, 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 \u003ccode\u003ewarn\u003c/code\u003e, \u003ccode\u003edisable\u003c/code\u003e and \u003ccode\u003efallback\u003c/code\u003e options if you'd rather handle it differently. Not silently.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe correct config method.\u003c/strong\u003e It writes an include file rather than abusing \u003ccode\u003erefind_linux.conf\u003c/code\u003e (which can only override \u003ccode\u003eoptions\u003c/code\u003e, not the \u003ccode\u003evolume\u003c/code\u003e/\u003ccode\u003eloader\u003c/code\u003e/\u003ccode\u003einitrd\u003c/code\u003e that btrfs-mode entries need), and it shows you a diff before it touches anything.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eAnd it auto-detects most of what matters: your ESP, each snapshot's boot mode, a standard Snapper layout. The defaults work with no configuration on a typical setup, so for most people it's install, enable the path unit, and forget it's there. Installs from the AUR (\u003ccode\u003erefind-btrfs-snapshots-bin\u003c/code\u003e) or as a prebuilt binary, and \u003ccode\u003e--dry-run\u003c/code\u003e is first-class. Repo on \u003ca href=\"https://github.com/jmylchreest/refind-btrfs-snapshots\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2025-06-12T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-refind-btrfs-snapshots-a589ee1df337.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 5025600
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/keylightd-gnome/",
      "url": "https://blog.i0.pm/posts/keylightd-gnome/",
      "title": "Key Lights in the Top Bar",
      "summary": "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.",
      "content_html": "\u003cp\u003e\u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003ekeylightd\u003c/a\u003e 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'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.\u003c/p\u003e\n\u003cp\u003eSo keylightd now has a \u003cstrong\u003eGNOME Shell extension\u003c/strong\u003e. 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're doing. It's published on \u003ca href=\"https://extensions.gnome.org/\"\u003eextensions.gnome.org\u003c/a\u003e (extension 8185), so it installs like any other.\u003c/p\u003e\n\u003cp\u003eI'll be honest: I only really use GNOME when I'm not on my own machines; left to my own setup I'm more of a terminal junkie on a tiling Wayland desktop. But there's something quietly nice about a control that feels \u003cem\u003eintegrated\u003c/em\u003e, 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'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.\u003c/p\u003e\n\u003cp\u003eToggle from the bar, script from the shell, both driving the same daemon. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003eGitHub\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2025-05-18T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-gnome-e0883d7eeaa8.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2525638
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/keylightd/",
      "url": "https://blog.i0.pm/posts/keylightd/",
      "title": "A Daemon for the Key Lights",
      "summary": "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.",
      "content_html": "\u003cp\u003eI 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 \u003cem\u003etwo\u003c/em\u003e 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 \u003cem\u003eme\u003c/em\u003e a clean way in.\u003c/p\u003e\n\u003cp\u003eSo I wrote \u003cstrong\u003e\u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003ekeylightd\u003c/a\u003e\u003c/strong\u003e, a small Go daemon that owns the lights so nothing else has to.\u003c/p\u003e\n\u003cp\u003eThe daemon does three things. It \u003cstrong\u003ediscovers\u003c/strong\u003e 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 \u003cstrong\u003egroups\u003c/strong\u003e 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; \u0026quot;turn the office on\u0026quot; is one command and both come up matched. And it exposes that control through whichever interface suits the caller:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ea \u003cstrong\u003eCLI\u003c/strong\u003e (\u003ccode\u003ekeylightctl\u003c/code\u003e) talking to the daemon over a low-latency Unix socket;\u003c/li\u003e\n\u003cli\u003ea \u003cstrong\u003eREST API\u003c/strong\u003e with token auth and an auto-generated OpenAPI spec, for anything remote or scripted;\u003c/li\u003e\n\u003cli\u003ea small \u003cstrong\u003eclient library\u003c/strong\u003e so Go programs can skip the wire format entirely.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ekeylightctl group add Office\nkeylightctl group set Office on true\nkeylightctl group set Office brightness 40\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe point of a daemon, rather than a pile of \u003ccode\u003ecurl\u003c/code\u003e one-liners, is that there'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.\u003c/p\u003e\n\u003cp\u003eBecause the control point is just \u003ccode\u003ekeylightctl\u003c/code\u003e (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't need to know anything about that; watch for the camera device opening, or a specific app launching, and call \u003ccode\u003ekeylightctl group set Office on true\u003c/code\u003e. I did try building the webcam-detection in natively, but \u0026quot;is the camera in use\u0026quot; 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've wired something like this up, driving the lights off some local condition, drop an issue on the \u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003erepo\u003c/a\u003e; I'd genuinely love to hear how.\u003c/p\u003e\n\u003cp\u003eIt's day one and deliberately small. Repo and docs on \u003ca href=\"https://github.com/jmylchreest/keylightd\"\u003eGitHub\u003c/a\u003e; desktop control and a proper typed API come next.\u003c/p\u003e\n",
      "date_published": "2025-05-11T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-keylightd-28f6984ad02e.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 5892722
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/silicon-valley-season-3/",
      "url": "https://blog.i0.pm/posts/silicon-valley-season-3/",
      "title": "silicon valley season 3",
      "summary": "A stupidly accurate, satirical reflection of working in modern IT.",
      "content_html": "\u003cp\u003eSince having a friend point out the HBO original \u003ca href=\"http://www.hbo.com/silicon-valley\"\u003eSilicon Valley\u003c/a\u003e I've been hooked. Such a brilliant TV show, and despite certain attitudes and characters being overdramatised it's a stupidly accurate (albeit satirical) reflection of working in modern IT… at least in most places.\u003c/p\u003e\n\u003cp\u003eI won't recap on the whole thing, but what I will say is I'm actually, for the first time in a long time, very excited for a TV show to bring back a new season. \u003cem\u003e\u003cstrong\u003eIt premiers on HBO on the 24th\u003c/strong\u003e\u003c/em\u003e.\u003c/p\u003e\n\u003cp\u003eIf you're looking for a quirky version of the IT Crowd, or you just work in IT I recommend you take a watch. It's free to recap on HBO right now, assuming you're in region. Assuming you're not, you'll discover that you get a particularly inelegant CloudFront geo-blocking error when you click the \u003ca href=\"http://watch.hbo.com/#id=silicon-valley\"\u003ewatch now for free\u003c/a\u003e link.\u003c/p\u003e\n",
      "date_published": "2016-04-09T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-silicon-valley-season-3-af2452c9a40b.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 1592380
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/igmpqd/",
      "url": "https://blog.i0.pm/posts/igmpqd/",
      "title": "igmpqd",
      "summary": "A small, standalone IGMPv2 querier, written because the existing options didn't fit.",
      "content_html": "\u003cp\u003eI 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're either part of a much larger suite (like \u003ca href=\"http://www.xorp.org/\"\u003eXORP\u003c/a\u003e, a fantastic OSS routing platform), or generally unsuitable to run as a system service.\u003c/p\u003e\n\u003cp\u003eThe IGMPv2 Query message spec is actually very simple as indicated in \u003ca href=\"https://tools.ietf.org/html/rfc2236#section-2\"\u003eRFC2236\u003c/a\u003e so I thought, what the heck, I might as well have a stab myself.\u003c/p\u003e\n\u003cp\u003eAfter a few hours I had the basic thing together and principally tested. Given that I can't be the only one in need of such a tool I figured I'd also publish it somewhere for other people to use.\u003c/p\u003e\n\u003cp\u003eTo that end, the igmpqd project is now live on \u003ca href=\"https://github.com/jmylchreest/igmpqd\"\u003egithub\u003c/a\u003e. 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.\u003c/p\u003e\n\u003ch3 id=\"using-igmpqd\"\u003eUsing IGMPQD\u003c/h3\u003e\n\u003cp\u003eUsing IGMPQD is simple. Download a binary that matches your platform from the github releases page and run it with \u003ccode\u003e./igmpqd run\u003c/code\u003e. By default, it'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.\u003c/p\u003e\n\u003cp\u003eIf you want to tune any of those options, check out \u003ccode\u003e./igmpqd run --help\u003c/code\u003e or the github page for more information.\u003c/p\u003e\n",
      "date_published": "2016-04-07T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-igmpqd-ddea8dbc6920.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2774786
        }
      ]
    },
    {
      "id": "https://blog.i0.pm/posts/back-on-the-grid/",
      "url": "https://blog.i0.pm/posts/back-on-the-grid/",
      "title": "back on the grid",
      "summary": "Yet another IT geek promising opinionated and likely pointless public posts.",
      "content_html": "\u003cp\u003eI 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's missing something.\u003c/p\u003e\n\u003cp\u003eThat'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't be quite as bad as that.\u003c/p\u003e\n\u003ch2 id=\"enough-what-should-i-expect\"\u003eEnough. What should I expect?\u003c/h2\u003e\n\u003cp\u003eWell, I'm glad you asked. What? You didn't? Well let me tell you anyway.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eBadly written CSS.\u003c/li\u003e\n\u003cli\u003eNews (related to my own activities) regarding any of the public projects I may be working on.\u003c/li\u003e\n\u003cli\u003eGuides on specific tuning/best practices as and when I have a need to do something that relates.\u003c/li\u003e\n\u003cli\u003eResolutions to problems I face, but hopefully only the complicated ones. Hopefully they might help you, and if not, they'll help me when I forget!\u003c/li\u003e\n\u003cli\u003eAnything that crops up in the news that \u003cem\u003ereally\u003c/em\u003e irks or excites me.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eSo… lets see if I can get it started keep it up.\u003c/p\u003e\n",
      "date_published": "2016-04-02T00:00:00Z",
      "attachments": [
        {
          "url": "https://pub-d5c2f354a50f4560977c04c2f7d1b93a.r2.dev/assets/generated/posts-back-on-the-grid-b0b5b130e471.wav",
          "mime_type": "audio/wav",
          "size_in_bytes": 2311642
        }
      ]
    }
  ]
}
