<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Agent-Cli | Bas Nijholt</title><link>https://www.nijho.lt/tag/agent-cli/</link><atom:link href="https://www.nijho.lt/tag/agent-cli/index.xml" rel="self" type="application/rss+xml"/><description>Agent-Cli</description><generator>Hugo</generator><language>en-us</language><copyright>© 2026</copyright><lastBuildDate>Tue, 22 Sep 2026 00:00:00 +0000</lastBuildDate><image><url>https://www.nijho.lt/media/icon_hu_408a8f6cef43e327.png</url><title>Agent-Cli</title><link>https://www.nijho.lt/tag/agent-cli/</link></image><item><title>Frontier-level dictation on your iPhone keyboard, self-hosted</title><link>https://www.nijho.lt/post/diction-agent-cli-qwen/</link><pubDate>Tue, 22 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/diction-agent-cli-qwen/</guid><description>&lt;p&gt;On a recent month-long trip visiting family in Europe, I did a lot of work from my phone, using &lt;a href="https://www.nijho.lt/post/agentic-mobile-workflow/"&gt;my mobile coding workflow&lt;/a&gt;.&#10;It still boggles my mind that real, productive work from a phone is possible now.&#10;Most of that work is talking to coding agents, and the built-in iOS dictation is garbage for that.&#10;I had an iOS Shortcut that sent recordings to &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;Agent CLI&lt;/a&gt;, &lt;a href="https://www.nijho.lt/post/auto-install-extras/"&gt;my local AI toolbox&lt;/a&gt;, running at home instead.&#10;The problem was that the recording screen takes over the whole display, so I could not see the thing I was commenting on.&#10;The transcript then landed in my clipboard, and I had to paste it myself.&lt;/p&gt;&#10;&lt;p&gt;So I threw my principles overboard and installed &lt;a href="https://wisprflow.ai" target="_blank" rel="noopener"&gt;Wispr Flow&lt;/a&gt;, a voice keyboard for iOS.&#10;I loved it instantly.&#10;iOS does not let keyboards use the microphone, so the keyboard&amp;rsquo;s button hands off to the Wispr Flow app, which keeps recording in the background.&#10;I was less happy about that, since it meant a closed-source app always had the microphone open and sent my voice to their servers.&#10;I was on the free plan, so I was probably the product.&#10;I also hit the free weekly word limit very quickly, and started looking for an alternative.&lt;/p&gt;&#10;&lt;p&gt;That is how I found &lt;a href="https://apps.apple.com/app/id6759807364" target="_blank" rel="noopener"&gt;Diction&lt;/a&gt;, which has since become my favorite app when I&amp;rsquo;m away from my laptop.&#10;It works the same way, background recording included.&#10;The difference is that it has a self-hosted mode with an open-source gateway, so my voice goes to a machine I own instead of their servers.&lt;/p&gt;&#10;&lt;p&gt;I run the &lt;a href="https://github.com/DictionLabs/Diction" target="_blank" rel="noopener"&gt;gateway&lt;/a&gt; on my home machine in the U.S., reachable only over my private network.&#10;The gateway streams audio to Agent CLI, which runs Alibaba&amp;rsquo;s &lt;a href="https://huggingface.co/Qwen/Qwen3-ASR-1.7B-hf" target="_blank" rel="noopener"&gt;&lt;code&gt;Qwen/Qwen3-ASR-1.7B-hf&lt;/code&gt;&lt;/a&gt; on an RTX 3090 behind an OpenAI-compatible transcription endpoint.&#10;There is no LLM cleanup step, so Diction&amp;rsquo;s Writing Style and Tones features do not work with this setup.&#10;I don&amp;rsquo;t miss them: Qwen&amp;rsquo;s raw transcription is already good enough for me.&lt;/p&gt;&#10;&lt;h2 id="1-why-qwen"&gt;1. Why Qwen? &lt;a class="anchor" href="#1-why-qwen" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For the longest time I used &lt;a href="https://github.com/SYSTRAN/faster-whisper" target="_blank" rel="noopener"&gt;Faster-Whisper&lt;/a&gt; with Whisper large-v3.&#10;Whisper is still the default choice for most people, but many newer models beat it.&#10;I tried some of them, including NVIDIA&amp;rsquo;s &lt;a href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3" target="_blank" rel="noopener"&gt;Parakeet&lt;/a&gt;.&#10;Parakeet is fast, but it cannot take custom instructions.&#10;Those instructions are how Diction&amp;rsquo;s &lt;strong&gt;My Words&lt;/strong&gt; feature biases a transcription toward specialized names.&lt;/p&gt;&#10;&lt;p&gt;At the start of September, I looked at the &lt;a href="https://huggingface.co/spaces/hf-audio/open_asr_leaderboard" target="_blank" rel="noopener"&gt;Hugging Face Open ASR Leaderboard&lt;/a&gt;, which tests speech-to-text models on the same recordings and ranks them.&#10;The score is the word error rate (WER): the share of words a model gets wrong, counting words it swaps, drops, or makes up.&#10;Lower is better, and a WER of 4% means about one wrong word in every 25.&#10;The test recordings range from audiobooks and podcasts to meetings and earnings calls, so the average says more than any single clean benchmark.&lt;/p&gt;&#10;&lt;p&gt;These are the averages on the public English test sets as of September 19, 2026:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Model&lt;/th&gt;&#10;&lt;th&gt;WER&lt;/th&gt;&#10;&lt;th&gt;Can I run it at home?&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;Zoom Scribe v2 Pro&lt;/td&gt;&#10;&lt;td&gt;3.6%&lt;/td&gt;&#10;&lt;td&gt;No, paid API (#1 overall)&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;ElevenLabs Scribe v2&lt;/td&gt;&#10;&lt;td&gt;4.0%&lt;/td&gt;&#10;&lt;td&gt;No, paid API&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;strong&gt;Qwen3-ASR 1.7B&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;strong&gt;4.3%&lt;/strong&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;strong&gt;Yes, Apache-2.0&lt;/strong&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;AssemblyAI Universal-3.5 Pro&lt;/td&gt;&#10;&lt;td&gt;4.3%&lt;/td&gt;&#10;&lt;td&gt;No, paid API&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;NVIDIA Parakeet TDT 0.6B v3&lt;/td&gt;&#10;&lt;td&gt;4.9%&lt;/td&gt;&#10;&lt;td&gt;Yes&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;OpenAI Whisper large-v3&lt;/td&gt;&#10;&lt;td&gt;5.8%&lt;/td&gt;&#10;&lt;td&gt;Yes&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;I picked the best model on the board whose weights you can download: Qwen3-ASR 1.7B.&#10;It lands within a point of the best paid API, which is what I mean by frontier-level.&#10;Since I maintain Agent CLI, I told an agent to add a Qwen backend, and shortly afterward &lt;a href="https://github.com/basnijholt/agent-cli/pull/636" target="_blank" rel="noopener"&gt;it had landed&lt;/a&gt; and was running on my server.&lt;/p&gt;&#10;&lt;p&gt;Agent CLI&amp;rsquo;s transcription server loads a model on the first request and unloads it after an idle timeout, so the model only takes VRAM while I use it.&#10;The same process serves an OpenAI-compatible API, which the Diction gateway talks to, and the &lt;a href="https://www.home-assistant.io/integrations/wyoming/" target="_blank" rel="noopener"&gt;Wyoming protocol&lt;/a&gt; that Home Assistant uses for local voice.&lt;/p&gt;&#10;&lt;h2 id="2-the-recipe"&gt;2. The recipe &lt;a class="anchor" href="#2-the-recipe" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;You need an NVIDIA GPU with the &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html" target="_blank" rel="noopener"&gt;NVIDIA Container Toolkit&lt;/a&gt;, Docker Compose 2.30 or newer, and a private route from your iPhone to the server.&#10;I use Headscale, but Tailscale or plain WireGuard works just as well.&#10;Do not expose the gateway to the public internet.&lt;/p&gt;&#10;&lt;h3 id="write-the-compose-file"&gt;Write the Compose file &lt;a class="anchor" href="#write-the-compose-file" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Save the following as &lt;code&gt;compose.yaml&lt;/code&gt; in an empty directory.&#10;Agent CLI&amp;rsquo;s CI publishes a CUDA image with the Transformers backend and Qwen as the default model, so there is nothing to build.&#10;The file points Diction&amp;rsquo;s gateway at Agent CLI&amp;rsquo;s transcription port.&#10;&lt;code&gt;WHISPER_TTL&lt;/code&gt; raises the idle timeout from the default five minutes to a day, so dictation rarely waits for a reload:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;asr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ghcr.io/basnijholt/agent-cli-whisper:0.112.0-cuda-transformers&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;WHISPER_TTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;86400&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;model-cache:/home/whisper/.cache&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;gpus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;all&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;unless-stopped&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;dictionlabs/gateway:v14.0&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;depends_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;asr&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;CUSTOM_BACKEND_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;http://asr:10301&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;CUSTOM_BACKEND_MODEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Qwen/Qwen3-ASR-1.7B-hf&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;CUSTOM_BACKEND_CANONICAL_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;Qwen/Qwen3-ASR-1.7B-hf&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;CUSTOM_BACKEND_NEEDS_WAV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;true&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;8080:8080&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;unless-stopped&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;model-cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="start-it-and-connect-the-app"&gt;Start it and connect the app &lt;a class="anchor" href="#start-it-and-connect-the-app" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Start both services:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;docker compose up -d&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The first transcription downloads Qwen into the &lt;code&gt;model-cache&lt;/code&gt; volume, so that request can take a few minutes.&#10;&lt;code&gt;docker compose logs -f&lt;/code&gt; shows the progress.&lt;/p&gt;&#10;&lt;p&gt;On the iPhone, go to &lt;strong&gt;Settings → General → Keyboard → Keyboards → Add New Keyboard → Diction&lt;/strong&gt;.&#10;Tap Diction in the keyboard list, enable &lt;strong&gt;Allow Full Access&lt;/strong&gt;, and grant microphone access when prompted.&#10;Then open Diction, go to &lt;strong&gt;Preferences → Mode → Self-Hosted&lt;/strong&gt;, enter &lt;code&gt;http://&amp;lt;private-server-ip&amp;gt;:8080&lt;/code&gt;, and tap &lt;strong&gt;Test connection&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;A small upstream detour:&lt;/strong&gt; Diction&amp;rsquo;s &lt;strong&gt;My Words&lt;/strong&gt; feature was not reaching the ASR backend during streaming, so I opened &lt;a href="https://github.com/DictionLabs/Diction/pull/20" target="_blank" rel="noopener"&gt;Diction PR #20&lt;/a&gt; to forward those words as the transcription prompt.&#10;With the fix, package names such as &lt;code&gt;PipeFunc&lt;/code&gt; and &lt;code&gt;MindRoom&lt;/code&gt; come back with the correct spelling and capitalization.&#10;It shipped in gateway v13.0, which is why the Compose file needs at least that version.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;p&gt;On my machine, Qwen3-ASR 1.7B uses about 4.7 GiB of GPU memory and roughly 3 GiB of system memory while loaded.&lt;/p&gt;&#10;&lt;h2 id="3-the-bar-test"&gt;3. The bar test &lt;a class="anchor" href="#3-the-bar-test" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A day later, I met a friend in a bar in the Netherlands and wanted to let him try it.&lt;/p&gt;&#10;&lt;p&gt;He was already on my tailnet for some of my other self-hosted services, so his phone only needed an ACL change to reach the gateway.&#10;I opened the terminal app on my phone, connected to an agent, and dictated that change.&#10;In less than a minute, Diction was connected and working on his phone.&#10;His speech crossed the Atlantic, was transcribed by Qwen on my home GPU, and appeared back in the app with barely any delay.&lt;/p&gt;&#10;&lt;p&gt;That moment sold the whole setup to me.&#10;I was in a bar without a laptop, and I used dictation to grant access to the dictation service.&lt;/p&gt;&#10;</description></item><item><title>Auto-installing extras at runtime so users never have to think about dependencies</title><link>https://www.nijho.lt/post/auto-install-extras/</link><pubDate>Wed, 11 Feb 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/auto-install-extras/</guid><description>&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-from-one-command-to-a-dependency-maze"&gt;1. From one command to a dependency maze&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-why-just-use-extras-breaks-down"&gt;2. Why &amp;ldquo;just use extras&amp;rdquo; breaks down&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-the-decorator"&gt;3. The decorator&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-checking-without-importing"&gt;4. Checking without importing&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-the-metadata-bridge"&gt;5. The metadata bridge&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-three-environments-three-strategies"&gt;6. Three environments, three strategies&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#a-uv-tool-install--the-persistent-environment"&gt;A) &lt;code&gt;uv tool install&lt;/code&gt; — the persistent environment&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#b-uvx--the-ephemeral-environment"&gt;B) &lt;code&gt;uvx&lt;/code&gt; — the ephemeral environment&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#c-regular-virtualenv-pipx-or-system-install"&gt;C) Regular virtualenv, &lt;code&gt;pipx&lt;/code&gt;, or system install&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-preventing-infinite-re-execution-loops"&gt;7. Preventing infinite re-execution loops&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-what-users-actually-see"&gt;8. What users actually see&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-keeping-everything-in-sync"&gt;9. Keeping everything in sync&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#10-why-uv-made-this-practical"&gt;10. Why uv made this practical&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#11-tradeoffs"&gt;11. Tradeoffs&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#12-not-dependency-injection"&gt;12. Not dependency injection&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#13-conclusion"&gt;13. Conclusion&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-from-one-command-to-a-dependency-maze"&gt;1. From one command to a dependency maze &lt;a class="anchor" href="#1-from-one-command-to-a-dependency-maze" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt; started as a simple thing.&#10;Voice in, text out.&#10;It connected to a &lt;a href="https://github.com/rhasspy/wyoming" target="_blank" rel="noopener"&gt;Wyoming&lt;/a&gt; Whisper server for speech-to-text and a Piper server for text-to-speech, basically a local, open-source Siri triggered from terminal hotkeys.&#10;I wrote about the early version in my &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;local AI journey&lt;/a&gt; post.&lt;/p&gt;&#10;&lt;p&gt;And then, as tends to happen with side projects I use every day, I kept adding things.&lt;/p&gt;&#10;&lt;p&gt;First a RAG proxy with ChromaDB, so I could chat with my documents.&#10;Then a long-term memory system, so conversations could remember context across sessions.&#10;Server commands that run Kokoro TTS or Whisper locally, optimized for whatever hardware you have—Apple Silicon gets MLX, NVIDIA gets CTranslate2.&#10;Voice activity detection with Silero VAD.&#10;Audio speed adjustment.&#10;An &lt;a href="https://www.nijho.lt/post/parallel-agentic-coding/"&gt;agent orchestration system&lt;/a&gt;.&#10;&lt;code&gt;systemd&lt;/code&gt;/&lt;code&gt;launchd&lt;/code&gt; service management for running background daemons.&lt;/p&gt;&#10;&lt;p&gt;Each of these features pulled in wildly different dependency trees.&#10;&lt;code&gt;torch&lt;/code&gt; and &lt;code&gt;transformers&lt;/code&gt; for GPU-accelerated Whisper.&#10;&lt;code&gt;sounddevice&lt;/code&gt; and &lt;code&gt;numpy&lt;/code&gt; for microphone access.&#10;&lt;code&gt;chromadb&lt;/code&gt; and &lt;code&gt;onnxruntime&lt;/code&gt; for vector storage.&#10;&lt;code&gt;kokoro&lt;/code&gt; with its entire spacy pipeline for neural TTS.&lt;/p&gt;&#10;&lt;p&gt;Today the project has &lt;strong&gt;13 optional extras&lt;/strong&gt;.&#10;The tool became much more useful, but dependency UX got progressively worse with every feature.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I moved from &amp;ldquo;users choose extras upfront&amp;rdquo; to &lt;strong&gt;runtime optional dependency resolution&lt;/strong&gt;.&#10;Commands declare required extras with &lt;code&gt;@requires_extras(...)&lt;/code&gt;; if something is missing, &lt;code&gt;agent-cli&lt;/code&gt; installs it in the right environment and re-runs the command automatically.&#10;The user just types &lt;code&gt;agent-cli transcribe&lt;/code&gt; and everything works.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="2-why-just-use-extras-breaks-down"&gt;2. Why &amp;ldquo;just use extras&amp;rdquo; breaks down &lt;a class="anchor" href="#2-why-just-use-extras-breaks-down" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Python&amp;rsquo;s &lt;code&gt;optional-dependencies&lt;/code&gt; in &lt;code&gt;pyproject.toml&lt;/code&gt; is the standard mechanism for this, and it&amp;rsquo;s the right packaging primitive.&#10;You define groups, users install with &lt;code&gt;pip install agent-cli[rag]&lt;/code&gt;.&#10;On paper, this is perfectly reasonable.&lt;/p&gt;&#10;&lt;p&gt;In practice, it falls apart when you have 13 of them:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;optional-dependencies&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;audio&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;numpy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;sounddevice&amp;gt;=0.4.6&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;agent-cli[wyoming]&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;llm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;pydantic-ai-slim[openai,google,duckduckgo,vertexai]&amp;gt;=0.1.1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;rag&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;pydantic-ai-slim[openai,google]&amp;gt;=0.1.1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;chromadb&amp;gt;=0.4.22&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;memory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;pydantic-ai-slim[openai,google]&amp;gt;=0.1.1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;chromadb&amp;gt;=0.4.22&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;vad&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;onnxruntime&amp;gt;=1.16.0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;faster-whisper&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;faster-whisper&amp;gt;=1.0.0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;mlx-whisper&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;mlx-whisper&amp;gt;=0.4.0; sys_platform == &amp;#39;darwin&amp;#39; and platform_machine == &amp;#39;arm64&amp;#39;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;kokoro&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;kokoro&amp;gt;=0.9.0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;soundfile&amp;gt;=0.12.0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;piper&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;piper-tts&amp;gt;=1.2.0&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# ... and more&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The UX problem is obvious.&#10;If I want to transcribe voice to text, do I need &lt;code&gt;audio&lt;/code&gt;? &lt;code&gt;llm&lt;/code&gt;? &lt;code&gt;vad&lt;/code&gt;? All three?&#10;What if I want the Whisper server to run locally—is that &lt;code&gt;faster-whisper&lt;/code&gt; or &lt;code&gt;mlx-whisper&lt;/code&gt;?&#10;Do I also need &lt;code&gt;server&lt;/code&gt; for that?&lt;/p&gt;&#10;&lt;p&gt;Expecting users to study a dependency matrix before running a CLI command is a terrible experience.&#10;I wanted three things:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;A lightweight base install that&amp;rsquo;s fast and doesn&amp;rsquo;t pull in heavy ML dependencies.&lt;/li&gt;&#10;&lt;li&gt;Commands that &amp;ldquo;just work&amp;rdquo; when first used.&lt;/li&gt;&#10;&lt;li&gt;A clear opt-out for people who don&amp;rsquo;t want automatic installs.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="3-the-decorator"&gt;3. The decorator &lt;a class="anchor" href="#3-the-decorator" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The core idea is simple.&#10;Each CLI command declares what it needs, and the decorator handles the rest:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@app.command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;transcribe&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rich_help_panel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Voice Commands&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@requires_extras&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;audio&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;llm&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s it from the command author&amp;rsquo;s perspective.&#10;One line, and you never think about dependency management in that command again.&lt;/p&gt;&#10;&lt;p&gt;Here are a few more examples from the codebase to show how this scales:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Continuous transcription needs audio, voice activity detection, and an LLM&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@requires_extras&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;audio&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;vad&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;llm&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transcribe_live&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# TTS server needs the server framework + one of two TTS backends + Wyoming protocol&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@requires_extras&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;server&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;piper|kokoro&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wyoming&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_tts_server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Whisper server: any of three ASR backends will do&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@requires_extras&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;server&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;faster-whisper|mlx-whisper|whisper-transformers&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;wyoming&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_whisper_server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Notice the pipe syntax: &lt;code&gt;&amp;quot;piper|kokoro&amp;quot;&lt;/code&gt; means &amp;ldquo;you need &lt;em&gt;either&lt;/em&gt; Piper &lt;em&gt;or&lt;/em&gt; Kokoro—any one of these satisfies the requirement.&amp;rdquo;&#10;On Apple Silicon, if you already have &lt;code&gt;mlx-whisper&lt;/code&gt; installed, it won&amp;rsquo;t try to install &lt;code&gt;faster-whisper&lt;/code&gt; instead.&lt;/p&gt;&#10;&lt;p&gt;The &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/core/deps.py#L365-L383" target="_blank" rel="noopener"&gt;decorator itself&lt;/a&gt; is straightforward:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;requires_extras&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decorator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nd"&gt;@functools.wraps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_check_and_install_extras&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;typer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;wrapper&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decorator&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The real complexity lives in &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/core/deps.py#L321-L359" target="_blank" rel="noopener"&gt;&lt;code&gt;_check_and_install_extras&lt;/code&gt;&lt;/a&gt;, which handles several installation scenarios.&#10;But the interface for command authors is always a single line.&lt;/p&gt;&#10;&lt;h2 id="4-checking-without-importing"&gt;4. Checking without importing &lt;a class="anchor" href="#4-checking-without-importing" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;You don&amp;rsquo;t want to &lt;em&gt;import&lt;/em&gt; &lt;code&gt;torch&lt;/code&gt; just to check if it&amp;rsquo;s available—that takes seconds and loads hundreds of megabytes into memory.&#10;Instead, I use &lt;code&gt;importlib.util.find_spec&lt;/code&gt;, which checks whether a module exists on the import path without executing any of its code.&#10;No heavy imports, no GPU initialization, no side effects.&#10;The check is essentially free.&lt;/p&gt;&#10;&lt;h2 id="5-the-metadata-bridge"&gt;5. The metadata bridge &lt;a class="anchor" href="#5-the-metadata-bridge" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;There&amp;rsquo;s a gap between what &lt;code&gt;pyproject.toml&lt;/code&gt; knows and what the runtime check needs.&#10;&lt;code&gt;pyproject.toml&lt;/code&gt; says the &lt;code&gt;rag&lt;/code&gt; extra requires &lt;code&gt;chromadb&amp;gt;=0.4.22&lt;/code&gt;—that&amp;rsquo;s the &lt;em&gt;pip package name&lt;/em&gt;.&#10;But at runtime, I need to check &lt;em&gt;Python import names&lt;/em&gt;.&#10;Sometimes these are the same (&lt;code&gt;chromadb&lt;/code&gt; → &lt;code&gt;import chromadb&lt;/code&gt;), but often they&amp;rsquo;re not: &lt;code&gt;pydantic-ai-slim&lt;/code&gt; is imported as &lt;code&gt;pydantic_ai&lt;/code&gt;, &lt;code&gt;piper-tts&lt;/code&gt; as &lt;code&gt;piper&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;To bridge this, I generate an &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/_extras.json" target="_blank" rel="noopener"&gt;&lt;code&gt;_extras.json&lt;/code&gt;&lt;/a&gt; file that maps each extra to its description and the Python import names needed for the runtime check:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;audio&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Audio recording/playback&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;sounddevice&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;rag&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;RAG proxy (ChromaDB, embeddings)&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;chromadb&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pydantic_ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;memory&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Long-term memory proxy&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;chromadb&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;yaml&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pydantic_ai&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;vad&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Voice Activity Detection (Silero VAD via ONNX)&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;onnxruntime&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;faster-whisper&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Whisper ASR via CTranslate2&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;faster_whisper&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This file is &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/.github/scripts/sync_extras.py" target="_blank" rel="noopener"&gt;generated by a script&lt;/a&gt; that parses &lt;code&gt;pyproject.toml&lt;/code&gt; and applies a mapping table for the package-name-to-import-name conversions.&#10;A pre-commit hook ensures it stays in sync—if you add an extra to &lt;code&gt;pyproject.toml&lt;/code&gt; and forget to update the metadata, the commit fails.&lt;/p&gt;&#10;&lt;p&gt;I wish Python packaging had a standard way to discover a package&amp;rsquo;s top-level import name from its distribution name.&#10;It doesn&amp;rsquo;t, so the mapping table is the least-bad option I found.&lt;/p&gt;&#10;&lt;h2 id="6-three-environments-three-strategies"&gt;6. Three environments, three strategies &lt;a class="anchor" href="#6-three-environments-three-strategies" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The hard part is that people run &lt;code&gt;agent-cli&lt;/code&gt; in several different ways, and each needs a different installation strategy.&lt;/p&gt;&#10;&lt;h3 id="a-uv-tool-install--the-persistent-environment"&gt;A) &lt;code&gt;uv tool install&lt;/code&gt; — the persistent environment &lt;a class="anchor" href="#a-uv-tool-install--the-persistent-environment" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;When installed via &lt;code&gt;uv tool install agent-cli&lt;/code&gt;, the tool lives in a persistent environment under &lt;code&gt;~/.local/share/uv/tools/agent-cli/&lt;/code&gt;.&#10;This environment has a &lt;code&gt;uv-receipt.toml&lt;/code&gt; file that tracks which extras are installed.&lt;/p&gt;&#10;&lt;p&gt;When auto-install triggers, the system reads the current extras from that receipt, merges in the new ones, and reinstalls:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;install_extras_impl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quiet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_uv_tool_install&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;current_extras&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_get_current_uv_tool_extras&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;new_extras&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_extras&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_install_via_uv_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_extras&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quiet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;quiet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So if you already had &lt;code&gt;audio&lt;/code&gt; installed and now run a command that needs &lt;code&gt;llm&lt;/code&gt;, it becomes &lt;code&gt;uv tool install agent-cli[audio,llm] --force&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;The key insight: &lt;code&gt;uv tool upgrade&lt;/code&gt; reads &lt;code&gt;uv-receipt.toml&lt;/code&gt; and preserves the extras list.&#10;Once auto-install adds an extra, it persists across future upgrades.&#10;You never have to install it again.&lt;/p&gt;&#10;&lt;p&gt;(&lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/core/deps.py#L179-L235" target="_blank" rel="noopener"&gt;Full implementation&lt;/a&gt;)&lt;/p&gt;&#10;&lt;h3 id="b-uvx--the-ephemeral-environment"&gt;B) &lt;code&gt;uvx&lt;/code&gt; — the ephemeral environment &lt;a class="anchor" href="#b-uvx--the-ephemeral-environment" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;&lt;code&gt;uvx&lt;/code&gt; is uv&amp;rsquo;s tool for running packages in temporary, disposable environments.&#10;Running &lt;code&gt;uvx agent-cli transcribe&lt;/code&gt; creates a fresh environment, installs &lt;code&gt;agent-cli&lt;/code&gt;, runs the command, and throws the environment away.&lt;/p&gt;&#10;&lt;p&gt;The problem: there&amp;rsquo;s nowhere to persist extras.&#10;No &lt;code&gt;uv-receipt.toml&lt;/code&gt; to update, no virtualenv that survives the invocation.&lt;/p&gt;&#10;&lt;p&gt;The solution is one of my favorite tricks in this system: re-execute the entire command with the extras baked into the &lt;code&gt;uvx&lt;/code&gt; invocation itself:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_maybe_reexec_with_uvx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_REEXEC_MARKER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_is_uvx_cache&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;uvx_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;which&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;uvx&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;uvx_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;extras_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;,&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extras&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;uvx_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--python&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;3.13&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;agent-cli[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;extras_str&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;]&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_maybe_exec_with_marker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Re-running with extras: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;extras_str&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So &lt;code&gt;uvx agent-cli transcribe&lt;/code&gt; transparently becomes &lt;code&gt;uvx agent-cli[audio,llm] transcribe&lt;/code&gt;.&#10;The user sees a brief &amp;ldquo;Re-running with extras: audio, llm&amp;rdquo; message, and then the command works.&lt;/p&gt;&#10;&lt;p&gt;Environment detection is straightforward—&lt;code&gt;uvx&lt;/code&gt; environments live under the uv cache directory:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_uvx_cache&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;prefix_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;as_posix&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/cache/uv/&amp;#34;&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prefix_str&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;/archive-v&amp;#34;&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;prefix_str&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;(&lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/core/deps.py#L287-L301" target="_blank" rel="noopener"&gt;Full implementation&lt;/a&gt;)&lt;/p&gt;&#10;&lt;h3 id="c-regular-virtualenv-pipx-or-system-install"&gt;C) Regular virtualenv, &lt;code&gt;pipx&lt;/code&gt;, or system install &lt;a class="anchor" href="#c-regular-virtualenv-pipx-or-system-install" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;For plain &lt;code&gt;pip install agent-cli&lt;/code&gt;, &lt;code&gt;pipx&lt;/code&gt;, or virtualenv installs, the system falls back to direct installation.&#10;Each extra has a pre-generated requirements file with pinned versions (e.g., &lt;code&gt;_requirements/audio.txt&lt;/code&gt;), and the install command adapts to whatever&amp;rsquo;s available:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_install_cmd&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;in_venv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_prefix&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;which&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;uv&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;uv&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pip&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;install&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--python&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;in_venv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--system&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;-m&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;pip&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;install&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;in_venv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Prefers &lt;code&gt;uv pip&lt;/code&gt; when available (fast), falls back to regular pip.&#10;Uses &lt;code&gt;--system&lt;/code&gt; or &lt;code&gt;--user&lt;/code&gt; as appropriate when not in a virtualenv.&#10;This is also the path that &lt;code&gt;pipx&lt;/code&gt; installations take—&lt;code&gt;pipx&lt;/code&gt; creates a regular virtualenv under the hood, so the auto-installer detects it as a venv and installs directly into it.&lt;/p&gt;&#10;&lt;p&gt;(&lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/core/deps.py#L215-L248" target="_blank" rel="noopener"&gt;Full implementation&lt;/a&gt;)&lt;/p&gt;&#10;&lt;h2 id="7-preventing-infinite-re-execution-loops"&gt;7. Preventing infinite re-execution loops &lt;a class="anchor" href="#7-preventing-infinite-re-execution-loops" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;There&amp;rsquo;s an obvious footgun here: what if the install succeeds but the package still isn&amp;rsquo;t importable for some reason?&#10;You&amp;rsquo;d get an infinite loop of install → re-exec → check → install → re-exec → &amp;hellip;&lt;/p&gt;&#10;&lt;p&gt;The fix is a simple environment variable marker:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_REEXEC_MARKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_AGENT_CLI_REEXEC&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_maybe_exec_with_marker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_REEXEC_MARKER&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;# Already re-executed once, don&amp;#39;t loop&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;new_env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;new_env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_REEXEC_MARKER&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;1&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execvpe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;os.execvpe&lt;/code&gt; replaces the current process entirely—it never returns.&#10;The marker ensures we only do this once.&#10;If the second run still can&amp;rsquo;t find the packages, it falls back to a helpful error message instead of looping forever.&lt;/p&gt;&#10;&lt;h2 id="8-what-users-actually-see"&gt;8. What users actually see &lt;a class="anchor" href="#8-what-users-actually-see" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;With auto-install &lt;strong&gt;enabled&lt;/strong&gt; (the default), a first run looks like this:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Auto-installing missing extras: audio, llm&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Running: uv tool install agent-cli[audio,llm] --force --python 3.13 -q&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Installation complete!&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Re-running with installed extras...&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And then the command runs normally.&#10;Next time, it runs instantly—no install needed.&lt;/p&gt;&#10;&lt;p&gt;With auto-install &lt;strong&gt;disabled&lt;/strong&gt;, the user gets a clear message telling them exactly what to do:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;This command requires the &amp;#39;audio&amp;#39; extra (Audio recording/playback).&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Install with:&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; uv tool install -p 3.13 &amp;#34;agent-cli\[audio]&amp;#34;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; # or&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; agent-cli install-extras audio&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If you prefer explicit control, there&amp;rsquo;s also a manual &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/install/extras.py#L24-L104" target="_blank" rel="noopener"&gt;&lt;code&gt;install-extras&lt;/code&gt;&lt;/a&gt; command:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli install-extras rag memory &lt;span class="c1"&gt;# Install specific extras&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli install-extras --all &lt;span class="c1"&gt;# Install everything&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli install-extras --list &lt;span class="c1"&gt;# Show available extras with descriptions&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You can opt out via environment variable or config file:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;AGENT_CLI_NO_AUTO_INSTALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# ~/.config/agent-cli/config.toml&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;auto_install_extras&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="9-keeping-everything-in-sync"&gt;9. Keeping everything in sync &lt;a class="anchor" href="#9-keeping-everything-in-sync" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The runtime system depends on two generated artifacts:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;_extras.json&lt;/code&gt;&lt;/strong&gt;: maps extras to descriptions and import names (for the &lt;code&gt;find_spec&lt;/code&gt; check)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;_requirements/*.txt&lt;/code&gt;&lt;/strong&gt;: pinned dependency files for each extra (for the pip/uv install)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;&lt;code&gt;_extras.json&lt;/code&gt; is generated by &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/.github/scripts/sync_extras.py" target="_blank" rel="noopener"&gt;&lt;code&gt;sync_extras.py&lt;/code&gt;&lt;/a&gt; (run manually or in CI).&#10;The requirements files are generated by &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/.github/scripts/sync_requirements.py" target="_blank" rel="noopener"&gt;&lt;code&gt;sync_requirements.py&lt;/code&gt;&lt;/a&gt;, which runs as a pre-commit hook whenever &lt;code&gt;pyproject.toml&lt;/code&gt; or &lt;code&gt;uv.lock&lt;/code&gt; changes.&#10;A &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/.github/scripts/check_extras_sync.py" target="_blank" rel="noopener"&gt;check script&lt;/a&gt; enforces that &lt;code&gt;_extras.json&lt;/code&gt; stays consistent with &lt;code&gt;pyproject.toml&lt;/code&gt;—if you add an extra but forget to update the metadata, the commit fails with a clear error.&lt;/p&gt;&#10;&lt;p&gt;This means the runtime system never drifts from the source of truth.&#10;&lt;code&gt;pyproject.toml&lt;/code&gt; is the canonical definition, everything else is derived.&lt;/p&gt;&#10;&lt;h2 id="10-why-uv-made-this-practical"&gt;10. Why uv made this practical &lt;a class="anchor" href="#10-why-uv-made-this-practical" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I want to be clear: this system would be &lt;em&gt;much&lt;/em&gt; harder without &lt;a href="https://docs.astral.sh/uv/" target="_blank" rel="noopener"&gt;uv&lt;/a&gt;.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;uv tool install&lt;/code&gt;&lt;/strong&gt; with persistent extras in &lt;code&gt;uv-receipt.toml&lt;/code&gt;—extras survive upgrades automatically&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;uvx&lt;/code&gt;&lt;/strong&gt; for ephemeral execution with extras specified inline&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;uv pip install&lt;/code&gt;&lt;/strong&gt; that&amp;rsquo;s fast enough for runtime auto-install to feel acceptable&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;uv export&lt;/code&gt;&lt;/strong&gt; for generating pinned requirements files from &lt;code&gt;uv.lock&lt;/code&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;With traditional pip, auto-installing at runtime would take 30-60 seconds and feel terrible.&#10;With uv, it typically takes a second.&#10;That&amp;rsquo;s the difference between &amp;ldquo;annoying&amp;rdquo; and &amp;ldquo;barely noticeable.&amp;rdquo;&lt;/p&gt;&#10;&lt;h2 id="11-tradeoffs"&gt;11. Tradeoffs &lt;a class="anchor" href="#11-tradeoffs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This approach works well, but it&amp;rsquo;s still a tradeoff:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;There&amp;rsquo;s metadata to maintain.&lt;/strong&gt; The package-name-to-import-name mapping requires a lookup table with special cases like &lt;code&gt;piper-tts&lt;/code&gt; → &lt;code&gt;piper&lt;/code&gt; and &lt;code&gt;pydantic-ai-slim&lt;/code&gt; → &lt;code&gt;pydantic_ai&lt;/code&gt;. I wish &lt;code&gt;pyproject.toml&lt;/code&gt; had a field for this.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;The check only verifies importability, not version compatibility.&lt;/strong&gt; &lt;code&gt;find_spec&lt;/code&gt; tells you a module exists, not whether it&amp;rsquo;s the right version. In practice this hasn&amp;rsquo;t been a problem because uv&amp;rsquo;s pinned requirements handle versions, but it&amp;rsquo;s a theoretical gap.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Auto-install can fail in restricted environments.&lt;/strong&gt; Corporate machines, read-only containers, air-gapped systems—the opt-out exists for a reason.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Re-exec is a CLI pattern, not a library pattern.&lt;/strong&gt; &lt;code&gt;os.execvpe&lt;/code&gt; replaces the process, which means any shell hooks or signal handlers set up before the decorator runs are lost. Fine for CLI tools, would be problematic for libraries.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h2 id="12-not-dependency-injection"&gt;12. Not dependency injection &lt;a class="anchor" href="#12-not-dependency-injection" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I originally called this &amp;ldquo;dependency injection&amp;rdquo; in my head, but that&amp;rsquo;s the wrong term entirely.&#10;Dependency injection is about providing object dependencies through constructors or function parameters.&#10;This is something different.&lt;/p&gt;&#10;&lt;p&gt;The best names I&amp;rsquo;ve come up with:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;runtime optional dependency resolution&lt;/strong&gt; — accurate but a mouthful&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;on-demand extras installation&lt;/strong&gt; — emphasizes the user experience&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;lazy extras&lt;/strong&gt; — short, probably what I&amp;rsquo;d use in conversation&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;If there&amp;rsquo;s a standard term for this pattern, I haven&amp;rsquo;t found it.&lt;/p&gt;&#10;&lt;h2 id="13-conclusion"&gt;13. Conclusion &lt;a class="anchor" href="#13-conclusion" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The underlying principle is simple: &lt;strong&gt;the tool should know what it needs, and it should get it&lt;/strong&gt;.&#10;Users shouldn&amp;rsquo;t have to study a matrix of 13 optional extras to figure out which pip command to run.&#10;They should just type the command they want, and it should work.&lt;/p&gt;&#10;&lt;p&gt;uv&amp;rsquo;s speed and tooling model made this practical.&#10;A decorator made it ergonomic for command authors.&#10;Pre-commit hooks made it maintainable.&#10;And three environment-detection strategies made it work regardless of how the user installed the tool.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re building a Python CLI with optional features that keep growing, consider this pattern.&#10;Your users will thank you, mostly by never having to ask &amp;ldquo;which extras do I need?&amp;rdquo;&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;p&gt;&lt;em&gt;The code discussed here is part of &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt;, an open-source suite of AI-powered command-line tools.&#10;The implementation lives primarily in &lt;a href="https://github.com/basnijholt/agent-cli/blob/c9a67b484331b64733edba8d72d20523699c5a72/agent_cli/core/deps.py" target="_blank" rel="noopener"&gt;&lt;code&gt;agent_cli/core/deps.py&lt;/code&gt;&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;&#10;</description></item><item><title>Parallel agentic coding made trivial with `agent-cli dev`</title><link>https://www.nijho.lt/post/parallel-agentic-coding/</link><pubDate>Tue, 13 Jan 2026 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/parallel-agentic-coding/</guid><description>&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-the-just-use-worktrees-problem"&gt;1. The &amp;ldquo;just use worktrees&amp;rdquo; problem&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-why-parallel-development-matters-for-agentic-coding"&gt;2. Why parallel development matters for agentic coding&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-one-command-to-rule-them-all"&gt;3. One command to rule them all&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-it-knows-your-stack"&gt;4. It knows your stack&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-terminal-magic"&gt;5. Terminal magic&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-spawning-agents-with-prompts"&gt;6. Spawning agents with prompts&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-teaching-claude-to-spawn-agents"&gt;7. Teaching Claude to spawn agents&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-my-actual-workflow"&gt;8. My actual workflow&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-managing-environments"&gt;9. Managing environments&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#10-getting-started"&gt;10. Getting started&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#conclusion"&gt;Conclusion&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#links"&gt;Links&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-the-just-use-worktrees-problem"&gt;1. The &amp;ldquo;just use worktrees&amp;rdquo; problem &lt;a class="anchor" href="#1-the-just-use-worktrees-problem" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Last week, I was chatting with a friend about my &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;agentic coding workflow&lt;/a&gt;.&#10;I was sharing some of my best practices, and when the topic of parallel development came up, I said:&lt;/p&gt;&#10;&lt;p&gt;&amp;ldquo;Just use git worktrees with quickly installable environments.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;He stared at me blankly.&lt;/p&gt;&#10;&lt;p&gt;And then it hit me: for most developers who just &lt;code&gt;git add&lt;/code&gt;, &lt;code&gt;git commit&lt;/code&gt;, &lt;code&gt;git push&lt;/code&gt; and don&amp;rsquo;t touch their environment setup once it works, this is &lt;em&gt;not&lt;/em&gt; trivial.&#10;What seems obvious to me involves:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Understanding what git worktrees even are&lt;/li&gt;&#10;&lt;li&gt;Knowing the &lt;code&gt;git worktree add&lt;/code&gt; command and its flags&lt;/li&gt;&#10;&lt;li&gt;Remembering to fetch before creating&lt;/li&gt;&#10;&lt;li&gt;Creating a sibling directory structure&lt;/li&gt;&#10;&lt;li&gt;Running your package manager&amp;rsquo;s install command&lt;/li&gt;&#10;&lt;li&gt;Copying over &lt;code&gt;.env&lt;/code&gt; files&lt;/li&gt;&#10;&lt;li&gt;Setting up direnv or manually activating your virtual environment&lt;/li&gt;&#10;&lt;li&gt;Opening a new terminal tab&lt;/li&gt;&#10;&lt;li&gt;Navigating to the worktree&lt;/li&gt;&#10;&lt;li&gt;Finally launching your coding agent&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;That&amp;rsquo;s a lot of friction for something you want to do &lt;em&gt;frequently&lt;/em&gt; when using agentic tools.&lt;/p&gt;&#10;&lt;p&gt;So I built &lt;code&gt;agent-cli dev&lt;/code&gt;.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;code&gt;agent-cli dev new --agent --prompt &amp;quot;Fix the login bug&amp;quot;&lt;/code&gt; creates a complete, isolated development environment in one command.&#10;It creates a git worktree, installs dependencies, copies env files, sets up direnv, and opens your AI coding agent in a new terminal tab with your prompt already loaded—all automatically.&#10;No more manual setup, no more excuses to not work in parallel.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="2-why-parallel-development-matters-for-agentic-coding"&gt;2. Why parallel development matters for agentic coding &lt;a class="anchor" href="#2-why-parallel-development-matters-for-agentic-coding" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;In my &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;previous post on agentic coding&lt;/a&gt;, I described my workflow of running 5-6 Claude Code sessions simultaneously, each working on a different feature.&#10;This is how I can easily work on multiple features in parallel while waiting for an agent to process and generate code.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;The usual way:&lt;/strong&gt;&#10;Start feature A → wait for Claude to finish → review → move to feature B → repeat.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;The better way:&lt;/strong&gt;&#10;Start feature A in tab 1 → switch to tab 2, start feature B while A is working → switch to tab 3, start feature C → circle back to tab 1, review what Claude did → give feedback, move to next tab → repeat every 10-15 minutes.&lt;/p&gt;&#10;&lt;p&gt;This parallel approach allows me to go much faster.&#10;But it only works if each tab has its own &lt;strong&gt;completely isolated and working environment&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s where &lt;a href="https://www.nijho.lt/post/git-worktree/"&gt;git worktrees&lt;/a&gt; come in.&#10;A worktree gives you a separate working directory for a branch, sharing the same git history but with independent file states.&lt;/p&gt;&#10;&lt;p&gt;The problem is the &lt;em&gt;setup&lt;/em&gt;.&#10;Every time you want to start a new feature:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# The manual way (don&amp;#39;t do this)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git fetch origin&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git worktree add ../my-project-worktrees/cool-feature -b cool-feature origin/main&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../my-project-worktrees/cool-feature&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uv sync --all-extras &lt;span class="c1"&gt;# or npm install, or poetry install, or...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cp ../.env .env&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Now open a new terminal tab...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Navigate there...&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Finally start claude&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After the 100th(?) time doing this manually, I knew I needed to automate it.&lt;/p&gt;&#10;&lt;h2 id="3-one-command-to-rule-them-all"&gt;3. One command to rule them all &lt;a class="anchor" href="#3-one-command-to-rule-them-all" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s what I built:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new --agent&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s it.&#10;One command.&#10;Here&amp;rsquo;s what it looks like:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Generated branch name: cool-crane&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Creating worktree &lt;span class="k"&gt;for&lt;/span&gt; branch &lt;span class="s1"&gt;&amp;#39;cool-crane&amp;#39;&lt;/span&gt;...&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Running: git fetch origin&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Running: git worktree add -b cool-crane ~/Work/project-worktrees/cool-crane origin/main&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✓ Created worktree at ~/Work/project-worktrees/cool-crane&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✓ Copied env file&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt;: .env.example, .envrc&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Detected Python project with uv&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Running: uv sync --all-extras&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Running: direnv allow&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✓ direnv: allowed existing .envrc&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;✓ Started claude in new iterm2 tab&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;╭─────────────────────────────────────────────── Success ───────────────────────────────────────────────╮&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│ Dev environment created: ~/Work/project-worktrees/cool-crane │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;│ Branch: cool-crane │&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;4 seconds later, I have a new terminal tab with Claude Code already running in a fresh, isolated environment.&#10;Ready to work on my next feature.&lt;/p&gt;&#10;&lt;p&gt;What happens under the hood:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Generates a random branch name&lt;/strong&gt; like &lt;code&gt;clever-fox&lt;/code&gt; (you can just set the name yourself of course)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Creates the git worktree&lt;/strong&gt; in a sibling directory&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Detects the project type&lt;/strong&gt; and runs the appropriate setup (&lt;code&gt;uv sync&lt;/code&gt;, &lt;code&gt;npm install&lt;/code&gt;, etc.)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Copies &lt;code&gt;.env&lt;/code&gt; files&lt;/strong&gt; from the main repo&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Sets up direnv&lt;/strong&gt; so the environment activates automatically&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Opens a new terminal tab&lt;/strong&gt; (works with tmux, zellij, iTerm2, and more)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Launches your coding agent&lt;/strong&gt; in that tab&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The Docker-style random names (&lt;code&gt;clever-fox&lt;/code&gt;, &lt;code&gt;happy-bear&lt;/code&gt;, &lt;code&gt;swift-owl&lt;/code&gt;) there just for those too lazy to pick a name.&#10;Just &lt;code&gt;agent-cli dev new --agent&lt;/code&gt; and start coding.&lt;/p&gt;&#10;&lt;h2 id="4-it-knows-your-stack"&gt;4. It knows your stack &lt;a class="anchor" href="#4-it-knows-your-stack" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The command auto-detects your project type:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;If it sees&amp;hellip;&lt;/th&gt;&#10;&lt;th&gt;It runs&amp;hellip;&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;uv.lock&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;uv sync --all-extras&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;pixi.lock&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;pixi install&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;poetry.lock&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;poetry install&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;requirements.yaml&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;unidep install -e .&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;package-lock.json&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;Cargo.toml&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;cargo build&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;code&gt;go.mod&lt;/code&gt;&lt;/td&gt;&#10;&lt;td&gt;&lt;code&gt;go mod download&lt;/code&gt;&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;For my projects using &lt;a href="https://github.com/basnijholt/unidep" target="_blank" rel="noopener"&gt;unidep&lt;/a&gt;, it even detects monorepos and adjusts the command accordingly.&lt;/p&gt;&#10;&lt;p&gt;It also generates the right &lt;code&gt;.envrc&lt;/code&gt; for &lt;a href="https://direnv.net/" target="_blank" rel="noopener"&gt;direnv&lt;/a&gt;.&#10;For a Python project with uv, that&amp;rsquo;s &lt;code&gt;source .venv/bin/activate&lt;/code&gt;.&#10;For a conda/unidep project, it&amp;rsquo;s the full micromamba activation.&#10;For Nix projects, it&amp;rsquo;s &lt;code&gt;use flake&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;This means when you &lt;code&gt;cd&lt;/code&gt; into the worktree later, your environment is automatically activated.&#10;No more &lt;code&gt;source .venv/bin/activate&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="5-terminal-magic"&gt;5. Terminal magic &lt;a class="anchor" href="#5-terminal-magic" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The real magic is opening a new terminal tab with the agent already running.&lt;/p&gt;&#10;&lt;p&gt;The command detects which terminal you&amp;rsquo;re in and uses the appropriate API—whether that&amp;rsquo;s iTerm2, Kitty, Warp, or GNOME Terminal.&#10;It even works with terminal multiplexers like &lt;strong&gt;tmux&lt;/strong&gt; and &lt;strong&gt;zellij&lt;/strong&gt; (my current favorite), opening a new window/tab in your existing session.&lt;/p&gt;&#10;&lt;h2 id="6-spawning-agents-with-prompts"&gt;6. Spawning agents with prompts &lt;a class="anchor" href="#6-spawning-agents-with-prompts" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The &lt;code&gt;--prompt&lt;/code&gt; option transforms how I spawn parallel agents.&#10;Instead of just opening a blank Claude session, I can give the new agent its task immediately:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new fix-auth-bug --agent --prompt &lt;span class="s2"&gt;&amp;#34;Fix the login validation bug in auth.py. The issue is that empty passwords pass validation.&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The agent opens with the prompt already loaded and starts working immediately.&#10;No copy-pasting, no context switching, no &amp;ldquo;let me explain what I need you to do.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;For longer, more detailed prompts (which you should use for complex tasks), there&amp;rsquo;s &lt;code&gt;--prompt-file&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new new-feature --agent --prompt-file .claude/spawn-prompt.md&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is particularly powerful when you&amp;rsquo;re in a Claude session and discover a feature that needs work but is unrelated to your current task.&#10;Instead of context-switching or noting it for later, you can spawn a new agent right there:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;Write a detailed prompt to a file (Claude can do this)&lt;/li&gt;&#10;&lt;li&gt;Run &lt;code&gt;agent-cli dev new unrelated-feature --agent --prompt-file .claude/spawn-prompt.md&lt;/code&gt;&lt;/li&gt;&#10;&lt;li&gt;A new terminal tab opens with a fresh Claude session already working on the task&lt;/li&gt;&#10;&lt;li&gt;Continue with your original work&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;The spawned agent works independently in its own branch while you stay focused.&lt;/p&gt;&#10;&lt;h2 id="7-teaching-claude-to-spawn-agents"&gt;7. Teaching Claude to spawn agents &lt;a class="anchor" href="#7-teaching-claude-to-spawn-agents" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s where it gets really interesting: you can teach Claude Code itself how to spawn parallel agents.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev install-skill&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This installs a &lt;a href="https://docs.anthropic.com/en/docs/claude-code/skills" target="_blank" rel="noopener"&gt;Claude Code skill&lt;/a&gt; that teaches Claude how to use &lt;code&gt;agent-cli dev&lt;/code&gt;.&#10;The skill includes detailed examples and prompt templates following Anthropic&amp;rsquo;s &lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview" target="_blank" rel="noopener"&gt;prompt engineering guide&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;With the skill installed, you can say things like:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&amp;ldquo;Work on auth, payments, and notifications in parallel&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;This feature is unrelated to what we&amp;rsquo;re doing—spawn a new agent for it&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;&amp;ldquo;Split this refactoring by module and work on them simultaneously&amp;rdquo;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Claude will write well-structured prompts (using proper XML tags, clear context, explicit requirements) and spawn the agents for you.&#10;Each spawned agent writes a report to &lt;code&gt;.claude/REPORT.md&lt;/code&gt; when done, so you can easily review what happened.&lt;/p&gt;&#10;&lt;p&gt;One of my favorite patterns: I ask Claude to look at the latest 10 open GitHub issues and spawn an agent for each one.&#10;Ten independent parallel environments, set up in seconds, each working on a different issue.&#10;That&amp;rsquo;s the kind of leverage this enables.&lt;/p&gt;&#10;&lt;h2 id="8-my-actual-workflow"&gt;8. My actual workflow &lt;a class="anchor" href="#8-my-actual-workflow" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s how I use this in practice.&#10;I&amp;rsquo;m working on &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli&lt;/a&gt; itself, and I want to add three features:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tab 1: New RAG feature&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new rag-improvements --agent --prompt &lt;span class="s2"&gt;&amp;#34;Improve RAG chunking strategy for better retrieval accuracy&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tab 2: Fix a bug in the transcribe command&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new fix-transcribe-vad --agent --prompt &lt;span class="s2"&gt;&amp;#34;Fix VAD detection cutting off words at sentence boundaries&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tab 3: Refactor the config system&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new config-refactor --agent --prompt &lt;span class="s2"&gt;&amp;#34;Refactor config.py to use a single Config class with lazy loading&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Three commands, three isolated environments, three Claude Code sessions already working on their tasks.&lt;/p&gt;&#10;&lt;p&gt;I cycle through the tabs every 10-15 minutes:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Review what Claude did&lt;/li&gt;&#10;&lt;li&gt;Give voice feedback using my &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;transcription workflow&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;Move to the next tab&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;When a feature is done, I have Claude open a PR from that branch.&#10;When the PR is merged:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev clean --merged&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This uses the GitHub CLI to check which branches have merged PRs, then removes those worktrees and deletes the branches.&#10;No more zombie worktrees cluttering my disk.&lt;/p&gt;&#10;&lt;h2 id="9-managing-environments"&gt;9. Managing environments &lt;a class="anchor" href="#9-managing-environments" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;A few other commands I use regularly:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# List all worktrees&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev list&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Navigate to a worktree&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;agent-cli dev path clever-fox&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Remove a worktree&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev rm clever-fox&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Force removal + delete branch&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev rm clever-fox --force --delete-branch&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Check what&amp;#39;s available on your system&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev doctor&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;doctor&lt;/code&gt; command is handy for seeing which terminals, editors, and coding agents are installed and detected.&lt;/p&gt;&#10;&lt;h2 id="10-getting-started"&gt;10. Getting started &lt;a class="anchor" href="#10-getting-started" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;code&gt;agent-cli dev&lt;/code&gt; is part of the &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli&lt;/a&gt; package:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# With uv (recommended)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uv tool install agent-cli&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then in any git repository:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Check what&amp;#39;s available&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev doctor&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Create your first parallel environment with a task&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev new my-feature --agent --prompt &lt;span class="s2"&gt;&amp;#34;Add user profile page with avatar upload&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Or install the skill so Claude can spawn agents for you&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;agent-cli dev install-skill&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Supported coding agents:&lt;/strong&gt; Claude Code, Codex, Gemini, Aider, GitHub Copilot, Continue, OpenCode, and Cursor Agent.&#10;Use &lt;code&gt;--with-agent gemini&lt;/code&gt; to pick a specific one, or set a default in your config.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Supported terminals:&lt;/strong&gt; tmux, zellij, iTerm2, Terminal.app, Kitty, Warp, and GNOME Terminal.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;Supported editors:&lt;/strong&gt; Cursor, VS Code, Zed, PyCharm (and other JetBrains IDEs), Neovim, Vim, Emacs, Sublime Text, and nano.&lt;/p&gt;&#10;&lt;h2 id="conclusion"&gt;Conclusion &lt;a class="anchor" href="#conclusion" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The barrier between &amp;ldquo;I want to work on this feature&amp;rdquo; and &amp;ldquo;I&amp;rsquo;m actually working on it in an isolated environment&amp;rdquo; should be zero.&lt;/p&gt;&#10;&lt;p&gt;Before:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;2-5 minutes of manual setup per feature&lt;/li&gt;&#10;&lt;li&gt;Often skipped because &amp;ldquo;it&amp;rsquo;s not worth it for a small change&amp;rdquo;&lt;/li&gt;&#10;&lt;li&gt;Mental overhead remembering all the steps&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;After:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;One command, few seconds&lt;/li&gt;&#10;&lt;li&gt;No excuse not to work in isolation&lt;/li&gt;&#10;&lt;li&gt;More parallel features, faster iteration&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;If you&amp;rsquo;re using agentic coding tools and not yet working in parallel, you&amp;rsquo;re leaving productivity on the table.&#10;And if the setup friction was stopping you—well, now it&amp;rsquo;s gone.&lt;/p&gt;&#10;&lt;p&gt;Try it out and let me know what you think!&#10;The code is open-source at &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;github.com/basnijholt/agent-cli&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="links"&gt;Links &lt;a class="anchor" href="#links" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;agent-cli on GitHub&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://agent-cli.nijho.lt/commands/dev/" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli dev&lt;/code&gt; documentation&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/skills" target="_blank" rel="noopener"&gt;Claude Code skills&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview" target="_blank" rel="noopener"&gt;Anthropic prompt engineering guide&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/git-worktree/"&gt;My post on git worktrees&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;My post on agentic coding&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://zellij.dev/" target="_blank" rel="noopener"&gt;Zellij&lt;/a&gt; / &lt;a href="https://github.com/tmux/tmux" target="_blank" rel="noopener"&gt;tmux&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://direnv.net/" target="_blank" rel="noopener"&gt;direnv&lt;/a&gt;&lt;/li&gt;&#10;&lt;/ul&gt;&#10;</description></item><item><title>SOTA RAG &amp; Memory without the database: Files, Git, and simple folders</title><link>https://www.nijho.lt/post/file-based-rag-memory/</link><pubDate>Wed, 10 Dec 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/file-based-rag-memory/</guid><description>&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-the-amnesia-problem"&gt;1. The amnesia problem&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-learning-from-failure-ai-journal"&gt;2. Learning from failure: AI Journal&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-how-i-studied-sota-clone-read-re-implement"&gt;3. How I studied SOTA: clone, read, re-implement&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-the-dependency-problem-why-onnx-over-pytorch"&gt;4. The dependency problem: why ONNX over PyTorch&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-rag-just-let-me-grep-it-kind-of"&gt;5. RAG: just let me grep it (kind of)&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#the-architecture-openai-compatibility"&gt;The architecture: OpenAI compatibility&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#two-stage-retrieval-the-sota-approach"&gt;Two-stage retrieval: the SOTA approach&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-memory-files-over-databases"&gt;6. Memory: files over databases&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#git-integration-versioning-memory-"&gt;Git integration: versioning memory 🕰️&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-reconciliation-loop"&gt;The reconciliation loop&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#the-tiny-detail-that-broke-everything"&gt;The tiny detail that broke everything&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-why-building-this-was-necessary"&gt;7. Why building this was necessary&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#privacy"&gt;Privacy&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#integration"&gt;Integration&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#dependencies"&gt;Dependencies&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-try-it-yourself"&gt;8. Try it yourself&lt;/a&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#try-memory"&gt;Try Memory&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#try-rag"&gt;Try RAG&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10; &lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-what-i-learned"&gt;9. What I learned&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-the-amnesia-problem"&gt;1. The amnesia problem &lt;a class="anchor" href="#1-the-amnesia-problem" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;If you&amp;rsquo;ve read my posts on &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;agentic coding&lt;/a&gt; or my &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;local AI journey&lt;/a&gt;, you know I am all-in on using AI to control my computer and write software.&#10;But one critical piece was missing: &lt;strong&gt;Context&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;LLMs are powerful but stateless.&#10;Every time I start a new chat, the model forgets who I am, what I&amp;rsquo;m working on, and that I prefer &lt;a href="https://www.nijho.lt/post/functional-python/"&gt;functional Python code&lt;/a&gt; over object-oriented styles.&#10;Worse, it also forgets the thing I just told it five minutes ago in another window.&lt;/p&gt;&#10;&lt;p&gt;The standard solution to this is &lt;a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation" target="_blank" rel="noopener"&gt;RAG (Retrieval-Augmented Generation)&lt;/a&gt; and &lt;a href="https://arxiv.org/abs/2310.08560" target="_blank" rel="noopener"&gt;Memory&lt;/a&gt;.&#10;But when I looked at the available tools, I hit a wall.&lt;/p&gt;&#10;&lt;p&gt;Most solutions fell into two buckets:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;SaaS / Web UIs:&lt;/strong&gt; Upload your files to a cloud service or use a specific web chat interface that locks you in.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Developer Libraries:&lt;/strong&gt; &amp;ldquo;Just use LangChain/LlamaIndex!&amp;rdquo; they say. Great, now I have to write a Python script every time I want to ask a question about a PDF?&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t want a library. I didn&amp;rsquo;t want a web UI.&#10;&lt;strong&gt;I wanted a folder.&lt;/strong&gt;&#10;I wanted to drop a file into &lt;code&gt;~/my-docs&lt;/code&gt;, and have my AI instantly know about it.&#10;And I wanted my AI&amp;rsquo;s &amp;ldquo;memory&amp;rdquo; to be a file I can edit, not a hidden vector in a database I can&amp;rsquo;t inspect.&lt;/p&gt;&#10;&lt;p&gt;So I decided to build my own file-based solution.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I built &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt; features that combine two ideas:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;File-based storage:&lt;/strong&gt; Documents in a folder, memories as Markdown files with Git versioning.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;OpenAI-compatible proxy:&lt;/strong&gt; Works with &lt;em&gt;any&lt;/em&gt; OpenAI-compatible tool—Cursor, Cline, Open WebUI, LibreChat, Lobe Chat, your terminal—without lock-in.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;Tools like Open WebUI have built-in RAG, but that forces you to use only Open WebUI.&#10;My proxy means I can switch tools whenever I want, and they all share the same memory and document index.&lt;/p&gt;&#10;&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="2-learning-from-failure-ai-journal"&gt;2. Learning from failure: AI Journal &lt;a class="anchor" href="#2-learning-from-failure-ai-journal" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This isn&amp;rsquo;t my first attempt at building a memory system.&lt;/p&gt;&#10;&lt;p&gt;A few months ago, I built &lt;a href="https://github.com/basnijholt/aijournal" target="_blank" rel="noopener"&gt;&lt;strong&gt;AI Journal&lt;/strong&gt;&lt;/a&gt;—an ambitious local-first journaling app where an AI would read your entries and build a &amp;ldquo;living self-model&amp;rdquo; of who you are.&#10;It had sophisticated features: a structured &amp;ldquo;persona core,&amp;rdquo; typed &amp;ldquo;claim atoms&amp;rdquo; with evidence links, four organizational layers (L1→L4), recency-aware scoring with decay functions, and Git-based time travel to &amp;ldquo;ask questions of your younger self.&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;The architecture seemed solid.&#10;The problem was simpler: &lt;strong&gt;local models aren&amp;rsquo;t good enough yet.&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;I designed the whole thing to run entirely on Ollama with my dual RTX 3090s.&#10;But even with a 48GB of VRAM, the models couldn&amp;rsquo;t reliably extract structured claims, handle the multi-step reconciliation, or maintain coherence across long conversations.&#10;Whether the design was right, I still don&amp;rsquo;t know—but the execution definitely hit the wall of current local LLM capabilities.&lt;/p&gt;&#10;&lt;p&gt;The lesson: I tried to build the entire system at once without validating the core concepts and individual ideas first.&#10;This time, I took a different approach: &lt;strong&gt;tackle it problem by problem.&lt;/strong&gt;&#10;Instead of designing a complete system upfront, I would validate each piece—chunking, retrieval, re-ranking, memory reconciliation—before combining them.&#10;The experience and UX would be my own, but the algorithms would be adapted from established projects.&lt;/p&gt;&#10;&lt;h2 id="3-how-i-studied-sota-clone-read-re-implement"&gt;3. How I studied SOTA: clone, read, re-implement &lt;a class="anchor" href="#3-how-i-studied-sota-clone-read-re-implement" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My approach was more methodical this time.&#10;I cloned five major frameworks into the same workspace as &lt;code&gt;agent-cli&lt;/code&gt;:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/run-llama/llama_index" target="_blank" rel="noopener"&gt;LlamaIndex&lt;/a&gt;&lt;/strong&gt;: The OG RAG framework&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/langchain-ai/langchain" target="_blank" rel="noopener"&gt;LangChain&lt;/a&gt;&lt;/strong&gt;: The kitchen-sink approach&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/letta-ai/letta" target="_blank" rel="noopener"&gt;Letta&lt;/a&gt;&lt;/strong&gt; (formerly MemGPT): Stateful agents with long-term memory&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/mem0ai/mem0" target="_blank" rel="noopener"&gt;Mem0&lt;/a&gt;&lt;/strong&gt;: Focused specifically on memory extraction&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/pydantic/pydantic-ai" target="_blank" rel="noopener"&gt;PydanticAI&lt;/a&gt;&lt;/strong&gt;: Clean, typed agent framework&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t read through all this code myself—that would take months.&#10;Instead, I used AI coding agents to search the codebases and write summaries of the technical implementation details:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Gemini CLI&lt;/strong&gt; (Gemini 3.0 Pro)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; (Opus 4.5)&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Codex CLI&lt;/strong&gt; (GPT-5.1-Codex-Max)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I fed those summaries into &lt;strong&gt;ChatGPT 5 Pro&lt;/strong&gt; together with my high level plan of what I wanted to create, and it created an implementation plan.&#10;Then I took that plan back to the coding agents for implementation.&#10;I personally found Opus 4.5 the best coding model, but GPT‑5.1 Codex Max was the best at reviewing the code and actually identifying critical bugs.&lt;/p&gt;&#10;&lt;p&gt;For validation, I used &lt;a href="https://github.com/basnijholt/clip-files" target="_blank" rel="noopener"&gt;&lt;code&gt;clip-files&lt;/code&gt;&lt;/a&gt; to copy the agent-cli source code plus the architecture documents (like &lt;a href="https://github.com/basnijholt/agent-cli/blob/main/docs/architecture/memory.md" target="_blank" rel="noopener"&gt;memory.md&lt;/a&gt; and &lt;a href="https://github.com/basnijholt/agent-cli/blob/main/docs/architecture/rag.md" target="_blank" rel="noopener"&gt;rag.md&lt;/a&gt;) into ChatGPT 5 Pro to validate the approach.&lt;/p&gt;&#10;&lt;p&gt;The AI did the searching and implementing; I did the understanding and decision-making.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;The key insight:&lt;/strong&gt; Existing frameworks are incredibly over-engineered for my use case, but the &lt;em&gt;core algorithms&lt;/em&gt; are gold.&#10;My job was to extract the essence and re-implement it cleanly.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="4-the-dependency-problem-why-onnx-over-pytorch"&gt;4. The dependency problem: why ONNX over PyTorch &lt;a class="anchor" href="#4-the-dependency-problem-why-onnx-over-pytorch" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;One thing became immediately clear when going through these frameworks: &lt;strong&gt;they&amp;rsquo;re massive&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;Installing LlamaIndex with all its dependencies pulls in PyTorch.&#10;PyTorch alone is &lt;strong&gt;2-3GB&lt;/strong&gt;.&#10;Add transformers, sentence-transformers, and the kitchen sink, and you&amp;rsquo;re looking at an &lt;strong&gt;8GB+ environment&lt;/strong&gt; just to ask questions about some PDFs.&lt;/p&gt;&#10;&lt;p&gt;This felt excessive for a CLI tool.&lt;/p&gt;&#10;&lt;p&gt;One of my core design goals was &lt;strong&gt;minimal dependencies&lt;/strong&gt;.&#10;I wanted &lt;code&gt;agent-cli&lt;/code&gt; to install in seconds, not minutes.&#10;I wanted it to work on my Mac, my NixOS server, and my friend&amp;rsquo;s laptop without downloading half the internet.&lt;/p&gt;&#10;&lt;p&gt;The solution: &lt;strong&gt;ONNX Runtime&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;ONNX Runtime is ~200MB and runs inference on basically anything—CPU, CUDA, even Apple Silicon.&#10;For embeddings, I use &lt;a href="https://developers.googleblog.com/introducing-embeddinggemma/" target="_blank" rel="noopener"&gt;&lt;code&gt;embeddinggemma&lt;/code&gt;&lt;/a&gt; running locally via &lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;llama.cpp&lt;/a&gt;.&#10;For re-ranking, I use a cross-encoder exported to ONNX (&lt;code&gt;Xenova/ms-marco-MiniLM-L-6-v2&lt;/code&gt;).&lt;/p&gt;&#10;&lt;p&gt;The result: &lt;code&gt;pip install agent-cli[rag]&lt;/code&gt; takes about 30 seconds and adds ~300MB.&#10;The key insight: embeddings and re-ranking are cheap operations.&#10;You don&amp;rsquo;t need a GPU for them—CPU is perfectly fine.&#10;PyTorch with CUDA support can easily reach 2-3GB+; ONNX Runtime is ~200MB and runs anywhere.&#10;Since I&amp;rsquo;m not training models, just running inference on small encoders, ONNX is the obvious choice.&lt;/p&gt;&#10;&lt;h2 id="5-rag-just-let-me-grep-it-kind-of"&gt;5. RAG: just let me grep it (kind of) &lt;a class="anchor" href="#5-rag-just-let-me-grep-it-kind-of" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My requirement for RAG was simple: &lt;strong&gt;Files on disk are the source of truth.&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;I didn&amp;rsquo;t want to manage an ingestion pipeline or trigger APIs.&#10;I implemented a &lt;a href="https://github.com/basnijholt/agent-cli/blob/main/docs/architecture/rag.md" target="_blank" rel="noopener"&gt;RAG proxy&lt;/a&gt; that uses OS-level file watchers (&lt;code&gt;watchfiles&lt;/code&gt;).&#10;Here is the workflow:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;I drop a PDF, Markdown, or Python file into &lt;code&gt;~/rag_docs&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;The system detects the &lt;code&gt;Create&lt;/code&gt; event.&lt;/li&gt;&#10;&lt;li&gt;It chunks the text and embeds it into a local ChromaDB instance.&lt;/li&gt;&#10;&lt;li&gt;It&amp;rsquo;s immediately searchable.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;When moving or deleting files, the system updates the index accordingly.&lt;/p&gt;&#10;&lt;h3 id="the-architecture-openai-compatibility"&gt;The architecture: OpenAI compatibility &lt;a class="anchor" href="#the-architecture-openai-compatibility" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The &amp;ldquo;secret sauce&amp;rdquo; isn&amp;rsquo;t the retrieval itself; it&amp;rsquo;s how I expose it.&#10;Instead of building a custom chat app, I built an &lt;strong&gt;OpenAI-compatible proxy&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;div class="mermaid"&gt;flowchart LR&#10; Client["Client&lt;br/&gt;(Cursor, Open WebUI)"] --&gt; Retrieve&#10;&#10; subgraph Proxy ["RAG Proxy"]&#10; direction TB&#10; Retrieve --&gt; Augment["Augment &amp; Forward"]&#10; Chroma[("ChromaDB")] -.-&gt; Retrieve&#10; Watcher["File Watcher"] --&gt; Chroma&#10; end&#10;&#10; Augment --&gt; Upstream["Upstream LLM"]&lt;/div&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Point any OpenAI-compatible tool at your local proxy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8000/v1&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s it. Any tool that supports custom OpenAI endpoints—Cursor, Cline, Open WebUI, LibreChat—instantly gains the ability to &amp;ldquo;read&amp;rdquo; my document folder.&lt;/p&gt;&#10;&lt;h3 id="two-stage-retrieval-the-sota-approach"&gt;Two-stage retrieval: the SOTA approach &lt;a class="anchor" href="#two-stage-retrieval-the-sota-approach" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;I looked at LlamaIndex here.&#10;Standard &amp;ldquo;semantic search&amp;rdquo; (using just embeddings/bi-encoders) is often disappointing.&#10;It finds text that &lt;em&gt;looks&lt;/em&gt; similar but isn&amp;rsquo;t actually relevant.&lt;/p&gt;&#10;&lt;p&gt;A common pattern in modern RAG systems is &lt;strong&gt;Two-Stage Retrieval&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Fast Stage:&lt;/strong&gt; Retrieve 3x candidates using standard embeddings (&lt;code&gt;text-embedding-3-small&lt;/code&gt; or local models like &lt;a href="https://developers.googleblog.com/introducing-embeddinggemma/" target="_blank" rel="noopener"&gt;&lt;code&gt;embeddinggemma&lt;/code&gt;&lt;/a&gt;).&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Smart Stage:&lt;/strong&gt; Use a &lt;strong&gt;Cross-Encoder&lt;/strong&gt; to re-rank the results.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;A cross-encoder looks at the query and the document &lt;em&gt;together&lt;/em&gt; and outputs a relevance score.&#10;It&amp;rsquo;s computationally heavier, but since I&amp;rsquo;m running this locally on my RTX 3090 (or even CPU via ONNX), the 50ms latency penalty is worth it for results that are actually useful.&lt;/p&gt;&#10;&lt;p&gt;This isn&amp;rsquo;t a new idea, but implementing it cleanly with ONNX avoided the huge dependency tax.&lt;/p&gt;&#10;&lt;h2 id="6-memory-files-over-databases"&gt;6. Memory: files over databases &lt;a class="anchor" href="#6-memory-files-over-databases" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This is where my approach diverges most from standard tools like Mem0.&#10;Most memory systems treat memories as database rows.&#10;If the AI remembers &amp;ldquo;User likes pizza,&amp;rdquo; that&amp;rsquo;s a vector in a DB.&#10;If it hallucinates and records &amp;ldquo;User hates pizza,&amp;rdquo; good luck fixing it.&#10;You need to write a script to query the DB and delete the row.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;I prefer data I can inspect.&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;My memory system stores every fact as a &lt;strong&gt;Markdown file&lt;/strong&gt; with YAML front matter.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;550e8400-e29b-41d4-a716-446655440000&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;memory&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;2025-12-15T10:00:00Z&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;default&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nn"&gt;---&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;The user prefers using &amp;#39;uv&amp;#39; for Python package management.&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="git-integration-versioning-memory-"&gt;Git integration: versioning memory 🕰️ &lt;a class="anchor" href="#git-integration-versioning-memory-" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Because memories are just files, I added a feature that I haven&amp;rsquo;t seen anywhere else: &lt;strong&gt;Automatic Git Versioning&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;Every time the AI learns a new fact, updates an old one, or deletes a contradiction, the system automatically commits the change to a local git repository.&lt;/p&gt;&#10;&lt;p&gt;This leads to incredible capabilities:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Inspectability:&lt;/strong&gt; I can &lt;code&gt;git log&lt;/code&gt; and see exactly &lt;em&gt;when&lt;/em&gt; the AI learned something.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Correction:&lt;/strong&gt; If the AI learns something wrong, I open the markdown file in VS Code, edit it, and save.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Reversion:&lt;/strong&gt; Agent hallucinated wildly yesterday? &lt;code&gt;git reset --hard HEAD~1&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;h3 id="the-reconciliation-loop"&gt;The reconciliation loop &lt;a class="anchor" href="#the-reconciliation-loop" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;The logic for storing memories isn&amp;rsquo;t just &amp;ldquo;append to file.&amp;rdquo;&#10;Digging into Mem0 (and Letta) showed me that memory systems need active &lt;strong&gt;reconciliation&lt;/strong&gt;.&#10;When I say &amp;ldquo;Actually, I switched to nix instead of uv,&amp;rdquo; the system:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&lt;strong&gt;Retrieves&lt;/strong&gt; existing memories about package managers.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Decides&lt;/strong&gt; (using a lightweight LLM) whether to &lt;code&gt;ADD&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Executes&lt;/strong&gt; the file operation (moving the old fact to a &lt;code&gt;deleted/&lt;/code&gt; folder for audit trails).&lt;/li&gt;&#10;&lt;/ol&gt;&#10;&lt;p&gt;This keeps the memory bank clean and contradictory-free, unlike simple vector stores that just accumulate conflicting junk over time.&lt;/p&gt;&#10;&lt;h3 id="the-tiny-detail-that-broke-everything"&gt;The tiny detail that broke everything &lt;a class="anchor" href="#the-tiny-detail-that-broke-everything" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;After implementing the memory system—mostly modeled after Mem0—I noticed it didn&amp;rsquo;t work as well.&#10;After a lot of debugging, I found a tiny difference: I was asking the model for structured data via a tool call, whereas Mem0 uses freeform JSON that the LLM returns directly.&lt;/p&gt;&#10;&lt;p&gt;Since I use PydanticAI, fixing this was a one-line change:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model_cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UPDATE_MEMORY_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MemoryDecision&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PromptedOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MemoryDecision&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="c1"&gt;# JSON mode instead of tool calls&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This is exactly why reading the source code matters—not just the algorithms, but the tiny details that make them work.&lt;/p&gt;&#10;&lt;h2 id="7-why-building-this-was-necessary"&gt;7. Why building this was necessary &lt;a class="anchor" href="#7-why-building-this-was-necessary" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;You might ask: &amp;ldquo;Bas, why write thousands of lines of code for this? Why not just use ChatGPT&amp;rsquo;s memory or install LangChain?&amp;rdquo;&lt;/p&gt;&#10;&lt;p&gt;Three reasons: &lt;strong&gt;Privacy&lt;/strong&gt;, &lt;strong&gt;Integration&lt;/strong&gt;, and &lt;strong&gt;Dependencies&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;h3 id="privacy"&gt;Privacy &lt;a class="anchor" href="#privacy" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;I want to feed my AI my financial records, my health data, and my private journals.&#10;I am not comfortable sending that to OpenAI&amp;rsquo;s long-term storage or some VC-backed startup&amp;rsquo;s vector cloud.&#10;With &lt;code&gt;agent-cli&lt;/code&gt;, the vectors live in &lt;code&gt;~/.cache/agent-cli/chroma&lt;/code&gt;, the files live in &lt;code&gt;~/documents&lt;/code&gt;, and the only thing leaving my machine is the specific context needed for a specific query (if I use a cloud model) or nothing at all (if I use Ollama/&lt;a href="https://www.nijho.lt/post/llama-nixos/"&gt;llama.cpp&lt;/a&gt;).&lt;/p&gt;&#10;&lt;h3 id="integration"&gt;Integration &lt;a class="anchor" href="#integration" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;By building this as an API proxy, I solved the &amp;ldquo;fragmented tools&amp;rdquo; problem.&#10;Any tool that supports custom OpenAI-compatible endpoints works out of the box:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;agent-cli chat&lt;/code&gt;&lt;/strong&gt; in the terminal&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Cursor&lt;/strong&gt; or &lt;strong&gt;Cline&lt;/strong&gt; for agentic coding&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://openwebui.com/" target="_blank" rel="noopener"&gt;Open WebUI&lt;/a&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;a href="https://www.librechat.ai/" target="_blank" rel="noopener"&gt;LibreChat&lt;/a&gt;&lt;/strong&gt;, or &lt;strong&gt;&lt;a href="https://lobechat.com/" target="_blank" rel="noopener"&gt;Lobe Chat&lt;/a&gt;&lt;/strong&gt; in the browser&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Because my system speaks &amp;ldquo;OpenAI,&amp;rdquo; all of these tools share the &lt;em&gt;same&lt;/em&gt; memory and the &lt;em&gt;same&lt;/em&gt; document index.&#10;If I tell the terminal agent &amp;ldquo;I&amp;rsquo;m working on Project X,&amp;rdquo; and then switch to Cursor, Cursor knows about Project X.&#10;I can try a new chat UI tomorrow without losing anything—just point it to &lt;code&gt;localhost:8000&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h3 id="dependencies"&gt;Dependencies &lt;a class="anchor" href="#dependencies" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;To me, it just doesn&amp;rsquo;t feel right that asking questions about a PDF requires downloading 8GB of PyTorch.&#10;By choosing ONNX Runtime over the PyTorch ecosystem, I kept the entire install lightweight and fast.&#10;This matters when you want to run the system on a cheap VPS or your friend&amp;rsquo;s laptop.&lt;/p&gt;&#10;&lt;h2 id="8-try-it-yourself"&gt;8. Try it yourself &lt;a class="anchor" href="#8-try-it-yourself" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;The entire system is open-source and available in &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h3 id="try-memory"&gt;Try Memory &lt;a class="anchor" href="#try-memory" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Get an LLM that remembers you using &lt;a href="https://ollama.com" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 1. Pull models and start proxy&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ollama pull embeddinggemma:300m &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ollama pull gpt-oss:20b&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uvx -p 3.13 --from &lt;span class="s2"&gt;&amp;#34;agent-cli[memory]&amp;#34;&lt;/span&gt; agent-cli memory proxy &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --memory-path ./my-memories &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --openai-base-url http://localhost:11434/v1 &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --embedding-model embeddinggemma:300m&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 2. Chat from the terminal (env vars point to proxy)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8100/v1 &lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dummy&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Tell it something about yourself&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uvx openai api chat.completions.create -m gpt-oss:20b -g user &lt;span class="s2"&gt;&amp;#34;My name is Alice and I love hiking&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Later, ask if it remembers&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uvx openai api chat.completions.create -m gpt-oss:20b -g user &lt;span class="s2"&gt;&amp;#34;What&amp;#39;s my name?&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="try-rag"&gt;Try RAG &lt;a class="anchor" href="#try-rag" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h3&gt;&#10;&lt;p&gt;Chat with your documents.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 1. Start the RAG proxy (runs in foreground)&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;uvx -p 3.13 --from &lt;span class="s2"&gt;&amp;#34;agent-cli[rag]&amp;#34;&lt;/span&gt; agent-cli rag-proxy &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --docs-folder ./my-docs &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --openai-base-url http://localhost:11434/v1 &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; --embedding-model embeddinggemma:300m&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# 2. Chat with your docs&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8000/v1 &lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dummy &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; uvx openai api chat.completions.create &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -m gpt-oss:20b &lt;span class="se"&gt;\&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -g user &lt;span class="s2"&gt;&amp;#34;What does my documentation say about X?&amp;#34;&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="9-what-i-learned"&gt;9. What I learned &lt;a class="anchor" href="#9-what-i-learned" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This journey reinforced a few beliefs:&lt;/p&gt;&#10;&lt;ol&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Validate components first.&lt;/strong&gt; AI Journal failed not because the ideas were wrong, but because I tried to build a complete system before validating individual components. This time I tackled problems one at a time—chunking, retrieval, re-ranking, memory reconciliation—and only combined them once each piece worked.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Study implementations, not just papers.&lt;/strong&gt; Reading about two-stage retrieval is one thing. Reading how LlamaIndex actually implements it—the edge cases, the defaults, the performance tradeoffs—is far more valuable. Clone the repos. Read (or make AI read) the code.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Dependencies matter.&lt;/strong&gt; A 30-second install versus a 10-minute download changes whether someone actually tries your tool. I am happy with the ONNX Runtime over PyTorch choice.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Files are the ultimate API.&lt;/strong&gt; By stripping away the complex databases and proprietary interfaces, I ended up with a system that is robust, version-controllable, and surprisingly simple to reason about.&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;li&gt;&#10;&lt;p&gt;&lt;strong&gt;Use the best model for each job.&lt;/strong&gt; I used 4 different AI models during development, each with different strengths. Gemini 3.0 Pro, Opus 4.5, GPT-5.1-Codex Max. I used all of them during the implementation phase, but each one has its own strengths. Fore example, Opus 4.5 was the best coding model, but GPT-5.1 Codex Max was the best at reviewing the code and actually identifying critical bugs (use the built-in &lt;code&gt;/review&lt;/code&gt; feature).&lt;/p&gt;&#10;&lt;/li&gt;&#10;&lt;/ol&gt;&#10;</description></item><item><title>Coding from my phone 📱</title><link>https://www.nijho.lt/post/agentic-mobile-workflow/</link><pubDate>Mon, 20 Oct 2025 00:00:00 +0000</pubDate><guid isPermaLink="false">/post/agentic-mobile-workflow/</guid><description>&lt;p&gt;I am dictating this post at ten kilometers altitude on a flight home from Mexico, Blink Shell open on my iPhone, while an agentic assistant on my &lt;a href="https://nixos.org/" target="_blank" rel="noopener"&gt;NixOS&lt;/a&gt; machine writes an initial draft for this post.&#10;Admittedly, I’m a little addicted to agentic coding: when an idea pops up, I want to jump on it right away—even if that means doing it from a cramped seat in the sky.&#10;This is the workflow I’ve arrived at after trying many alternatives, it extends &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;my agentic coding write-up&lt;/a&gt; and the self-hosted AI obsession in &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;my local AI journey&lt;/a&gt;.&#10;It&amp;rsquo;s my backup plan for those moments when a computer simply isn&amp;rsquo;t nearby—the rest of the time I&amp;rsquo;m still at a keyboard like any other developer.&#10;This is in no way a replacement for a proper computer/laptop workflow but gets the job done when needed.&#10;It&amp;rsquo;s a personal, mostly open-source stack built around &lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt;—my local-first voice layer.&#10;Here&amp;rsquo;s how it works: I speak into my phone, the audio goes to &lt;a href="https://github.com/SYSTRAN/faster-whisper-server" target="_blank" rel="noopener"&gt;FasterWhisper&lt;/a&gt; for transcription, &lt;a href="https://ollama.com/" target="_blank" rel="noopener"&gt;Ollama&lt;/a&gt; cleans up the text, and it lands on my clipboard ready to paste.&#10;My iOS Shortcut handles this through a small HTTP interface that agent-cli exposes.&#10;The coding agent itself still uses the best proprietary model I can access.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;Meta: I&amp;rsquo;m writing about agentic coding on mobile from my actual phone.&#10;It&amp;rsquo;s not the most efficient way to write, but it gets the job done — during a 6.5‑hour flight I dictated and iterated this entire post.&#10;Each iteration was a commit: dictate changes, review the diff, refine, commit, repeat.&#10;See all &lt;a href="https://github.com/basnijholt/nijho.lt/pull/40" target="_blank" rel="noopener"&gt;50+ commits in PR #40&lt;/a&gt; for the full iteration trail.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; iPhone → WireGuard → Blink+Mosh → Zellij.&#10;I dictate via a Shortcut → FasterWhisper (transcribe) → Ollama (polish) → clipboard, then paste into Codex CLI using OpenAI’s &lt;code&gt;gpt-5-codex-high&lt;/code&gt;.&#10;Everything is local except the model.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;details class="toc" open&gt;&lt;summary&gt;Table of contents&lt;/summary&gt;&lt;nav id="TableOfContents"&gt;&#10; &lt;ul&gt;&#10; &lt;li&gt;&lt;a href="#1-why-phone-coding-works-now"&gt;1. Why Phone Coding Works Now&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#2-constraints-and-trade-offs"&gt;2. Constraints and Trade-offs&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#3-layer-1-wireguard-from-the-router"&gt;3. Layer 1: WireGuard From the Router&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#4-layer-2-blink-shell--mosh-for-durable-sessions"&gt;4. Layer 2: Blink Shell + Mosh for Durable Sessions&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#5-layer-3-zellij-layouts"&gt;5. Layer 3: Zellij Layouts&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#6-layer-4-agent-cli-server"&gt;6. Layer 4: &lt;code&gt;agent-cli&lt;/code&gt; Server&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#7-layer-5-the-ios-shortcut-pipeline"&gt;7. Layer 5: The iOS Shortcut Pipeline&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#8-workflow-in-practice"&gt;8. Workflow in Practice&lt;/a&gt;&lt;/li&gt;&#10; &lt;li&gt;&lt;a href="#9-conclusion-and-further-reading"&gt;9. Conclusion and Further Reading&lt;/a&gt;&lt;/li&gt;&#10; &lt;/ul&gt;&#10;&lt;/nav&gt;&lt;/details&gt;&#10;&#10;&lt;h2 id="1-why-phone-coding-works-now"&gt;1. Why Phone Coding Works Now &lt;a class="anchor" href="#1-why-phone-coding-works-now" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;For years I used &lt;a href="https://ish.app/" target="_blank" rel="noopener"&gt;&lt;strong&gt;iSH&lt;/strong&gt;&lt;/a&gt; (a full Alpine Linux emulator) with SSH to hop into servers from my phone, but dropped connections were a constant frustration (Mosh fixes this, more on that later).&#10;Because coding on a phone keyboard is terrible, I kept it to tiny configuration tweaks or one-off fixes of a few characters.&#10;Agentic tools changed that: with a CLI coding agent, I don’t need to type the code—I describe the change, review the patch, and run it.&#10;That made meaningful work on the phone possible for the first time, for those moments when a computer isn’t around.&lt;/p&gt;&#10;&lt;p&gt;I tried &lt;strong&gt;VS Code in the browser&lt;/strong&gt;, bounced between &lt;strong&gt;iSH&lt;/strong&gt; and &lt;strong&gt;Terminus&lt;/strong&gt; for SSH, and even lived inside a handful of in-browser terminal clients.&#10;I also spent time with mobile companions like &lt;a href="https://happy.engineering/" target="_blank" rel="noopener"&gt;&lt;strong&gt;Happy&lt;/strong&gt;&lt;/a&gt; and &lt;a href="https://www.ycombinator.com/companies/omnara" target="_blank" rel="noopener"&gt;&lt;strong&gt;Omnara&lt;/strong&gt;&lt;/a&gt;, both designed to mirror Claude Code sessions on the phone, but they still felt like extra steps between me and my actual terminal.&#10;That friction pushed me toward a phone‑ready, self‑hosted workflow that still gives me raw SSH access to my own machine when the computer is out of reach.&lt;/p&gt;&#10;&lt;p&gt;This post is based on the way I develop software today.&#10;Your mileage may vary, but if you also care about privacy, open tooling, and reproducible environments, I think there are useful pieces here.&lt;/p&gt;&#10;&lt;h2 id="2-constraints-and-trade-offs"&gt;2. Constraints and Trade-offs &lt;a class="anchor" href="#2-constraints-and-trade-offs" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This setup grew organically over time. What emerged are less strict requirements and more happy accidents that turned into core benefits:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Single trust boundary:&lt;/strong&gt; Only the model provider (OpenAI) sees code context; audio and automation stay local, and I avoid any additional third‑party relays.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Resilient sessions:&lt;/strong&gt; Connections should survive sleep and spotty networks.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Voice-friendly:&lt;/strong&gt; Dictation should be accurate enough that I can trust it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Reproducible config:&lt;/strong&gt; The entire stack must live in my &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;dotfiles&lt;/a&gt; and &lt;a href="https://github.com/basnijholt/dotfiles/tree/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/nixos" target="_blank" rel="noopener"&gt;NixOS configuration&lt;/a&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;To give you a sense of what I tried, here is the short comparison that convinced me to roll my own:&lt;/p&gt;&#10;&lt;div class="table-wrap"&gt;&#10;&lt;table&gt;&#10;&lt;thead&gt;&#10;&lt;tr&gt;&#10;&lt;th&gt;Approach&lt;/th&gt;&#10;&lt;th&gt;Pros&lt;/th&gt;&#10;&lt;th&gt;Why I moved on&lt;/th&gt;&#10;&lt;/tr&gt;&#10;&lt;/thead&gt;&#10;&lt;tbody&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;VS Code in the browser&lt;/td&gt;&#10;&lt;td&gt;Familiar editor UI&lt;/td&gt;&#10;&lt;td&gt;Needs a steady connection and still lives outside my dotfiles comfort zone&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;iSH / Terminus SSH&lt;/td&gt;&#10;&lt;td&gt;Works without extra infrastructure&lt;/td&gt;&#10;&lt;td&gt;Laggy, no Mosh, and awkward keybindings&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;In-browser terminals&lt;/td&gt;&#10;&lt;td&gt;Instant access from anywhere&lt;/td&gt;&#10;&lt;td&gt;Poor copy/paste ergonomics and flaky mobile keyboards&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://apps.apple.com/us/app/happy-codex-claude-code-app/id6748571505" target="_blank" rel="noopener"&gt;Happy&lt;/a&gt; (Claude Code companion)&lt;/td&gt;&#10;&lt;td&gt;Push notifications, encrypted mobile UI for Claude Code&lt;/td&gt;&#10;&lt;td&gt;Requires wrapping every session with a separate CLI and still abstracts away my shell, and was quite buggy for me&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;tr&gt;&#10;&lt;td&gt;&lt;a href="https://omnara.com" target="_blank" rel="noopener"&gt;Omnara&lt;/a&gt; (agent command center)&lt;/td&gt;&#10;&lt;td&gt;Centralizes Claude Code/Codex sessions with terminal replay&lt;/td&gt;&#10;&lt;td&gt;Proxies via their servers; I already trust OpenAI for the model and don&amp;rsquo;t want another third‑party handling my code&lt;/td&gt;&#10;&lt;/tr&gt;&#10;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;/div&gt;&#10;&lt;p&gt;The stack below gives me the resilience of Mosh, the ergonomics of Zellij, and full control over the AI layer.&lt;/p&gt;&#10;&lt;h2 id="3-layer-1-wireguard-from-the-router"&gt;3. Layer 1: WireGuard From the Router &lt;a class="anchor" href="#3-layer-1-wireguard-from-the-router" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://www.wireguard.com/" target="_blank" rel="noopener"&gt;WireGuard&lt;/a&gt; is a modern VPN that lets me securely access my home network from anywhere.&#10;I terminate WireGuard on my router so every device in the house (and on the road) can dial home with the same config.&#10;(If you want something easier to set up without opening ports, &lt;a href="https://tailscale.com/" target="_blank" rel="noopener"&gt;Tailscale&lt;/a&gt; is a more user-friendly option.)&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;Server:&lt;/strong&gt; ASUS XT8 router with WireGuard enabled via the router UI.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Client:&lt;/strong&gt; The WireGuard iOS app with &lt;code&gt;On-Demand&lt;/code&gt; rules so the tunnel flips on whenever I am off trusted Wi-Fi.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;DNS:&lt;/strong&gt; All mobile sessions resolve through my home DNS, so &lt;code&gt;git.nijho.lt&lt;/code&gt; and internal services resolve instantly.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;This gives Blink a local-LAN address for my desktop &lt;code&gt;nixos&lt;/code&gt;.&lt;/p&gt;&#10;&lt;h2 id="4-layer-2-blink-shell--mosh-for-durable-sessions"&gt;4. Layer 2: Blink Shell + Mosh for Durable Sessions &lt;a class="anchor" href="#4-layer-2-blink-shell--mosh-for-durable-sessions" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;What is &lt;a href="https://mosh.org/" target="_blank" rel="noopener"&gt;Mosh&lt;/a&gt;?&#10;It&amp;rsquo;s like SSH, but it stays connected when the network changes or the phone sleeps, and it feels faster on bad connections.&lt;/p&gt;&#10;&lt;p&gt;&lt;a href="https://blink.sh/" target="_blank" rel="noopener"&gt;Blink Shell&lt;/a&gt; is my daily driver on iOS because it pairs beautifully with Mosh and has great keyboard ergonomics.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;I launch sessions using &lt;code&gt;mosh bas@nixos -- zellij attach --create phone&lt;/code&gt; (&lt;a href="https://zellij.dev/" target="_blank" rel="noopener"&gt;Zellij&lt;/a&gt; is my terminal multiplexer—more on that in the next section).&lt;/li&gt;&#10;&lt;li&gt;Mosh smooths over spotty LTE and keeps my session alive when the phone sleeps.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Mosh keeps the session responsive across flaky networks and sleep.&lt;/p&gt;&#10;&lt;h2 id="5-layer-3-zellij-layouts"&gt;5. Layer 3: Zellij Layouts &lt;a class="anchor" href="#5-layer-3-zellij-layouts" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;I use &lt;strong&gt;Zellij&lt;/strong&gt; as my terminal multiplexer for mobile work.&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;I stick to the defaults and use a couple of predefined layouts (e.g., a &amp;ldquo;phone&amp;rdquo; layout with editor + shell panes).&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Ergonomics matter even more on a glass keyboard, so I lean on a few shell helpers:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/ajeetdsouza/zoxide" target="_blank" rel="noopener"&gt;&lt;code&gt;zoxide&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; means I can jump between repos with &lt;code&gt;z foo&lt;/code&gt; instead of pecking long paths.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Single-character aliases&lt;/strong&gt; like &lt;code&gt;p&lt;/code&gt; for &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;gs&lt;/code&gt; for &lt;code&gt;git status&lt;/code&gt; live in &lt;a href="https://github.com/basnijholt/dotfiles/blob/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/shell/10_aliases.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/shell/10_aliases.sh&lt;/code&gt;&lt;/a&gt;, keeping command entry to a minimum.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Autocompletion plugins&lt;/strong&gt;—&lt;a href="https://github.com/zsh-users/zsh-autosuggestions" target="_blank" rel="noopener"&gt;&lt;code&gt;zsh-autosuggestions&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;zsh-syntax-highlighting&lt;/code&gt;—are wired up in &lt;a href="https://github.com/basnijholt/dotfiles/blob/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/shell/70_zsh_plugins.sh" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/shell/70_zsh_plugins.sh&lt;/code&gt;&lt;/a&gt;, so I get inline hints and colour cues while typing.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I break these tricks down in more depth in &lt;a href="https://www.nijho.lt/post/terminal-ninja/"&gt;Terminal Ninja&lt;/a&gt;, explain how I sync them with my &lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;Dotfiles&lt;/a&gt;, and even package binaries like &lt;code&gt;zoxide&lt;/code&gt; with &lt;a href="https://www.nijho.lt/post/dotbins/"&gt;Dotbins&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="6-layer-4-agent-cli-server"&gt;6. Layer 4: &lt;code&gt;agent-cli&lt;/code&gt; Server &lt;a class="anchor" href="#6-layer-4-agent-cli-server" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;&lt;a href="https://github.com/basnijholt/agent-cli" target="_blank" rel="noopener"&gt;&lt;code&gt;agent-cli&lt;/code&gt;&lt;/a&gt; runs as a small server on my NixOS machine.&#10;In this workflow I only use &lt;code&gt;transcribe&lt;/code&gt; from iOS Shortcuts: the phone records audio, the server transcribes and cleans it up, and I paste the text.&#10;(Agent-CLI has other features like &lt;code&gt;autocorrect&lt;/code&gt; and &lt;code&gt;chat&lt;/code&gt;, but I don&amp;rsquo;t use them for this workflow.)&#10;On this machine I run a long‑lived &lt;code&gt;agent-cli&lt;/code&gt; user service (systemd) so Shortcuts can POST audio to it and get cleaned text back—ready to paste into Codex CLI.&#10;The service is defined in my dotfiles—see: &lt;a href="https://github.com/basnijholt/dotfiles/blob/8f6bf0b7219195a46a3e010d3538e1e449634db7/configs/nixos/modules/user.nix#L29-L40" target="_blank" rel="noopener"&gt;&lt;code&gt;configs/nixos/modules/user.nix&lt;/code&gt; (agent-cli service)&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;For more background on why I built it and how I use it in practice, see &lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;Local AI Journey&lt;/a&gt; (§3) and &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;Agentic Coding&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The models and services run on the same box:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;FasterWhisper&lt;/strong&gt; via &lt;a href="https://github.com/SYSTRAN/faster-whisper" target="_blank" rel="noopener"&gt;&lt;code&gt;faster-whisper-server&lt;/code&gt;&lt;/a&gt; for high‑accuracy streaming transcription.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;Ollama&lt;/strong&gt; for on‑device rewrite/cleanup before sending prompts to the coding agent.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;My iOS Shortcut dictation workflow is slower than Apple&amp;rsquo;s built-in dictation, but the accuracy is much better—and that matters more.&lt;/p&gt;&#10;&lt;p&gt;For the actual coding agent, I use a &lt;a href="https://github.com/just-every/code" target="_blank" rel="noopener"&gt;fork&lt;/a&gt; of Codex CLI with OpenAI’s &lt;code&gt;gpt-5-codex-high&lt;/code&gt; model (here no open-source solution matches the frontier).&lt;/p&gt;&#10;&lt;h2 id="7-layer-5-the-ios-shortcut-pipeline"&gt;7. Layer 5: The iOS Shortcut Pipeline &lt;a class="anchor" href="#7-layer-5-the-ios-shortcut-pipeline" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;My iPhone&amp;rsquo;s Action Button runs a Shortcut that records audio and sends my voice to agent-cli on my NixOS machine for transcription and cleanup.&lt;/p&gt;&#10;&lt;p&gt;For the full recipe, see the iOS Shortcut Guide: &lt;a href="https://github.com/basnijholt/agent-cli/blob/main/iOS_Shortcut_Guide.md" target="_blank" rel="noopener"&gt;agent-cli/iOS_Shortcut_Guide.md&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;In short: I press the Action Button, the Shortcut records a snippet, sends it to &lt;code&gt;agent-cli&lt;/code&gt;, and copies the cleaned text to my clipboard so I can paste it into Codex CLI.&lt;/p&gt;&#10;&lt;p&gt;The whole loop finishes fast enough that I can capture intent by voice and paste it into Codex CLI without typing.&lt;/p&gt;&#10;&lt;aside class="callout callout-note"&gt;&lt;svg class="ico" viewBox="0 0 256 256" aria-hidden="true"&gt;&lt;path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/&gt;&lt;/svg&gt;&lt;div&gt;&lt;strong&gt;Dictation quality:&lt;/strong&gt; In my experience, the built‑in iOS dictation is absolute garbage compared to Whisper/FasterWhisper.&#10;Accuracy and punctuation are much better with the FasterWhisper server running at home; it’s a bit slower, but that trade‑off is worth it for clean, usable text.&lt;/div&gt;&lt;/aside&gt;&#10;&lt;h2 id="8-workflow-in-practice"&gt;8. Workflow in Practice &lt;a class="anchor" href="#8-workflow-in-practice" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;Here&amp;rsquo;s the typical flow:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;WireGuard auto-connects when I&amp;rsquo;m off home WiFi&lt;/li&gt;&#10;&lt;li&gt;Open Blink—my Mosh session is still alive from last time, Zellij panes intact&lt;/li&gt;&#10;&lt;li&gt;Dictate the change, paste into Codex CLI, review the agent&amp;rsquo;s response&lt;/li&gt;&#10;&lt;li&gt;Check diffs in an adjacent Zellij pane or different Blink tab with separate Mosh session&lt;/li&gt;&#10;&lt;li&gt;The agent commits and pushes to a branch, then opens a PR via &lt;code&gt;gh&lt;/code&gt; for me to review and merge&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;On the phone, I aim for the smallest practical edits and initial implementations.&#10;Even on personal repos, I still open a PR—sometimes prompting alone gets me to a merge‑ready result.&#10;For open‑source with real users, I finish on the computer with a careful review and any final edits.&lt;/p&gt;&#10;&lt;h2 id="9-conclusion-and-further-reading"&gt;9. Conclusion and Further Reading &lt;a class="anchor" href="#9-conclusion-and-further-reading" aria-label="Link to this section"&gt;#&lt;/a&gt;&lt;/h2&gt;&#10;&lt;p&gt;This phone setup extends my &lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;Agentic Coding&lt;/a&gt; workflow to mobile.&#10;I connect to the same Zellij session on &lt;code&gt;nixos&lt;/code&gt;, so when an idea hits, I pick up exactly where I left off—same environment, same context, no setup.&#10;Everything runs on my hardware (voice processing and automation) except the coding model itself.&#10;It’s the most effective mobile workflow I’ve had so far.&#10;It’s mostly open‑source not out of dogma, but because those tools are the best options for my needs.&#10;The one exception is the coding model: there’s no true open equivalent right now, and it makes no sense to buy 20× H100s just to self‑host a frontier model even if such a high quality model would be available open-source.&lt;/p&gt;&#10;&lt;p&gt;If you&amp;rsquo;re curious to dive deeper, here are a few related posts:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/agentic-coding/"&gt;Agentic Coding&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/dotfiles/"&gt;Dotfiles: A Practical, Cross-Platform Terminal Setup&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/local-ai-journey/"&gt;Local AI Journey: RTX 3090 Edition&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/terminal-ninja/"&gt;Terminal Ninja&lt;/a&gt;&lt;/li&gt;&#10;&lt;li&gt;&lt;a href="https://www.nijho.lt/post/using-llms/"&gt;Using LLMs Effectively&lt;/a&gt; (already outdated at this point!)&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;I&amp;rsquo;d love to hear what self-hosted tricks you&amp;rsquo;re using for mobile development—reach out if you adapt this stack or build something wild on top of it.&lt;/p&gt;&#10;</description></item></channel></rss>