<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
    <title>legolas.dev</title>
    <subtitle>Writing, technical notes, and experiments.</subtitle>
    <link rel="self" type="application/atom+xml" href="https://legolas.dev/atom.xml"/>
    <link rel="alternate" type="text/html" href="https://legolas.dev"/>
    <generator uri="https://www.getzola.org/">Zola</generator>
    <updated>2026-07-26T00:00:00+00:00</updated>
    <id>https://legolas.dev/atom.xml</id>
    <entry xml:lang="en">
        <title>I wanted agent state in Zellij, not another tab I forget about</title>
        <published>2026-07-26T00:00:00+00:00</published>
        <updated>2026-07-26T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/zj-agents-semantic-state/"/>
        <id>https://legolas.dev/writing/zj-agents-semantic-state/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/zj-agents-semantic-state/">&lt;p&gt;I run coding agents inside Zellij under Ghostty. Claude, Codex, Grok, Pi, whatever is useful that week. Several tabs. A few floating panes. The agent that needs a permission prompt never feels urgent until I happen to look at the right surface.&lt;&#x2F;p&gt;
&lt;p&gt;You know the loop. Switch tab. Read the bottom of a scrollback. Switch again. Miss the one that finished twenty minutes ago. Desktop is quiet because nothing ever told it the state changed. The multiplexer is great at panes. It is not great at &quot;which agent is stuck on allow?&quot;&lt;&#x2F;p&gt;
&lt;p&gt;I did not want another orchestration layer. I did not want a fork of Zellij just for a status rail. Stock 0.44.3+ was the bar. Two WASM plugins and a pure Rust core felt like the right size.&lt;&#x2F;p&gt;
&lt;p&gt;The product itch came from &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;herdr.dev&#x2F;&quot;&gt;Herdr&lt;&#x2F;a&gt; and its agent panes: real terminals, agent state rolled up so you can jump to blocked work instead of polling every surface by hand. Herdr is an agent multiplexer. I live in Zellij already. The design question was how much of that agent-pane idea can sit &lt;em&gt;on&lt;&#x2F;em&gt; stock Zellij as plugins, without replacing the multiplexer.&lt;&#x2F;p&gt;
&lt;p&gt;So I shipped &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;zj-agents&quot;&gt;zj-agents&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-is-for&quot;&gt;What it is for&lt;&#x2F;h2&gt;
&lt;p&gt;zj-agents is semantic state for coding-agent panes on unmodified Zellij. A background &lt;strong&gt;engine&lt;&#x2F;strong&gt; discovers panes that look like known agents, classifies what the viewport is saying, and pushes a small snapshot. A &lt;strong&gt;sidebar&lt;&#x2F;strong&gt; renders that list, focuses a pane when you hit Enter, and stays hideable so it is not another permanent column.&lt;&#x2F;p&gt;
&lt;p&gt;States matter more than pretty glyphs. Unknown. Idle. Working. Blocked. Done. Blocked means a real rule matched something like a permission prompt, not a random fallback. Done is derived: two successful Idle observations after Working while the pane is unfocused. Focus clears sticky Done without a notification spam loop. That table lives in pure code so tests can hammer it without a host.&lt;&#x2F;p&gt;
&lt;p&gt;Desktop notifications exist, but only on new Blocked and Done transitions when the pane is not focused. Labels go through a sanitizer first. Hostile titles and markup do not get to train your notification daemon.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-problem-that-actually-closes&quot;&gt;What problem that actually closes&lt;&#x2F;h2&gt;
&lt;p&gt;The desk problem is attention, not another chat UI. I already have agents. I already have panes. I needed a shared, boring truth:&lt;&#x2F;p&gt;
&lt;ul&gt;
&lt;li&gt;which pane is an agent&lt;&#x2F;li&gt;
&lt;li&gt;what state it is in&lt;&#x2F;li&gt;
&lt;li&gt;whether I should look now&lt;&#x2F;li&gt;
&lt;li&gt;without pasting full screen contents into a second process that logs everything&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;p&gt;The engine is the only thing allowed to read viewports. The sidebar only sees sanitized metadata and derived state over a versioned pipe (&lt;code&gt;hello&lt;&#x2F;code&gt;, &lt;code&gt;snapshot&lt;&#x2F;code&gt;, &lt;code&gt;reload&lt;&#x2F;code&gt;). No bye, no ack theater. Privacy was a product requirement, not a footnote.&lt;&#x2F;p&gt;
&lt;p&gt;Install is the usual Zellij story. No package manager. Release WASM, named aliases in config, &lt;code&gt;load_plugins&lt;&#x2F;code&gt; for the engine. v0.1.0 is up with both artifacts and checksums. From the repo: &lt;code&gt;nu scripts&#x2F;install.nu --from-release&lt;&#x2F;code&gt;, or curl the assets into &lt;code&gt;~&#x2F;.config&#x2F;zellij&#x2F;plugins&#x2F;&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-it-came-from&quot;&gt;Where it came from&lt;&#x2F;h2&gt;
&lt;p&gt;Primary inspiration: &lt;strong&gt;Herdr&#x27;s agent pane model&lt;&#x2F;strong&gt;. Semantic state on real PTYs (blocked, working, done, idle), see the herd at a glance, jump to what needs you. Herdr owns that stack as a multiplexer. I wanted the awareness layer without leaving Zellij.&lt;&#x2F;p&gt;
&lt;p&gt;On the Zellij shelf I also checked &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;zellij-org&#x2F;awesome-zellij&quot;&gt;awesome-zellij&lt;&#x2F;a&gt;. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;marktoda&#x2F;zj-radar&quot;&gt;zj-radar&lt;&#x2F;a&gt; is the closest cousin (sidebar of agent states; more push&#x2F;pipe). &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ishefi&#x2F;zellaude&quot;&gt;zellaude&lt;&#x2F;a&gt; goes deep on Claude in the status bar. &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;KiryuuLight&#x2F;zellij-attention&quot;&gt;zellij-attention&lt;&#x2F;a&gt; puts icons on tab names from hooks. Different shapes.&lt;&#x2F;p&gt;
&lt;p&gt;So the pitch is not &quot;nobody did agent awareness.&quot; It is Herdr-shaped state on stock Zellij: fixture-backed manifests, engine plus hideable sidebar, no fork, no required hooks. Listing PR on awesome-zellij for that reason.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-got-built&quot;&gt;How it got built&lt;&#x2F;h2&gt;
&lt;p&gt;This one did not start as a blank weekend doodle. There was a design pass and a long implementation plan (semantic state, inventory bootstrap, atomic manifest reload, notification coalescing). Codex work handed off into a Grok-driven session under subagent-driven development: task, implement, review, fix until the bar held.&lt;&#x2F;p&gt;
&lt;p&gt;The same desk rules as other recent ships. No green CI that still ships stubs. No production warning suppressions as a lifestyle. Fixtures for Claude, Codex, Grok, Pi, and OMP before claiming detection. Patterns come from redacted captures, not README poetry. That slowed the agent list. It also kept OpenCode and friends out until someone brings real viewports.&lt;&#x2F;p&gt;
&lt;p&gt;Upstream style hygiene followed the same pattern as Keylore and tessera: dual license, Dependabot, verify on Ubuntu and macOS, release on &lt;code&gt;v*&lt;&#x2F;code&gt; tags with git-cliff notes, branch rules that force linear history. Actions burned private minutes until the repo went public. Then the release job actually produced the WASM files the README pretends exist.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-is-done-what-is-not&quot;&gt;What is done, what is not&lt;&#x2F;h2&gt;
&lt;p&gt;Done: core state machine and protocol, five bundled agents with fixture gates, engine session model, sidebar, install from release, public repo, v0.1.0 assets.&lt;&#x2F;p&gt;
&lt;p&gt;Not done: I am not claiming a full interactive smoke matrix on every host. Zellij still needs permission grants once. Classification is only as good as the fixtures and rules. If your agent UI changes, manifests change. That is the product.&lt;&#x2F;p&gt;
&lt;p&gt;If you live in multi-agent Zellij sessions and keep missing the pane that is waiting on you, that is the itch. Clone or curl. Grant the batches. Open the sidebar. See if the desk gets quieter.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;zj-agents&quot;&gt;kaankoken&#x2F;zj-agents&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;zj-agents&#x2F;releases&#x2F;tag&#x2F;v0.1.0&quot;&gt;Release v0.1.0&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;herdr.dev&#x2F;&quot;&gt;Herdr&lt;&#x2F;a&gt; (agent pane &#x2F; agent multiplexer inspiration)&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;zellij-org&#x2F;awesome-zellij&#x2F;pull&#x2F;68&quot;&gt;awesome-zellij listing PR&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;marktoda&#x2F;zj-radar&quot;&gt;zj-radar&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;ishefi&#x2F;zellaude&quot;&gt;zellaude&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;KiryuuLight&#x2F;zellij-attention&quot;&gt;zellij-attention&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>How Keylore got planned on a twenty dollar day and shipped with Grok</title>
        <published>2026-07-23T00:00:00+00:00</published>
        <updated>2026-07-23T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/keylore-built-with-agents/"/>
        <id>https://legolas.dev/writing/keylore-built-with-agents/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/keylore-built-with-agents/">&lt;p&gt;Planning Keylore ate almost a full day of a $20 package. Implementation with Grok 4.5 moved so much faster that I felt slightly scammed by the planning bill and grateful for the implement pass in the same hour. This post is that day, stretched into something I can still stand to read.&lt;&#x2F;p&gt;
&lt;p&gt;If you want the short why, the &lt;a href=&quot;&#x2F;writing&#x2F;keylore-macos-config-manager&#x2F;&quot;&gt;entry post&lt;&#x2F;a&gt; is enough. Here we stay on how the work ran.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-loop-without-the-costume&quot;&gt;The loop, without the costume&lt;&#x2F;h2&gt;
&lt;p&gt;I did not open a chat and say &quot;build me a config manager&quot; once. That path has bitten me before. Thin demos. Half-wired features. A README that lies about status.&lt;&#x2F;p&gt;
&lt;p&gt;The loop looked like this:&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;writing&#x2F;keylore-agent-loop.svg&quot; alt=&quot;How the Keylore work actually ran: spec and plan, reiterate until the goal holds, then SDD with parallel agents.&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Brainstorm and spec first. Write a plan second. When the plan wobbled, go back. Reiterate until the goal held. Only then subagent-driven development with parallel agents on the chunks, all under the same goal. Ship open source when the desk could use the app.&lt;&#x2F;p&gt;
&lt;p&gt;That is the whole ritual. No secret second process.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;who-did-which-half&quot;&gt;Who did which half&lt;&#x2F;h2&gt;
&lt;p&gt;Spec and planning ran on GPT 5.6 under a sol ultra setup. Long briefs. Boring acceptance notes. What local-first meant. What a kit was allowed to be. What v1 apps were in scope. What &quot;consent&quot; had to mean when a template wanted onto disk.&lt;&#x2F;p&gt;
&lt;p&gt;That half was slow on purpose. I kept pushing on holes: execution in kits, silent overwrites, fake multi-app support, release claims I could not keep. The meter on a $20 package does not care about your pride. It still runs down while you argue with a model about whether a template may ship a binary.&lt;&#x2F;p&gt;
&lt;p&gt;Implementation ran on Grok 4.5. Shoutout to the xAI team. Once the plan stopped falling over, the implement pass was the fun part. Milestone after milestone. Forms. Discovery. Kit consent paths. Shortcut views. Tests that fail when a dirty form would clobber a deploy. Template engine pieces and app pieces got separate attention so one agent was not inventing both a kit format and a window layout in the same breath.&lt;&#x2F;p&gt;
&lt;p&gt;I stayed in the loop for taste and for the dumb breaks. Green CI is a liar if you never open the window. Screenshots. Click the wrong mode. Edit AeroSpace, flip to Ghostty, come back, see if the buffer still makes sense. Save with dirty state. Cancel a load mid-flight. Agents propose. I try to break it. Fail again. Repeat.&lt;&#x2F;p&gt;
&lt;p&gt;Some days that meant three rounds on the same form adapter because the happy path looked fine and the second open of the same file did not. That is still cheaper than finding it after a tag.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-done-meant-on-a-chunk&quot;&gt;What &quot;done&quot; meant on a chunk&lt;&#x2F;h2&gt;
&lt;p&gt;I kept a short quality list in ordinary language, the same spirit as the global bar on the diagram.&lt;&#x2F;p&gt;
&lt;p&gt;No red tests. No warning piles I planned to &quot;clean later.&quot; No production stubs left as the real work. No TODO that was actually the feature. Use the skills that match the job. Prefer current tooling when I touch deps. Do not trust a stale local cache that says the world is fine.&lt;&#x2F;p&gt;
&lt;p&gt;That is not a manifesto. It is how you keep a multi-day agent run from turning into a junk drawer. When a chunk failed review, it went back. When the plan was wrong, the plan reopened. Spec was allowed to change when reality punched it. The diagram&#x27;s reiterate arrow is not decoration.&lt;&#x2F;p&gt;
&lt;p&gt;Parallel agents helped on independent pieces. Shared state still needed a human with opinions. Two agents inventing two different &quot;source of truth&quot; stories for the same config is a special kind of headache. The goal text stayed shared so they were not solving different products by accident.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-the-money-went&quot;&gt;Where the money went&lt;&#x2F;h2&gt;
&lt;p&gt;I will say the quiet part. Planning was expensive relative to typing. A day of back-and-forth on a $20 package feels silly until you remember the alternative: a week of coding the wrong architecture with full confidence.&lt;&#x2F;p&gt;
&lt;p&gt;The package did not care which half was &quot;creative.&quot; It billed the long arguments about consent and adapters the same as a short &quot;rename this type&quot; pass. I watched the budget go mostly into the former. That is fine. That is where the product got opinionated.&lt;&#x2F;p&gt;
&lt;p&gt;Implement was cheap by comparison. Grok 4.5 chewed through milestones that would have been me copy-pasting for evenings. I still had to read the diffs. I still threw some away. Speed without reading is how you open-source a haunted house.&lt;&#x2F;p&gt;
&lt;p&gt;If you run a similar loop, budget the boring half. Spec is where you decide kits cannot execute. Spec is where you admit v1 is four apps on Apple Silicon. Spec is where you stop promising a DMG you cannot build this week. Writing those sentences once saved me from &quot;just ship a zip of scripts&quot; at 2am.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;release-reality-said-out-loud&quot;&gt;Release reality, said out loud&lt;&#x2F;h2&gt;
&lt;p&gt;The repo is public: &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;keylore&quot;&gt;kaankoken&#x2F;keylore&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;I burned through GitHub Actions minutes on other projects like a responsible adult with a hobby and a timer. So the CI-built, signed DMG story is incomplete. The code is there. The Actions dashboard is crickets. Give me about ten days and I will put a proper build up. Until then, clone it and build on an Apple Silicon Mac if you are impatient.&lt;&#x2F;p&gt;
&lt;p&gt;I am also half looking for a good reason to stop treating GitHub as the only place builds can happen. Hosting the machines myself is on the list. Homelab energy. Not this week. The minutes first have to regenerate like a sad little meter.&lt;&#x2F;p&gt;
&lt;p&gt;That is not a brand story. It is ops. Open source without a pretty installer is still open source. Lying about the installer is just lying.&lt;&#x2F;p&gt;
&lt;p&gt;When the minutes come back I will wire the verify path and the signed release story the README already describes. Homebrew cask, notarized tags, the usual adult packaging. Until then the honest install docs are: clone, open the workspace, build Release, run it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-would-repeat&quot;&gt;What I would repeat&lt;&#x2F;h2&gt;
&lt;p&gt;Same loop on the next tool-shaped itch. Spec until it hurts. Plan until review stops finding new holes. Implement under a hard bar. Keep the human for the desk test.&lt;&#x2F;p&gt;
&lt;p&gt;I would also keep the split that worked here: a strong planner for the long argument, a fast implementer for the milestones, and me for &quot;does this still feel like the app I wanted at 1am.&quot; Different models for different halves is allowed. Pretending one chat session is a full engineering org is how you get a demo and a hangover.&lt;&#x2F;p&gt;
&lt;p&gt;Keylore exists because I was tired of blind config edits. The agents did not invent that pain. They helped close it under a loop I could audit. If the product post is the desk, this post is the workshop floor: sawdust, rework, and a meter that hit zero at an inconvenient time.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;series&quot;&gt;Series&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;writing&#x2F;keylore-macos-config-manager&#x2F;&quot;&gt;I got bored of editing configs blind, so I shipped a macOS app&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;This post&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;keylore&quot;&gt;kaankoken&#x2F;keylore&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>What Keylore actually does to your configs</title>
        <published>2026-07-22T00:00:00+00:00</published>
        <updated>2026-07-22T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/keylore-what-it-actually-does/"/>
        <id>https://legolas.dev/writing/keylore-what-it-actually-does/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/keylore-what-it-actually-does/">&lt;p&gt;&lt;a href=&quot;&#x2F;writing&#x2F;keylore-macos-config-manager&#x2F;&quot;&gt;Last time&lt;&#x2F;a&gt; I said I got bored of editing configs blind and shipped a macOS app for it. This post is the product tour. What Keylore does when you point it at a desk full of tool configs, and why the boring safety choices are the point.&lt;&#x2F;p&gt;
&lt;p&gt;I still open LazyGit, Ghostty, AeroSpace, and Zellij every week. Each one keeps its own file shape. TOML over here. YAML over there. KDL if you are in Zellij land. Ghostty still likes its own line format. None of that is wrong. It just means I spent years grepping docs for keys I had already half-remembered.&lt;&#x2F;p&gt;
&lt;p&gt;Keylore is the app I wanted on that desk. Local-first. No account. No &quot;sync your dots to our cloud&quot; pitch. Point it at a workspace or a repo layout, and it tries to find the configuration files that already live there.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;finding-the-mess&quot;&gt;Finding the mess&lt;&#x2F;h2&gt;
&lt;p&gt;Discovery is the first job. You pick a root. Keylore walks the layout it understands and surfaces the apps it can talk to. In v1 that means the four I actually live in: LazyGit, Ghostty, AeroSpace, Zellij.&lt;&#x2F;p&gt;
&lt;p&gt;On my machine that root is usually a dotfiles-style tree, the same place I keep everything else under version control. Keylore is not hunting your entire home directory for fun. It is looking for known layouts and known files. When it finds one, you pick the app in the sidebar and go to work.&lt;&#x2F;p&gt;
&lt;p&gt;If a tool is not there, it is not there. I did not invent a universal plugin marketplace on day one. The list is short on purpose. I would rather ship four honest adapters than a fake &quot;supports everything&quot; page. Starship and Atuin have kit sketches in the repo tree for later; they are not the v1 promise on the README. Weekly tools first.&lt;&#x2F;p&gt;
&lt;p&gt;Apple Silicon only. macOS 14+. Same limits as the README. That is the machine I build on. If you are on Intel, this is not your app yet, and I am not going to soft-pedal that.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;editing-without-pretending&quot;&gt;Editing without pretending&lt;&#x2F;h2&gt;
&lt;p&gt;Once an app is selected you can open its config. The files stay in their native formats. Keylore is not rewriting your Ghostty config into some private JSON of the week and hoping you never open the file again.&lt;&#x2F;p&gt;
&lt;p&gt;There is a text path with previews so a save is less of a coin flip. Syntax highlighting is enough to stop me from merging two KDL blocks with my face. There is also a structured path for options the kits and catalogs know about. Toggles, fields, rows for keybinds when the catalog has them. The goal is simple: see the option space on screen instead of hunting the same man page for the third time this month.&lt;&#x2F;p&gt;
&lt;p&gt;Some apps are happier in the form. Some still want the raw file. Both paths exist because I use both on the same afternoon.&lt;&#x2F;p&gt;
&lt;p&gt;I care about dirty state more than I should. If you changed a form and something else wants to overwrite the disk, the app should make you deal with that. Silent clobber is how people lose a half-hour of tweaks. The tests in the repo are full of that class of boring check. Form save writes. Dirty form blocks a reckless deploy. Stale edit sessions get rejected. Unsexy. Correct.&lt;&#x2F;p&gt;
&lt;p&gt;External edits still happen. You will open the file in another editor. The world is messy. Keylore has to live with that instead of pretending it owns the only process on the machine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;template-kits-with-a-hard-stop&quot;&gt;Template kits, with a hard stop&lt;&#x2F;h2&gt;
&lt;p&gt;Kits are optional. That sentence does a lot of work.&lt;&#x2F;p&gt;
&lt;p&gt;A kit is data. Catalogs of options. Default config fragments. Declarative payloads you can inspect. You opt in before anything lands. Kits do not ship executables. They do not run code on your machine. If a kit wants to &quot;help&quot; by running a script, it does not belong in this design.&lt;&#x2F;p&gt;
&lt;p&gt;I have installed enough &quot;helpful&quot; packages that quietly grew hands. Dotfile managers that shell out mid-install. Theme packs that assume your PATH. I wanted consent that means something. You see what is offered. You say yes. Then the data moves. If you never say yes, nothing installs.&lt;&#x2F;p&gt;
&lt;p&gt;Signed and declarative is the bar for the kit path. Local-first still holds. The kit is not a remote code delivery channel with a friendly name. If that sounds strict, good. Config managers that get clever with execution are how you wake up with a shell you do not recognize.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;shortcuts-you-can-actually-see&quot;&gt;Shortcuts you can actually see&lt;&#x2F;h2&gt;
&lt;p&gt;Most of these tools have defaults. Then you override three of them and forget the rest exist.&lt;&#x2F;p&gt;
&lt;p&gt;Keylore has a shortcuts and defaults view for that. Effective keybindings from the official defaults plus your overrides, in one place. If a tool has a key you never set, you can still see what it does out of the box. That is half the reason I built the app. I was tired of &quot;is this bound or am I imagining it&quot; at 1am.&lt;&#x2F;p&gt;
&lt;p&gt;It is not a remapper for every app on macOS. It is the keybind story for the tools Keylore already understands. Scope stays small. The list stays honest.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-v1-is-not&quot;&gt;What v1 is not&lt;&#x2F;h2&gt;
&lt;p&gt;v1 is not a full Nix-shaped declaration of your whole life. It is not a cross-platform GUI. It is not &quot;bring any random TOML and we magic a form.&quot; Adapters matter. Catalogs matter. Ghostty&#x27;s line format is not LazyGit&#x27;s YAML, and the code knows that.&lt;&#x2F;p&gt;
&lt;p&gt;It is also not a finished release machine story. The code is public. Clone it. Build the Keylore scheme on an Apple Silicon Mac with a current Xcode. I already wrote about the Actions minutes I burned elsewhere. A pretty DMG from CI is still on the list. Source is the truth today.&lt;&#x2F;p&gt;
&lt;p&gt;If you want brew or a signed download later, that is the README path once the pipeline is awake again. Until then I would rather you have the repo than a broken &quot;download now&quot; button.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-shape-i-keep-coming-back-to&quot;&gt;The shape I keep coming back to&lt;&#x2F;h2&gt;
&lt;p&gt;Discover what is on disk. Edit it in the format the tool already uses. Offer structure when the catalog knows the option. Install kit data only with consent. Show defaults and overrides for shortcuts. Stop before the app starts executing strangers&#x27; code.&lt;&#x2F;p&gt;
&lt;p&gt;That is the whole product pitch without the marketing coat of paint. If you only read this post and never the entry one, you should still know what you are installing: a native Mac app for a small set of developer configs, local-first, kit-shy on purpose.&lt;&#x2F;p&gt;
&lt;p&gt;More tools can land later. More adapters. More catalog depth. I will ship those when I use them weekly, not when a feature matrix looks lonely.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;series-and-where-to-go&quot;&gt;Series and where to go&lt;&#x2F;h2&gt;
&lt;p&gt;Day 1 was the why: &lt;a href=&quot;&#x2F;writing&#x2F;keylore-macos-config-manager&#x2F;&quot;&gt;I got bored of editing configs blind, so I shipped a macOS app&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Day 3 is the how: planning with GPT, implementing with Grok 4.5, the reiterate loop, and the release mess. If you care more about the agent day than the option inspector, skip ahead when that post is up.&lt;&#x2F;p&gt;
&lt;p&gt;Repo lives here either way: &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;keylore&quot;&gt;kaankoken&#x2F;keylore&lt;&#x2F;a&gt;. Open the workspace. Build Release. Point it at a real config root and try to break the boring paths. That is more useful feedback than a star on a README I wrote at 11pm.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;keylore&quot;&gt;kaankoken&#x2F;keylore&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a href=&quot;&#x2F;writing&#x2F;keylore-macos-config-manager&#x2F;&quot;&gt;entry post&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>I got bored of editing configs blind, so I shipped a macOS app</title>
        <published>2026-07-21T00:00:00+00:00</published>
        <updated>2026-07-21T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/keylore-macos-config-manager/"/>
        <id>https://legolas.dev/writing/keylore-macos-config-manager/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/keylore-macos-config-manager/">&lt;p&gt;I got tired of opening the same config files and still not knowing what options I had. LazyGit keybinds. Ghostty settings. AeroSpace window rules. Zellij layouts. Half the time I was grepping the docs or someone else&#x27;s dotfiles just to flip one thing I should have been able to see on the desk.&lt;&#x2F;p&gt;
&lt;p&gt;You know the loop. Open the file. Guess which key is real. Search the upstream README. Find three examples that disagree. Change one line. Restart the app. Wonder if you hit a default, an override, or nothing at all. I live in those tools all day. Blind edits got old.&lt;&#x2F;p&gt;
&lt;p&gt;The editor is fine for the text. Bad at the &quot;what can this app even do&quot; part of the job. I wanted a real macOS app on the desk: find the files, edit them in the formats they already use, show me the option space, and only install a template when I say yes.&lt;&#x2F;p&gt;
&lt;p&gt;So I shipped Keylore.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;writing&#x2F;keylore-hero.png&quot; alt=&quot;Keylore hero&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;Keylore is a native macOS app for developer app configs. Local-first. Point it at a workspace or a repo layout and it finds the configuration files sitting there. You edit in the formats those tools already speak: TOML, YAML, KDL, and the line formats some of them still use. Previews are there so a save is less of a guess.&lt;&#x2F;p&gt;
&lt;p&gt;Template kits are optional. You opt in. They are data only: signed, declarative payloads. Kits do not ship executables, and they do not run code on your machine. That consent line mattered. I have installed enough &quot;helpful&quot; packages that quietly grew hands.&lt;&#x2F;p&gt;
&lt;p&gt;There is also a shortcuts and defaults view. Effective keybindings from the official defaults plus your overrides, in one place, instead of bouncing between man pages and three config layers. If a tool has a key you never set, you can still see what it does out of the box.&lt;&#x2F;p&gt;
&lt;p&gt;v1 supports LazyGit, Ghostty, AeroSpace, and Zellij. Apple Silicon only. macOS 14+. That is the desk I live on, so that is what shipped first. Those four are the ones I open every week.&lt;&#x2F;p&gt;
&lt;p&gt;How it got built is a short story for this post. I planned the whole thing with GPT 5.6 on a sol ultra setup. Spec and plan ate almost a full day of a $20 package, with a reiterate loop until the goal held. Implementation ran on Grok 4.5 and moved a lot faster than the planning did. Spec first, plan next, push back until it stopped falling over, then subagent-driven development with parallel agents on the chunks. Shoutout to xAI for Grok 4.5; the implement pass was the fun part. Day 3 of this series goes into that loop for real. This one stays on why the app exists.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;writing&#x2F;keylore-agent-loop.svg&quot; alt=&quot;How the Keylore work actually ran: spec and plan, reiterate until the goal holds, then SDD with parallel agents.&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;There is a product post next: discover, edit, kits, shortcuts, and how the safety model actually works. After that, the process post: who planned, who implemented, how the loop felt day to day, and the release mess I am still cleaning up. Three days, same thread.&lt;&#x2F;p&gt;
&lt;p&gt;And yes, about that release mess. I burned through my GitHub Actions minutes on other projects like a responsible adult with a hobby and a timer. So today there is no CI-built DMG for you to click. The code is open. The signed binary pipeline is me looking at the Actions dashboard and hearing crickets. Give me about ten days and I will put a proper build up. Until then, clone it and build on an Apple Silicon Mac if you are impatient, or wait with the rest of us.&lt;&#x2F;p&gt;
&lt;p&gt;I am also half looking for a good reason to stop treating GitHub as the only place builds can happen. Hosting the machines myself is on the list. Homelab energy. Not this week. The minutes first have to regenerate like a sad little meter.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;keylore&quot;&gt;kaankoken&#x2F;keylore&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>I forked Zellij so my terminal could show images again</title>
        <published>2026-07-18T00:00:00+00:00</published>
        <updated>2026-07-18T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/zellij-images-kitty-graphics/"/>
        <id>https://legolas.dev/writing/zellij-images-kitty-graphics/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/zellij-images-kitty-graphics/">&lt;p&gt;I live in Zellij under Ghostty. Yazi, Codex, Grok... half the tools I use every day want to put an image in the pane. On a normal terminal that works. Inside Zellij it was just empty space.&lt;&#x2F;p&gt;
&lt;p&gt;Outside the multiplexer those apps draw fine. Inside it they speak the Kitty Graphics Protocol over APC, and the &lt;code&gt;vte&lt;&#x2F;code&gt; path Zellij uses throws APC away before the grid ever sees it. Zellij already had Sixel. It did not have this path. Agents sit in my panes for hours. I was done opening a raw window every time something needed a picture.&lt;&#x2F;p&gt;
&lt;p&gt;So I forked it.&lt;&#x2F;p&gt;
&lt;p&gt;I could have pasted a thin passthrough and hoped. I have paid for that class of fix before. This time I wanted something that still worked after resize, multi-client attach, and the dumb edge cases those tools actually hit. That meant treating it like real Rust work, and running the agents under a loop I could trust.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-i-ran-the-agents&quot;&gt;How I ran the agents&lt;&#x2F;h2&gt;
&lt;p&gt;Ultracode on. Superpowers brainstorm with a long, boring brief: what KGP does on the wire, how Sixel already works in this codebase, Ghostty as the host, and the acceptance desk (Yazi previews, Codex pets, Grok images). Then Fable-5 at max effort on the design. If the review found holes, back into brainstorming. When the design held, write-plan. Same Fable gate on the plan. Issues reopen the plan. Only after that did subagent-driven development start, and every milestone got the same review until it passed.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;img src=&quot;&#x2F;writing&#x2F;zellij-kgp-agent-loop.svg&quot; alt=&quot;How the work actually ran: brainstorm and plan under Fable-5, then milestones under the same gate, with a global bar the whole way.&quot; &#x2F;&gt;&lt;&#x2F;p&gt;
&lt;p&gt;While that loop ran I kept the same rules in the brief every time: no errors, no warnings, no red tests. No warning suppressions in production code (test-only &lt;code&gt;allow&lt;&#x2F;code&gt; with a reason is fine). No stubs, no TODO left as the real work. Use the skills that match the job. Latest deps when I touch crates and workflows. Do not trust a stale local tool-version cache.&lt;&#x2F;p&gt;
&lt;p&gt;That is the agent story. It only mattered because the desk needed a real multiplexer change, not a demo.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-had-to-be-true-in-the-code&quot;&gt;What had to be true in the code&lt;&#x2F;h2&gt;
&lt;p&gt;Ghostty still draws the pixels. Zellij is a bad place to become a graphics engine. The job is capture, bookkeeping, and re-emit.&lt;&#x2F;p&gt;
&lt;p&gt;Catch &lt;code&gt;ESC _ G … ST&lt;&#x2F;code&gt; in front of &lt;code&gt;vte&lt;&#x2F;code&gt; so the stream is not dropped. Store image bytes and placement metadata under ids Zellij owns (apps use their own ids; rewrite once on the way in). Put Unicode placeholder cells in the grid (U+10EEEE plus the diacritics the protocol uses) so scroll, resize, and occlusion stay honest. Re-emit the right sequences per attached client. If a client cannot do placeholders, stay dormant. Quiet beats a pane full of broken characters.&lt;&#x2F;p&gt;
&lt;p&gt;Early review passes killed the cute shortcuts. Id schemes that fall over on quiet transmits. Probes that lie about capability. Inventing a reverse channel when &lt;code&gt;host_query&lt;&#x2F;code&gt; already exists. Direct placements still get turned into placeholders under the hood so the grid stays the source of truth.&lt;&#x2F;p&gt;
&lt;p&gt;Then the milestones ran under that plan. Same bar. Same review. CI was green while a pet still sat on the wrong cells, so the human half stayed in the loop: screenshots, a &lt;code&gt;kgp-log&lt;&#x2F;code&gt; trail, resize, detach a second client, scroll, try again. Pets fighting the grid. Backgrounds wrong after a grid tweak. Capability checks that made an app decide KGP was dead forever. Yazi zero-extent placements that looked fine until you noticed the preview never painted. Agent proposes the next fix. I attach and try to break it. Fail again. Repeat until the daily tools stop lying.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-works-now&quot;&gt;What works now&lt;&#x2F;h2&gt;
&lt;p&gt;Public fork on main: &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;zellij&quot;&gt;kaankoken&#x2F;zellij&lt;&#x2F;a&gt;. On Ghostty, Yazi previews, Codex pets, and Grok images show up inside the session I already live in. I am not jumping to a second terminal for pictures anymore.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;upstream-already-said-something-useful&quot;&gt;Upstream already said something useful&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;zellij-org&#x2F;zellij&#x2F;pull&#x2F;4851&quot;&gt;PR 4851&lt;&#x2F;a&gt; was a Kitty-only passthrough. Maintainer imsnif closed the conversation on 2026-03-14 with a clear long-term preference: &lt;strong&gt;generic, protocol-agnostic passthrough&lt;&#x2F;strong&gt;, so Zellij does not grow a special case for every graphics dialect that shows up next year. Fair call for the project.&lt;&#x2F;p&gt;
&lt;p&gt;My fork is deeper KGP bookkeeping on purpose: placeholders, zellij-owned ids, local answers for app queries from a host capability probe. It keeps the desk honest today. It is also a protocol-specific shape, which is the maintenance cost he wants to keep out of tree. Both of those stand.&lt;&#x2F;p&gt;
&lt;p&gt;Next step for me is to honor that decision and aim a contribution at the generic direction he described. The fork stays the daily-drive bridge while that work exists.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;zellij&quot;&gt;kaankoken&#x2F;zellij&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;zellij-org&#x2F;zellij&#x2F;pull&#x2F;4851&quot;&gt;zellij-org&#x2F;zellij#4851&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;ghostty.org&#x2F;&quot;&gt;Ghostty&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;yazi-rs.github.io&#x2F;&quot;&gt;Yazi&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>nixup: one CLI for every machine I touch</title>
        <published>2026-07-17T00:00:00+00:00</published>
        <updated>2026-07-17T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/experiments/nixup/"/>
        <id>https://legolas.dev/experiments/nixup/</id>
        
        <content type="html" xml:base="https://legolas.dev/experiments/nixup/">&lt;p&gt;I got tired of applying a different mental model every time I sat down at a machine. Mac Mini, another Mac, a Linux box. Same tools, three slightly different stories of how they got there.&lt;&#x2F;p&gt;
&lt;p&gt;So I built &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;nixup&quot;&gt;nixup&lt;&#x2F;a&gt;: a small Rust CLI that owns day-0 and day-2 for a multi-host Nix flake. The flake is nix-darwin + home-manager on Apple Silicon, home-manager only on Linux. App configs stay in &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;.dotfiles&quot;&gt;.dotfiles&lt;&#x2F;a&gt; and land via GNU stow. This repo owns packages, shell hooks, and agent installers.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-was-trying-to-learn&quot;&gt;What I was trying to learn&lt;&#x2F;h2&gt;
&lt;p&gt;How far you can push &quot;config is the source of truth&quot; when Nix wants files on disk, pure flakes ignore gitignored paths, and half your tools still come from curl installers and casks.&lt;&#x2F;p&gt;
&lt;p&gt;Also: can a thin Rust CLI make that feel boring in a good way?&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-model&quot;&gt;The model&lt;&#x2F;h2&gt;
&lt;p&gt;&lt;code&gt;nixup.toml&lt;&#x2F;code&gt; is personal and gitignored. You copy from &lt;code&gt;nixup.toml.example&lt;&#x2F;code&gt;, list hosts, smoke checks, and defaults.&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;toml&quot;&gt;[[hosts]]
id = &amp;quot;my-mac&amp;quot;
match_hostnames = [&amp;quot;my-mac&amp;quot;]
os = &amp;quot;darwin&amp;quot;
flake_attr = &amp;quot;my-mac&amp;quot;

[[hosts]]
id = &amp;quot;my-linux&amp;quot;
match_hostnames = []
os = &amp;quot;linux&amp;quot;
flake_attr = &amp;quot;you@linux&amp;quot;
apply = &amp;quot;home-manager&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Then:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;nixup hosts sync --yes   # writes hosts&#x2F;&amp;lt;id&amp;gt;&#x2F; + hosts&#x2F;inventory.nix
nixup bootstrap --yes    # Nix if needed, apply, smoke
nixup apply --yes
nixup smoke --strict
nixup doctor
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Host modules and the inventory are generated. I do not hand-edit them as the long-term source of truth. Edit the TOML, re-sync, apply.&lt;&#x2F;p&gt;
&lt;p&gt;That collides with pure evaluation. Flakes only see tracked files. Personal &lt;code&gt;hosts&#x2F;inventory.nix&lt;&#x2F;code&gt; and &lt;code&gt;hosts&#x2F;&amp;lt;id&amp;gt;&#x2F;&lt;&#x2F;code&gt; stay local and gitignored, so pure eval falls back to committed example hosts (&lt;code&gt;my-mac&lt;&#x2F;code&gt;, &lt;code&gt;my-linux&lt;&#x2F;code&gt;) unless you force-stage your private inventory. CI always uses the example. My real machines never need to be in the public tree.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;layout&quot;&gt;Layout&lt;&#x2F;h2&gt;
&lt;p&gt;Four crates, virtual workspace, resolver 3:&lt;&#x2F;p&gt;
&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Crate&lt;&#x2F;th&gt;&lt;th&gt;Job&lt;&#x2F;th&gt;&lt;&#x2F;tr&gt;&lt;&#x2F;thead&gt;&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;nixup-core&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Config, host resolution, scaffold (render host &lt;code&gt;default.nix&lt;&#x2F;code&gt; + inventory)&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;nixup-ops&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Process runners: nix apply, stow, Determinate installer&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;nixup-smoke&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Required &#x2F; optional binary checks&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;nixup&lt;&#x2F;code&gt;&lt;&#x2F;td&gt;&lt;td&gt;Clap CLI, console, exit codes&lt;&#x2F;td&gt;&lt;&#x2F;tr&gt;
&lt;&#x2F;tbody&gt;&lt;&#x2F;table&gt;
&lt;p&gt;Shared modules under &lt;code&gt;modules&#x2F;&lt;&#x2F;code&gt;: &lt;code&gt;common&lt;&#x2F;code&gt; (modern CLIs), &lt;code&gt;shell&lt;&#x2F;code&gt; (nushell + starship binaries only), &lt;code&gt;agents&lt;&#x2F;code&gt; (fail-soft activation for rustup, headroom, claude, codex, grok, beads, radicle), plus darwin&#x2F;linux splits. Ghostty and the GUI messaging apps go through zerobrew with Homebrew as fallback. Not through &lt;code&gt;pkgs.ghostty&lt;&#x2F;code&gt;. I have dual-install scars.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;day-to-day-surface&quot;&gt;Day-to-day surface&lt;&#x2F;h2&gt;
&lt;pre&gt;&lt;code data-lang=&quot;text&quot;&gt;nixup bootstrap   # ensure Nix → apply → smoke → remind about stow
nixup apply       # switch for resolved host (switch is an alias)
nixup smoke       # --strict exits 2 if a required tool is missing
nixup doctor      # flake root, config, host map, light smoke
nixup hosts       # list; hosts sync materializes modules
nixup status      # OS, hostname, flake, nix version, resolved host
nixup update      # nix flake update
nixup stow        # optional --clone of defaults.dotfiles_url
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Hostname matching picks the host when you omit &lt;code&gt;--host&lt;&#x2F;code&gt;. Unknown host exits 4. Missing Nix on the apply path exits 3 after host resolution, so exit codes stay stable when you fat-finger an id on a machine without Nix.&lt;&#x2F;p&gt;
&lt;p&gt;The open question I&#x27;m still chewing on: how much &quot;activation script that curl | bash installs AI tools&quot; I want inside a declarative setup. Fail-soft is honest. Pure is prettier. Right now honest is winning.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>I put the Mac mini behind Cloudflare and now typing feels wet</title>
        <published>2026-07-17T00:00:00+00:00</published>
        <updated>2026-07-17T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/cloudflare-ssh-to-mac-mini/"/>
        <id>https://legolas.dev/writing/cloudflare-ssh-to-mac-mini/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/cloudflare-ssh-to-mac-mini/">&lt;p&gt;&lt;a href=&quot;&#x2F;writing&#x2F;one-cli-for-every-machine&#x2F;&quot;&gt;Last time&lt;&#x2F;a&gt; I got the fleet boring on purpose. Same flake, same CLI verbs, same shell wherever I sat down. That fixed the PATH archaeology. It did not fix the café problem.&lt;&#x2F;p&gt;
&lt;p&gt;The mini is the one that stays on. Claude, Codex, Grok, already parked in zellij under Ghostty at home. The laptop is the one that leaves. I wanted those sessions from somewhere else without treating my router like a public API.&lt;&#x2F;p&gt;
&lt;p&gt;So I drew a hard box. Skip the VPN. Route the path through Cloudflare, because the sites already live there. SSH as the door: shell, attach to zellij, move files, Screen Sharing over a local forward later if I needed a mouse.&lt;&#x2F;p&gt;
&lt;p&gt;The garden deploys as static Workers. Remote access had to sit next to that stack without touching &lt;code&gt;wrangler.toml&lt;&#x2F;code&gt;. One hostname, one tunnel, one Access app, then SSH keys the way they always work. Friends and a custom mobile app stayed on the wishlist. Half the work lived in the Cloudflare dashboard. The other half lived in nix.&lt;&#x2F;p&gt;
&lt;p&gt;I already had Cloudflare for the sites. Remote access meant opening Zero Trust and treating the mini like another application.&lt;&#x2F;p&gt;
&lt;p&gt;Identity first, and it is a two-dashboard job. Cloudflare Access wants GitHub as a login method. GitHub wants an OAuth App (the developer settings kind: homepage URL, callback URL, client ID, client secret). You create that app under the GitHub account or org you actually use, paste Cloudflare&#x27;s callback URL into it, then paste the client ID and secret back into Zero Trust under Authentication. Leave MFA on the GitHub account itself. Policy on the Cloudflare side stays narrow: my GitHub identity only, no bypass, no service token I would paste into a chat. Session length around eight hours so a workday does not re-auth every coffee, but the next morning still knocks.&lt;&#x2F;p&gt;
&lt;p&gt;Then a self-hosted Access application on the subdomain I chose for SSH. That app is the bouncer. It does not replace the SSH key on the mini. It only decides whether Cloudflare will hand the connection to the tunnel. The first real &lt;code&gt;ssh&lt;&#x2F;code&gt; still opens a browser, hits that GitHub OAuth app, and only then continues.&lt;&#x2F;p&gt;
&lt;p&gt;The tunnel is a named Cloudflare Tunnel for the mini. Create it, download the credentials JSON, run &lt;code&gt;cloudflared tunnel route dns&lt;&#x2F;code&gt; (or set the same CNAME in the DNS UI) so the public name points at the tunnel, not the living-room router.&lt;&#x2F;p&gt;
&lt;p&gt;Somewhere in that flow Cloudflare waves a finish-setup URL with a long token. It looks bookmarkable. It is a one-shot enrollment link. I almost saved it into notes. That would have been a fun secret to leak.&lt;&#x2F;p&gt;
&lt;p&gt;When Access is happy and the connector shows healthy edges, the dashboard work is mostly done. The rest is whether the mini runs &lt;code&gt;cloudflared&lt;&#x2F;code&gt; and &lt;code&gt;sshd&lt;&#x2F;code&gt; without babysitting launchd.&lt;&#x2F;p&gt;
&lt;p&gt;This is where &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;nixup&quot;&gt;nixup&lt;&#x2F;a&gt; stopped being background lore.&lt;&#x2F;p&gt;
&lt;p&gt;Copy the credential to &lt;code&gt;&#x2F;var&#x2F;lib&#x2F;cloudflared&#x2F;&lt;&#x2F;code&gt; as root &lt;code&gt;0600&lt;&#x2F;code&gt;. We argued about the UUID. First draft almost baked it into a shared Darwin module. Final split matches nixup hosts: tracked &lt;code&gt;modules&#x2F;darwin&#x2F;cloudflare-remote-access.nix&lt;&#x2F;code&gt; enables SSH, writes &lt;code&gt;&#x2F;etc&#x2F;cloudflared&#x2F;config.yml&lt;&#x2F;code&gt;, and runs the tunnel under launchd. The gitignored personal host module only supplies &lt;code&gt;tunnelId&lt;&#x2F;code&gt;, hostname, and authorized keys.&lt;&#x2F;p&gt;
&lt;p&gt;Ingress stays tiny:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;yaml&quot;&gt;ingress:
  - hostname: &amp;lt;access-subdomain&amp;gt;
    service: ssh:&#x2F;&#x2F;127.0.0.1:22
  - service: http_status:404
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Clients get &lt;code&gt;cloudflared&lt;&#x2F;code&gt;, a key per laptop, and &lt;code&gt;ProxyCommand cloudflared access ssh --hostname %h&lt;&#x2F;code&gt;. First connect opens a browser, the GitHub OAuth app consent screen, then Access, then the host key prompt. &lt;code&gt;id -un&lt;&#x2F;code&gt; prints my username. After two dashboards and a consent screen, success felt almost unfair.&lt;&#x2F;p&gt;
&lt;p&gt;Outside the house, the laptop opens a real shell on the mini. Tunnel healthy, launchd keeps it, home IP changes stop mattering. Zellij attaches over that hop. Files ride SFTP or &lt;code&gt;rsync -e ssh&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;Then I typed for real. Every character does a full trip through Cloudflare and back for the echo. Sticky on a good path. Paste-and-pray on mediocre mobile. Access is mostly connect-time (the browser dance you configured in Zero Trust). Typing lag is path length and remote echo.&lt;&#x2F;p&gt;
&lt;p&gt;Local comedy: Nushell ate &lt;code&gt;PasswordAuthentication=n&lt;&#x2F;code&gt;. Module boundaries took longer than the YAML. I almost committed private host values, walked it back. &lt;code&gt;nixup hosts sync --force&lt;&#x2F;code&gt; can still erase a hand-edited host file. Screen Sharing stays off Cloudflare: forward &lt;code&gt;5901&lt;&#x2F;code&gt; to loopback &lt;code&gt;5900&lt;&#x2F;code&gt; only.&lt;&#x2F;p&gt;
&lt;p&gt;Test latency from a hotspot before calling it finished. Throughput lies. Typing tells the truth. Access plus tunnel is a locked door with audit logs and GitHub MFA. It is a weak daily keyboard. Keep it for break-glass on the same account as the sites.&lt;&#x2F;p&gt;
&lt;p&gt;WARP is Cloudflare&#x27;s always-on client. We skipped it for plain OpenSSH. Tailscale (or WireGuard) is the honest fix for a keyboard that feels local when a direct path works. Cost is another identity plane.&lt;&#x2F;p&gt;
&lt;p&gt;Where I am leaning: dashboard Access as break-glass, mesh VPN for daily shells. nixup made the machines interchangeable. Zero Trust clicks plus a tiny nix module made the mini reachable. Next bet is a keyboard that does not swim, still near Loki.&lt;&#x2F;p&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>I got tired of setting up the same laptop three times</title>
        <published>2026-07-17T00:00:00+00:00</published>
        <updated>2026-07-17T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/one-cli-for-every-machine/"/>
        <id>https://legolas.dev/writing/one-cli-for-every-machine/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/one-cli-for-every-machine/">&lt;p&gt;Last month I sat down at the wrong Mac and spent twenty minutes wondering why half my PATH was from 2024. Same person, same tools, slightly different story of how they arrived. The other machine had the new zellij. This one still had the brew formula I thought I uninstalled. Loki was staring at me like I owed him a walk. Fair.&lt;&#x2F;p&gt;
&lt;p&gt;Two Apple Silicon Macs and a Linux host. Goal was boring: open a shell anywhere and get nushell, starship, neovim, the usual CLI pile, and the agent tools I keep reinstalling by muscle memory. What I had was folklore. &quot;On the Mini you run this. On the other Mac, that. On Linux, pray.&quot;&lt;&#x2F;p&gt;
&lt;p&gt;So I put the machines in a flake and wrote a CLI so I would stop improvising. That project is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;nixup&quot;&gt;nixup&lt;&#x2F;a&gt;. This post is how it holds the fleet together. &lt;a href=&quot;&#x2F;writing&#x2F;cloudflare-ssh-to-mac-mini&#x2F;&quot;&gt;The next one&lt;&#x2F;a&gt; is what happened when the always-on mini had to answer from outside the house.&lt;&#x2F;p&gt;
&lt;p&gt;On macOS it is nix-darwin plus home-manager. On Linux it is home-manager only. Packages and env hooks live in the flake. App configs live in &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;.dotfiles&quot;&gt;.dotfiles&lt;&#x2F;a&gt; and land with GNU stow. That split sounds pedantic until home-manager and stow both try to own &lt;code&gt;config.nu&lt;&#x2F;code&gt;. Nix installs the binary. Stow owns the config.&lt;&#x2F;p&gt;
&lt;p&gt;The public tree ships example hosts so CI can &lt;code&gt;nix flake show&lt;&#x2F;code&gt; without my hostname in git. Real devices stay local. That boundary bites you the first time you hang a tunnel UUID on one host and almost push it.&lt;&#x2F;p&gt;
&lt;p&gt;I used to edit host modules by hand. That lasted until the second machine. Now the personal file is &lt;code&gt;nixup.toml&lt;&#x2F;code&gt;, gitignored, copied from an example:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;toml&quot;&gt;[[hosts]]
id = &amp;quot;my-mac&amp;quot;
match_hostnames = [&amp;quot;my-mac&amp;quot;]
os = &amp;quot;darwin&amp;quot;
flake_attr = &amp;quot;my-mac&amp;quot;

[[hosts]]
id = &amp;quot;my-linux&amp;quot;
match_hostnames = []
os = &amp;quot;linux&amp;quot;
flake_attr = &amp;quot;you@linux&amp;quot;
apply = &amp;quot;home-manager&amp;quot;
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;&lt;code&gt;nixup hosts sync&lt;&#x2F;code&gt; turns that into &lt;code&gt;hosts&#x2F;&amp;lt;id&amp;gt;&#x2F;default.nix&lt;&#x2F;code&gt; and &lt;code&gt;hosts&#x2F;inventory.nix&lt;&#x2F;code&gt;. &lt;code&gt;flake.nix&lt;&#x2F;code&gt; imports the inventory when it exists, otherwise the example. Day-0 is roughly:&lt;&#x2F;p&gt;
&lt;pre&gt;&lt;code data-lang=&quot;bash&quot;&gt;cp nixup.toml.example nixup.toml
# edit hosts
nixup hosts sync --yes
nixup bootstrap --yes
nixup smoke --strict
&lt;&#x2F;code&gt;&lt;&#x2F;pre&gt;
&lt;p&gt;Bootstrap checks for Nix, applies the host, runs smoke, then reminds me about stow. Apply is day-2. Doctor is &quot;why is this machine weird.&quot; When I later needed SSH and a tunnel only on the mini, that same host file was how the feature could opt in without Remote Login on every Darwin box.&lt;&#x2F;p&gt;
&lt;p&gt;Pure flakes only see tracked files. My generated inventory is gitignored, which is correct for privacy and wrong for pure eval. Without force-staging, pure builds fall back to example hosts. CI always uses the example. Local apply uses the real inventory that never leaves the laptop.&lt;&#x2F;p&gt;
&lt;p&gt;I spent longer than I want to admit feeling smart about &quot;everything is in the flake&quot; and then watching pure mode ignore the host I just generated. The fix was design: example inventory in git, personal inventory local, CLI that regenerates from TOML. Tunnel ids and authorized keys want that same pocket. Shared modules stay tracked. Machine gossip stays home.&lt;&#x2F;p&gt;
&lt;p&gt;Nix gets the daily CLIs: nushell, starship, stow, neovim, zellij, atuin, lazygit, rg, fd, eza, uv, bun, zola. &lt;code&gt;cloudflared&lt;&#x2F;code&gt; ended up there too, long before I asked it to babysit a tunnel.&lt;&#x2F;p&gt;
&lt;p&gt;GUI and cask-shaped things go through zerobrew on Mac, Homebrew as fallback while that migration finishes. Ghostty is cask-only on purpose. I will not dual-install a terminal again.&lt;&#x2F;p&gt;
&lt;p&gt;Agent tools are the messy middle. rustup, headroom, claude-code, codex-cli, grok, beads, radicle. Activation is fail-soft so a moved installer URL never tanks the whole switch. Smoke lists required tools. &lt;code&gt;smoke --strict&lt;&#x2F;code&gt; exits 2 if one is missing. That receipt is why I trust apply again, and why agents can live on one always-on box while the other machines stay clients.&lt;&#x2F;p&gt;
&lt;p&gt;Nu scripts for bootstrap and smoke worked until they became the product. I wanted typed config, CI-friendly exit codes, and tests that do not need a full machine switch. Four crates: &lt;code&gt;nixup-core&lt;&#x2F;code&gt;, &lt;code&gt;nixup-ops&lt;&#x2F;code&gt;, &lt;code&gt;nixup-smoke&lt;&#x2F;code&gt;, &lt;code&gt;nixup&lt;&#x2F;code&gt;. Virtual workspace, resolver 3. Tests pin a temp &lt;code&gt;nixup.toml&lt;&#x2F;code&gt; and assert unknown hosts exit 4 and strict smoke exits 2. Same verbs on every box: &lt;code&gt;hosts sync&lt;&#x2F;code&gt;, &lt;code&gt;apply&lt;&#x2F;code&gt;, &lt;code&gt;smoke&lt;&#x2F;code&gt;, &lt;code&gt;doctor&lt;&#x2F;code&gt;.&lt;&#x2F;p&gt;
&lt;p&gt;I still don&#x27;t love all of it. Fail-soft curl installers inside a declarative system are honest and ugly. Secrets stay outside the flake. Stow still needs a dotfiles clone. Pure flakes stay rude about gitignored hosts, so I regenerate from TOML.&lt;&#x2F;p&gt;
&lt;p&gt;And once every machine finally felt like the same desk, a new itch showed up. The mini stays awake with zellij full of agents. The laptop leaves the house. Same shell, same flake, still no honest way to attach from a café without punching a hole in the router.&lt;&#x2F;p&gt;
&lt;p&gt;That part is next: &lt;a href=&quot;&#x2F;writing&#x2F;cloudflare-ssh-to-mac-mini&#x2F;&quot;&gt;Cloudflare to the mini, paid for in keystroke lag&lt;&#x2F;a&gt;.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;nixup&quot;&gt;nixup&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;nix-darwin&#x2F;nix-darwin&quot;&gt;nix-darwin&lt;&#x2F;a&gt; and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;nix-community&#x2F;home-manager&quot;&gt;home-manager&lt;&#x2F;a&gt;&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;determinate.systems&#x2F;&quot;&gt;Determinate Nix&lt;&#x2F;a&gt; when I need an installer that does not fight the daemon&lt;&#x2F;li&gt;
&lt;li&gt;GNU stow and a separate &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;kaankoken&#x2F;.dotfiles&quot;&gt;dotfiles&lt;&#x2F;a&gt; repo for configs&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>I turned my job hunt into a pipeline, then got rejected by my own tech stack</title>
        <published>2026-07-12T00:00:00+00:00</published>
        <updated>2026-07-12T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/job-search-with-career-ops/"/>
        <id>https://legolas.dev/writing/job-search-with-career-ops/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/job-search-with-career-ops/">&lt;p&gt;Two rejection emails landed the same week this spring. One from Deepgram, one from ElevenLabs. Both polite, both no.&lt;&#x2F;p&gt;
&lt;p&gt;Here&#x27;s the funny part. My side project runs on both of them. Voluble does real-time speech-to-speech translation: Deepgram turns speech into text, ElevenLabs turns it back, and I&#x27;ve been paying both at 2am for a year. Then both passed. If you want a quick lesson in humility, get rejected by the APIs you build on.&lt;&#x2F;p&gt;
&lt;p&gt;That was the mood in April. I needed a senior engineering role, I had EU work rights and a comp floor I wasn&#x27;t pretending didn&#x27;t exist, and my browser looked like a hostage situation. 40-something tabs of postings I&#x27;d half-read, and a spreadsheet whose &quot;status&quot; column stopped being true by week 1. I&#x27;d apply on a Tuesday, forget by Thursday, and rediscover the rejection three weeks later like a small archaeological find.&lt;&#x2F;p&gt;
&lt;p&gt;So I did the thing engineers do when they&#x27;re avoiding their feelings. I built a pipeline.&lt;&#x2F;p&gt;
&lt;p&gt;The engine is &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;santifer&#x2F;career-ops&quot;&gt;career-ops&lt;&#x2F;a&gt;, an open-source job-search system by santifer. I use it, and over 3 months I&#x27;ve bent it pretty hard around how I actually work. The apply button stayed mine. Everything before it, I handed to the machine.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-i-was-hunting&quot;&gt;What I was hunting&lt;&#x2F;h2&gt;
&lt;p&gt;Wide, on purpose. Backend, AI and ML product engineering, plain senior software engineer, app and product roles, and senior or staff mobile. I&#x27;ve shipped iOS and Android, Go backends, and a Rust core behind a translation app. People keep filing me under &quot;mobile dev,&quot; and that throws away half my range.&lt;&#x2F;p&gt;
&lt;p&gt;It all runs off two files.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;config&#x2F;profile.yml&lt;&#x2F;code&gt; is the me half: target roles, which archetypes are a real fit versus a stretch, where I can legally work, my comp floor, and a narrative block with the stories I want to tell (Voluble&#x27;s Rust core, dragging an OTT app&#x27;s ANR rate to 0.09%, the open-source work).&lt;&#x2F;p&gt;
&lt;p&gt;&lt;code&gt;portals.yml&lt;&#x2F;code&gt; is the market half: a location filter, a title filter, a stack of Google dorks aimed at specific ATS platforms (one is literally &lt;code&gt;site:jobs.ashbyhq.com &quot;Backend Engineer&quot; Netherlands OR remote&lt;&#x2F;code&gt;), and a list of tracked companies, each with a note on why it&#x27;s worth an evening.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-hunts&quot;&gt;How it hunts&lt;&#x2F;h2&gt;
&lt;p&gt;Scanning is the cheap part, so I keep it cheap. It hits the Greenhouse, Ashby, and Lever APIs directly, no language model near it, and dedups every URL.&lt;&#x2F;p&gt;
&lt;p&gt;The job boards are the annoying part. LinkedIn, Glassdoor, and Indeed have no friendly APIs, and they hate scrapers. So I drive my own logged-in Chrome with Playwright, WebWright, and Claude for Chrome, reading the accessibility tree the way a screen reader would instead of brittle CSS. Because it&#x27;s my own session, I&#x27;m not fighting bot checks, and keeping it local saved me more grief than anything else.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;how-it-scores&quot;&gt;How it scores&lt;&#x2F;h2&gt;
&lt;p&gt;Every posting that survives gets scored into a report under &lt;code&gt;reports&#x2F;&lt;&#x2F;code&gt;: fit, seniority, red flags, and a legitimacy check. That last one earns its keep. A depressing number of &quot;senior remote&quot; listings are ghost jobs, and I&#x27;d rather learn that before I burn an evening on a cover letter.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tailoring-each-application&quot;&gt;Tailoring each application&lt;&#x2F;h2&gt;
&lt;p&gt;Each score comes with a report, and that report is where tailoring starts. It pulls out what this specific role wants: the archetype, the must-have skills, the words an ATS will grep for. That drives the CV.&lt;&#x2F;p&gt;
&lt;p&gt;Everything builds from one &lt;code&gt;cv.md&lt;&#x2F;code&gt; plus the proof points in &lt;code&gt;profile.yml&lt;&#x2F;code&gt;, and the generator assembles a per-company version from them. It reads the role&#x27;s archetype and must-haves off the report, then decides which of my stories to lead with, which to keep, and which to cut. A backend role at Qonto pulls my Go and axum work and Voluble&#x27;s Rust core to the front. A mobile role at Zalando leads with SwiftUI, Compose, and that 0.09% ANR number. It also makes sure the words the ATS is grepping for actually show up, when they&#x27;re true. It never invents anything. It just decides which true thing goes first.&lt;&#x2F;p&gt;
&lt;p&gt;It renders two ways: a LaTeX path through xelatex (tectonic when the fonts sulk) and an HTML path through headless Chromium with the Unicode normalized, because half the ATS parsers choke on a smart quote.&lt;&#x2F;p&gt;
&lt;p&gt;The cover letter takes its angle from the company note I wrote in &lt;code&gt;portals.yml&lt;&#x2F;code&gt;. For the AI companies that angle was obvious: I build Voluble on Deepgram and ElevenLabs, so I said so. (They passed anyway. We&#x27;ll get to that.) The prose runs through &lt;code&gt;voice-dna.md&lt;&#x2F;code&gt;, my anti-slop style guide. If a draft sounds like a language model in a tie, it fails.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;where-i-take-over&quot;&gt;Where I take over&lt;&#x2F;h2&gt;
&lt;p&gt;One step is never automated: applying. The machine gets me to a ranked, tailored shortlist and stops there. I sent every application by hand.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;three-months-of-receipts&quot;&gt;Three months of receipts&lt;&#x2F;h2&gt;
&lt;p&gt;Early April to mid-July, the plain version. I scored 440 postings. I passed on 110 of them (66 skipped on sight, 44 binned after a closer look) and left 234 sitting in the maybe pile. And I applied to 91 companies by hand, 96 applications once you count the few I went back for.&lt;&#x2F;p&gt;
&lt;p&gt;Where those landed so far: 34 no&#x27;s, 4 interviews, 1 warm maybe (Beatgrid), no offers yet, and a healthy pile still pending or gone quiet. That&#x27;s a job hunt.&lt;&#x2F;p&gt;
&lt;p&gt;The applications went wide, which was the point. Mistral AI and Speechify on the AI side. Sentry, Supabase, and Reddit for dev tools. Qonto, Airwallex, Rabobank, and ING in fintech. Figma, TomTom, Coolblue, Fairphone, Tibber, Wikimedia, Lely, and NOWATCH across product. For once my applications said out loud what I am: a senior engineer who ships across mobile, backend, and AI.&lt;&#x2F;p&gt;
&lt;p&gt;The rejections went wide too, and most were fine. Booking.com, ABN AMRO, Mollie, Picnic, bol, eBay, Mozilla, PostHog, Philips, Just Eat Takeaway, Xebia, Speakap. That&#x27;s the job. And then Deepgram and ElevenLabs, which I keep framed in my head.&lt;&#x2F;p&gt;
&lt;p&gt;The 4 that turned into conversations are the ones I&#x27;m quietly proud of: bunq, Zalando (their Lounge team), Ditto (for Ditto Care), and Wypoon Technologies. A couple are still live as I write this.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;what-it-s-good-and-bad-at&quot;&gt;What it&#x27;s good and bad at&lt;&#x2F;h2&gt;
&lt;p&gt;It&#x27;s good at volume without panic: it reads more postings in a morning than I&#x27;d manage in a week, and it catches ghost jobs I&#x27;d have fallen for. It&#x27;s useless at the part that actually gets you hired, being a person a hiring manager remembers, or telling whether &quot;senior&quot; at a 12-person startup means staff-level scope or founder-does-everything chaos. That&#x27;s still me, at my desk, with coffee.&lt;&#x2F;p&gt;
&lt;p&gt;3 months in, the search is still open, but I can see all of it now. I know my conversion at every stage, I know which archetypes waste my night, and when a good role lands the boring work is already done.&lt;&#x2F;p&gt;
&lt;p&gt;And if bunq or Zalando works out, I owe Deepgram and ElevenLabs a thank-you note for the motivation.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;santifer&#x2F;career-ops&quot;&gt;career-ops&lt;&#x2F;a&gt;, the open-source engine behind all of this&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;playwright.dev&quot;&gt;Playwright&lt;&#x2F;a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;microsoft&#x2F;webwright&quot;&gt;WebWright&lt;&#x2F;a&gt;, and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;claude.ai&quot;&gt;Claude for Chrome&lt;&#x2F;a&gt; for driving my own logged-in browser&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;tectonic-typesetting.github.io&quot;&gt;Tectonic&lt;&#x2F;a&gt; and xelatex for rendering the per-company CVs&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;voluble.co.uk&quot;&gt;Voluble&lt;&#x2F;a&gt;, the side project that keeps showing up here&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
    <entry xml:lang="en">
        <title>My job hunt runs itself twice a day now</title>
        <published>2026-07-12T00:00:00+00:00</published>
        <updated>2026-07-12T00:00:00+00:00</updated>
        
        <author>
          <name>
            
              Unknown
            
          </name>
        </author>
        
        <link rel="alternate" type="text/html" href="https://legolas.dev/writing/scheduling-my-job-search/"/>
        <id>https://legolas.dev/writing/scheduling-my-job-search/</id>
        
        <content type="html" xml:base="https://legolas.dev/writing/scheduling-my-job-search/">&lt;p&gt;Most mornings I find out how my job hunt is going before I&#x27;m properly awake. I shuffle to the kitchen, start the coffee, open the laptop, and there&#x27;s a ranked shortlist in my inbox, each role with a one-page fit report. I didn&#x27;t lift a finger for it. My Mac did the work at 6am while I was face-down in a pillow.&lt;&#x2F;p&gt;
&lt;p&gt;&lt;a href=&quot;&#x2F;writing&#x2F;job-search-with-career-ops&#x2F;&quot;&gt;Last time&lt;&#x2F;a&gt; I walked through the pipeline. This is the bit I promised at the end: how it runs itself twice a day without me hovering over it.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;two-runs-weekdays-only&quot;&gt;Two runs, weekdays only&lt;&#x2F;h2&gt;
&lt;p&gt;I schedule the whole thing as local Claude Desktop tasks. One run early, before I&#x27;m functional, and one around 4pm, before I shut the laptop. The morning run scans and scores the overnight postings, so I wake up to coffee and a shortlist instead of 40 open tabs. The afternoon run sweeps up whatever went live during the day and tidies the tracker. Weekdays only. Senior roles don&#x27;t really get posted on a Sunday, and I&#x27;m not reading ghost jobs on my weekend.&lt;&#x2F;p&gt;
&lt;p&gt;I&#x27;m hunting senior engineering roles across mobile, AI, backend, and product, so the net is wide: Reddit, Supabase, Qonto, NOWATCH, Lely. The scheduler doesn&#x27;t care that those are five different archetypes. It reads, scores, and files.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;why-it-stays-on-my-mac&quot;&gt;Why it stays on my Mac&lt;&#x2F;h2&gt;
&lt;p&gt;The good listings live behind LinkedIn, Glassdoor, and Indeed, and all three want a real, logged-in session. My scanner drives my own Chrome over the remote debugging port, 9222, with the cookies I already have. A cloud runner has none of that. It&#x27;d trip every bot check on the first request and get my account rate-limited or worse. So the machine that does the scanning is the one on my desk. The scheduler just wakes it up on a timer.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;the-guardrails&quot;&gt;The guardrails&lt;&#x2F;h2&gt;
&lt;p&gt;Running a job hunt unattended is a great way to embarrass yourself, so there are rules. It never applies: the agent gets me to an evaluated shortlist and stops, and a human sends every application. It respects rate limits instead of hammering a careers page, because a flagged LinkedIn would undo the whole point. And I audit what it writes: I skim the generated CVs and cover letters before anything goes out. A tailored letter with one wrong company name is worse than no automation at all.&lt;&#x2F;p&gt;
&lt;p&gt;The shortlist it hands me is real, though. Ditto Care and Zalando both turned into actual interviews. My favorite result is still a rejection: I built Voluble on Deepgram and ElevenLabs, name-dropped both as a warm angle in the cover letters, and both said no. Fair enough.&lt;&#x2F;p&gt;
&lt;p&gt;So that&#x27;s the split. The computer does the grunt work, and I do the judging. Twice a day it hands me the 10 roles worth my attention and quietly bins the 300 that aren&#x27;t.&lt;&#x2F;p&gt;
&lt;h2 id=&quot;tools&quot;&gt;Tools&lt;&#x2F;h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;santifer&#x2F;career-ops&quot;&gt;career-ops&lt;&#x2F;a&gt;, the engine&lt;&#x2F;li&gt;
&lt;li&gt;Claude Desktop scheduled tasks, the timer that runs it twice a day&lt;&#x2F;li&gt;
&lt;li&gt;&lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;playwright.dev&quot;&gt;Playwright&lt;&#x2F;a&gt;, &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;github.com&#x2F;microsoft&#x2F;webwright&quot;&gt;WebWright&lt;&#x2F;a&gt;, and &lt;a rel=&quot;external&quot; href=&quot;https:&#x2F;&#x2F;claude.ai&quot;&gt;Claude for Chrome&lt;&#x2F;a&gt; for the scanning&lt;&#x2F;li&gt;
&lt;&#x2F;ul&gt;
</content>
        
    </entry>
</feed>
