<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Rust | AkitaOnRails.com</title><link>https://akitaonrails.github.io/en/tags/rust/</link><description>Fabio Akita's blog — tech, career, and assorted geek topics. English edition.</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 19 Oct 2026 23:20:52 +0000</lastBuildDate><atom:link href="https://akitaonrails.github.io/en/tags/rust/index.xml" rel="self" type="application/rss+xml"/><item><title>New AI-Focused Language Just Released: Bend 2</title><link>https://akitaonrails.github.io/en/2026/09/19/new-ai-language-just-released-bend-2/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/09/19/new-ai-language-just-released-bend-2/</guid><pubDate>Sat, 19 Sep 2026 12:00:00 -0300</pubDate><description>&lt;p&gt;Two days ago, on September 17, Brazilian researcher Victor Taelin &lt;a href="https://x.com/VictorTaelin/status/2100681226143092875"target="_blank" rel="noopener"&gt;announced on X&lt;/a&gt; the release of &lt;strong&gt;Bend 2&lt;/strong&gt;, hosted at &lt;a href="https://bend-lang.com/"target="_blank" rel="noopener"&gt;bend-lang.com&lt;/a&gt;. The tagline is ambitious: &lt;em&gt;&amp;ldquo;a fast language that blocks AI mistakes via proof: C speed · CUDA parallelism · Lean proofs · Python syntax&amp;rdquo;&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;A language that promises to run near C speed, parallelize itself across CPU and GPU, and still formally verify that the code the AI wrote does what you asked. That&amp;rsquo;s a lot of promises stacked together. I had never touched Bend 1, so this is new territory for me too: on launch day, I pointed my agents at two of my Rust projects and told them to port them to Bend 2. The result of that test is the most interesting part of this article.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Two days ago, on September 17, Brazilian researcher Victor Taelin <a href="https://x.com/VictorTaelin/status/2100681226143092875"target="_blank" rel="noopener">announced on X</a> the release of <strong>Bend 2</strong>, hosted at <a href="https://bend-lang.com/"target="_blank" rel="noopener">bend-lang.com</a>. The tagline is ambitious: <em>&ldquo;a fast language that blocks AI mistakes via proof: C speed · CUDA parallelism · Lean proofs · Python syntax&rdquo;</em>.</p>
<p>A language that promises to run near C speed, parallelize itself across CPU and GPU, and still formally verify that the code the AI wrote does what you asked. That&rsquo;s a lot of promises stacked together. I had never touched Bend 1, so this is new territory for me too: on launch day, I pointed my agents at two of my Rust projects and told them to port them to Bend 2. The result of that test is the most interesting part of this article.</p>
<p>Before we start, the disclaimer: this is a review written on the <strong>second day of the language&rsquo;s life</strong>, based on a one-day experiment. I may be wrong about several of my assumptions, and that&rsquo;s fine. The comments are down below; feel free to correct and add to it.</p>
<h2>Who Victor Taelin is<span class="hx:absolute hx:-mt-20" id="who-victor-taelin-is"></span>
    <a href="#who-victor-taelin-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>First things first, the author deserves an introduction. <a href="https://github.com/VictorTaelin"target="_blank" rel="noopener">Victor Taelin</a> is a Brazilian functional programming researcher who spent more than a decade obsessed with one specific problem: optimal reduction of the lambda calculus, Lamping&rsquo;s 1990 algorithm, and then Lafont&rsquo;s interaction nets.</p>
<p><a href="https://gist.github.com/VictorTaelin/77fd5a2a8a4a07e1da6157ebca3c7cf1"target="_blank" rel="noopener">His own autobiography</a> tells the story: he worked at the Ethereum Foundation, created the Formality proof language, retired early on the ETH he stacked from salary, went back to the problem on his own dime, created HVM (the Higher-Order Virtual Machine), and founded the Higher Order Company, which raised a <a href="https://x.com/VictorTaelin/status/1743751536465903795"target="_blank" rel="noopener">$4 million seed</a> to turn the research into a product.</p>
<p>And here&rsquo;s a point that matters a lot to me: <strong>I love seeing a Brazilian doing serious programming language design work</strong>. We barely have real cutting-edge research in this country, let alone in computing fundamentals. For that alone, Taelin already has my respect and my congratulations for what he&rsquo;s built so far.</p>
<p>So let it be on record: every criticism in this article is constructive, from someone rooting for Bend to go further. Nothing against him or the project.</p>
<h2>Where Bend comes from<span class="hx:absolute hx:-mt-20" id="where-bend-comes-from"></span>
    <a href="#where-bend-comes-from" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The first version of Bend showed up in <a href="https://news.ycombinator.com/item?id=40390287"target="_blank" rel="noopener">May 2024</a>, with Python-like syntax running on top of HVM2, and the promise of automatic parallelism: write sequential code, the runtime spreads it across every core and even the GPU, no threads, no locks, no mutexes.</p>
<p>The launch went viral. Almost a thousand points on Hacker News, over 20 thousand GitHub stars, a Fireship video. And then reality hit: the <a href="https://news.ycombinator.com/item?id=40390287"target="_blank" rel="noopener">HN thread</a> was merciless. 24-bit numbers, no FFI, a simple sum taking 42 minutes on a user&rsquo;s machine, single-core C++ beating the demo&rsquo;s RTX 4090. Taelin himself admitted back then that the codegen was bad, and admitted now, in the <a href="https://news.ycombinator.com/item?id=49746163"target="_blank" rel="noopener">Bend 2 thread</a>, that the 2024 hype didn&rsquo;t convert into usage: <em>&ldquo;there is also my own failure into making the language actually be used, rather than just a viral moment&rdquo;</em>.</p>
<p>Bend 2 is the answer to that. And pay attention: <strong>it&rsquo;s a different language</strong>. The README is explicit that Bend 1 programs don&rsquo;t load. Interaction-graph evaluation (HVM) is out; native compilation to a single C file is in, with clang for CPU and Metal or CUDA for GPU. Numbers are now real Nat, U32, and F32, and the memory limit jumped from 2 GB to 8 TB, according to <a href="https://gihyo.jp/article/2026/09/bend-2"target="_blank" rel="noopener">gihyo&rsquo;s Japanese announcement</a>. Interaction nets stay in the architecture, but in his words, <em>&ldquo;inets live in it architecturally, but they don&rsquo;t exist at runtime&rdquo;</em>.</p>
<h2>What Bend 2 promises<span class="hx:absolute hx:-mt-20" id="what-bend-2-promises"></span>
    <a href="#what-bend-2-promises" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The new pitch now revolves around AI. The project page sells a <em>&ldquo;post-AGI&rdquo;</em> vision: humans will stop writing and reading code, so we need an ambiguity-free way to tell the AIs what we want. Bend&rsquo;s solution is splitting the program into two files:</p>
<ul>
<li><code>LAWS.bend</code>: the system&rsquo;s invariants, written by the human.</li>
<li><code>PROOF.bend</code>: the proofs of those laws, written by the AI.</li>
</ul>
<p>Bend&rsquo;s type checker is a proof checker, in the lineage of Lean and Rocq, so <code>bend PROOF.bend</code> becomes a commit gate: if the proof doesn&rsquo;t close, the code doesn&rsquo;t ship. The site&rsquo;s demo is a little game with the law <em>&ldquo;winning is impossible&rdquo;</em> and a direct invitation: <em>&ldquo;Skeptical? Try breaking the game&rdquo;</em>. The positioning is explicit: <strong>LAWS.bend is an AGENTS.md with mathematical backing</strong>.</p>
<p>And what does it look like? The syntax feels like Python with dependent types. A complete program with effects:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">Base</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Performs effects on the CPU.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">IO</span><span class="p">(</span><span class="n">Unit</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">  <span class="n">do</span> <span class="n">IO</span><span class="o">&lt;</span><span class="n">Unit</span><span class="o">&gt;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">name</span> <span class="p">:</span> <span class="n">String</span> <span class="o">&lt;-</span> <span class="n">IO</span><span class="o">.</span><span class="k">try</span><span class="p">(</span><span class="n">String</span><span class="p">,</span> <span class="n">IO</span><span class="o">.</span><span class="n">get_env</span><span class="p">(</span><span class="s2">&#34;USER&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">IO</span><span class="o">.</span><span class="n">print</span><span class="p">(</span><span class="s2">&#34;Hello, &#34;</span> <span class="o">++</span> <span class="n">name</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Parallelism is divide-and-conquer: you split the work in two and the runtime spreads it over whatever cores it finds. And the <code>!</code> operator dispatches the function to the GPU:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># Computes 2^d in parallel: a tree of d levels, one leaf per unit.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">pow2</span><span class="p">(</span><span class="o">+</span><span class="n">d</span><span class="p">:</span> <span class="n">Nat</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">U32</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">  <span class="k">match</span> <span class="n">d</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">0</span><span class="n">n</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">p</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="n">a</span> <span class="n">b</span> <span class="o">=</span> <span class="n">pow2</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="n">pow2</span><span class="p">(</span><span class="n">p</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">(</span><span class="n">a</span> <span class="o">+</span> <span class="n">b</span> <span class="p">:</span> <span class="n">U32</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Runs pow2 on the GPU, via `!`.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="n">IO</span><span class="p">(</span><span class="n">Unit</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">  <span class="n">result</span> <span class="o">=</span> <span class="n">pow2</span><span class="err">!</span><span class="p">(</span><span class="mi">20</span><span class="n">n</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">IO</span><span class="o">.</span><span class="n">print</span><span class="p">(</span><span class="n">U32</span><span class="o">.</span><span class="n">show</span><span class="p">(</span><span class="n">result</span><span class="p">))</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Notice the differences from mainstream languages: there&rsquo;s no <code>if</code> (every branch is a <code>match</code> on <code>True</code>/<code>False</code>), natural numbers pattern-match on the successor pattern (<code>1n+p</code>), and the <code>+d</code> marks the parameter as <strong>affine</strong>, which is what lets the language live without a garbage collector.</p>
<p>And proofs are ordinary defs. The law declares what holds; the proof is induction with explicit rewrites:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># CLAIM: for every nat x, x + 0 equals x.</span>
</span></span><span class="line"><span class="cl"><span class="n">law</span> <span class="n">add_zero</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">  <span class="k">for</span> <span class="n">x</span><span class="p">:</span> <span class="n">Nat</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="n">Nat</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="mi">0</span><span class="n">n</span><span class="p">)</span> <span class="o">==</span> <span class="n">x</span> <span class="p">:</span> <span class="n">Nat</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># PROOF: induction on `x`, one rewrite (`%`) per step.</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">add_zero</span><span class="p">(</span><span class="n">x</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">  <span class="k">match</span> <span class="n">x</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">0</span><span class="n">n</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="o">==</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">xp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="o">%</span><span class="n">add_zero</span><span class="p">(</span><span class="n">xp</span><span class="p">)</span> <span class="p">:</span> <span class="p">{</span><span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">Nat</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">xp</span><span class="p">,</span> <span class="mi">0</span><span class="n">n</span><span class="p">)</span> <span class="o">==</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">_</span> <span class="p">:</span> <span class="n">Nat</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="o">==</span><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>No tactics, no inference, everything annotated by hand. It&rsquo;s verbose on purpose: that&rsquo;s the price of the 0.1s checker.</p>
<p>The other promises, all with benchmarks on the page:</p>
<ul>
<li><strong>Runs fast</strong>: Game of Life on an M4 Max in 7.8s on one core, against C&rsquo;s 6.78s. On the GPU, 0.06s, or 124x.</li>
<li><strong>Checks fast</strong>: 3,200 generic instantiations checked in 0.38s, against Lean&rsquo;s 19.2s and Rocq&rsquo;s 6.04s. The idea is that the agent can verify after every edit.</li>
<li><strong>Parallelizes by itself</strong>: the <code>!</code> operator sends the function to the GPU, no threads, no locks, no hand-written kernels.</li>
<li><strong>No garbage collector</strong>: affine types, each closure can be called at most once, so memory resolves statically.</li>
</ul>
<p>Two honest details I liked seeing published: the site itself warns <em>&ldquo;Bend is still evolving. Expect bugs&rdquo;</em>, and Taelin stated that <a href="https://gihyo.jp/article/2026/09/bend-2"target="_blank" rel="noopener">the compiler is 99% AI-written</a>, with only the checking kernel audited by humans. Consistent with his thesis, at least.</p>
<p>And there&rsquo;s already a business plan: the <a href="https://bend-lang.com/bender"target="_blank" rel="noopener">Bender</a>, a paid, proprietary <em>&ldquo;proving agent&rdquo;</em>, a harness on top of Anthropic and OpenAI models today, with a dedicated model and its own symbolic prover on the roadmap.</p>
<h2>Not everyone swallowed the benchmarks<span class="hx:absolute hx:-mt-20" id="not-everyone-swallowed-the-benchmarks"></span>
    <a href="#not-everyone-swallowed-the-benchmarks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The technical reception had strong caveats, and they matter. Nezk&rsquo;s gist, <a href="https://gist.github.com/Nezk/dda0511c492cf9bd673885f0341dca0e"target="_blank" rel="noopener">&ldquo;Why the benchmarks of Bend&rsquo;s 2 typechecker are misleading (and possible problems with its type system)&rdquo;</a>, points out that Bend skips elaboration, unification, and implicit arguments, which is exactly where Lean and Rocq spend their time. Comparing times like that would be comparing an apple to a rocket.</p>
<p>Heavier: Liam Powell&rsquo;s post, <a href="https://blog.liampwll.com/posts/bend_vibe_coding/"target="_blank" rel="noopener">&ldquo;Bend 2 and the Vibe-Coding Trap&rdquo;</a>, notes that the expression <em>&ldquo;formal verification&rdquo;</em> appears nowhere on Bend&rsquo;s site or code, redoes the game demo in SPARK/Ada proving the same two properties automatically with GNATprove (<em>&ldquo;all checks proved (12 checks)&rdquo;</em>), without the 442 lines of manual proof, and concludes that Bend is <em>&ldquo;decades behind the state of the art&rdquo;</em>.</p>
<p>These are criticisms from people who know the subject, and they deserve an answer. That said, Bend 2&rsquo;s main HN thread sits at <a href="https://news.ycombinator.com/item?id=49746163"target="_blank" rel="noopener">over 600 points</a>, so attention the project has.</p>
<h2>What I tested<span class="hx:absolute hx:-mt-20" id="what-i-tested"></span>
    <a href="#what-i-tested" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A tagline doesn&rsquo;t compile a program, so I went and tested. I have a <a href="https://github.com/akitaonrails/bend-tests"target="_blank" rel="noopener"><code>bend-tests</code></a> repository where I pointed my agents at porting real projects of mine, written in Rust, to Bend 2 (v2.0.16, pinned via mise; to get a sense of the maturity, the language had nine releases in ten hours on GitHub the day after launch). The documentation for the whole experiment lives in <code>docs/</code> in there, and that&rsquo;s where the next sections come from.</p>
<h3>The three candidates<span class="hx:absolute hx:-mt-20" id="the-three-candidates"></span>
    <a href="#the-three-candidates" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I picked three of my projects with different profiles. Fair warning: none of the three is a worthy Bend example, and I know it. I just grabbed small, simple projects I had lying around, somewhat at random, to see where the language breaks when you rub ordinary day-to-day code against it.</p>
<table>
  <thead>
      <tr>
          <th>Project</th>
          <th>Fit</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ghpending</td>
          <td>bad</td>
          <td>90% HTTP, JSON, and formatting; a single small function worth proving</td>
      </tr>
      <tr>
          <td>clock-tui</td>
          <td>bad</td>
          <td>terminal IO from start to finish; the arithmetic is U32, unprovable in practice without a mathlib</td>
      </tr>
      <tr>
          <td>ai-jail</td>
          <td>worst</td>
          <td>namespaces, mounts, seccomp, exec: almost everything is a syscall</td>
      </tr>
  </tbody>
</table>
<p><strong>ghpending</strong> is my CLI that lists open issues and PRs across my repositories via GraphQL. The port covers the main command, the filters, the sorting, and the config.toml, with output verified <strong>byte-for-byte identical</strong> to the Rust binary in the cases tested against the real GitHub API (on my loaded config, the only difference is the fork view the port left out).</p>
<p><strong>tclock</strong> is my fork of clock-tui, a terminal clock with timer and stopwatch. The port covers clock, timer, and stopwatch, without the widget system (which alone is 2,075 lines of the Rust), verified by driving the binary through a pty.</p>
<p><strong>ai-jail</strong>, my sandbox for AI agents (bubblewrap, Landlock, seccomp), never even became code: the paper evaluation killed it. A port would be a C program with a Bend <code>main</code>, which defeats the purpose. And it had the most interesting part to prove: the policy logic, like <em>&ldquo;path resolution never escapes the root&rdquo;</em>. But there&rsquo;s no way to embed that in the Rust binary, so it stays for a next round.</p>
<h3>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>I measured Rust and Bend side by side on both ports:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Rust</th>
          <th>Bend</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ghpending, lines of code</td>
          <td>1,962 (no tests)</td>
          <td>1,068 Bend + 93 laws/proofs + 109 C + 25 JS</td>
      </tr>
      <tr>
          <td>ghpending, dependencies</td>
          <td>278 crates in the lockfile</td>
          <td>dynamic libcurl, nothing else</td>
      </tr>
      <tr>
          <td>ghpending, binary</td>
          <td>6.8 MB</td>
          <td>1.4 MB</td>
      </tr>
      <tr>
          <td>ghpending, clean build</td>
          <td>57s</td>
          <td>62s (0.1s of checking; the rest is clang -O3 on the generated C)</td>
      </tr>
      <tr>
          <td>ghpending, run with 43 repos</td>
          <td>3.9s wall / 16ms CPU</td>
          <td>2.1s wall / 28ms CPU</td>
      </tr>
      <tr>
          <td>tclock, binary</td>
          <td>6.7 MB</td>
          <td>115 KB</td>
      </tr>
      <tr>
          <td>tclock, idle 5s</td>
          <td>333ms CPU, 10 MB</td>
          <td>9ms CPU, 6.6 MB</td>
      </tr>
  </tbody>
</table>
<p>Read that table carefully, because it lies. The runs are network-bound. The 43-repo gap is Rust doing more work (fork lookups, sequential viewer query). Bend&rsquo;s tclock redraws whole frames, 26 KB/s against ratatui&rsquo;s 2 KB/s of diffs. And the lower line count is partly because the port does less. <strong>No number here shows Bend faster than Rust on the same work.</strong></p>
<h3>The proofs in practice<span class="hx:absolute hx:-mt-20" id="the-proofs-in-practice"></span>
    <a href="#the-proofs-in-practice" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Here&rsquo;s the part Rust can&rsquo;t imitate, so it&rsquo;s worth the detail. The design that worked was <strong>proving an isolated guard, leaving the algorithm outside the proof</strong>. ghpending&rsquo;s <code>--limit</code> allocator ended up in two stages: the first is the original algorithm in U32, unproven; the second is a Nat guard that takes the first stage&rsquo;s proposal and cuts any excess. The laws say the budget is conserved and that no repo gets more items than it has.</p>
<p>This is the real guard, from the port&rsquo;s <code>alloc.bend</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># the lesser of b and w</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">take</span><span class="p">(</span><span class="n">b</span><span class="p">:</span> <span class="n">Nat</span><span class="p">,</span> <span class="n">w</span><span class="p">:</span> <span class="n">Nat</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Nat</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">  <span class="k">match</span> <span class="n">b</span> <span class="n">w</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">0</span><span class="n">n</span> <span class="n">w0</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="mi">0</span><span class="n">n</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">bp</span> <span class="mi">0</span><span class="n">n</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="mi">0</span><span class="n">n</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">bp</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">wp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">take</span><span class="p">(</span><span class="n">bp</span><span class="p">,</span> <span class="n">wp</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># what is left of b once w is taken from it</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">rest</span><span class="p">(</span><span class="n">b</span><span class="p">:</span> <span class="n">Nat</span><span class="p">,</span> <span class="n">w</span><span class="p">:</span> <span class="n">Nat</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Nat</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">  <span class="k">match</span> <span class="n">b</span> <span class="n">w</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">0</span><span class="n">n</span> <span class="n">w0</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="mi">0</span><span class="n">n</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">bp</span> <span class="mi">0</span><span class="n">n</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">bp</span>
</span></span><span class="line"><span class="cl">    <span class="k">case</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">bp</span> <span class="mi">1</span><span class="n">n</span><span class="o">+</span><span class="n">wp</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="n">rest</span><span class="p">(</span><span class="n">bp</span><span class="p">,</span> <span class="n">wp</span><span class="p">)</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<blockquote>
  <p>The law holds even if the unproven stage proposes garbage. The guard doesn&rsquo;t trust arithmetic it can&rsquo;t see. That&rsquo;s the pattern that makes proofs work in real code.</p>

</blockquote>
<p>The mutation test is the honest measure of the thing. I injected an off-by-one into the guard that leaks budget: the three sample outputs came out identical, so a test suite of that size would have passed it. The proof gate rejected it, with the exact term that stopped matching. That&rsquo;s Bend working as promised, down to the detail.</p>
<p>Now the price: 93 lines of laws and proofs to guarantee two simple facts about a guard of some 30 lines, starting from zero, because Base ships no Nat lemmas (the only arithmetic lemmas are <code>U32.add_comm</code> and <code>Word.add_comm</code>, which don&rsquo;t help in a Nat proof; we had to prove addition associativity by hand, and Taelin acknowledged in the thread: <em>&ldquo;We need a mathlib!&rdquo;</em>).</p>
<p>And what stayed outside the proof: the algorithm&rsquo;s exactness (it would need division lemmas, days of work), everything U32 (underneath it&rsquo;s a 32-bit vector; proving arithmetic on it without a lemma library is impractical), and literally everything else in the program: JSON, sorting, rendering, IO. The provable surface of a program like ghpending is a few percent of the code.</p>
<p>The checker, on the other hand, is the best part of the tool: 0.1s per round, errors pointing at one exact line. It took some 30 check rounds on ghpending. For an agent iterating in a loop, it&rsquo;s exactly the feedback format it needs.</p>
<h3>The price of escaping to C<span class="hx:absolute hx:-mt-20" id="the-price-of-escaping-to-c"></span>
    <a href="#the-price-of-escaping-to-c" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Everything Base lacks becomes a <em>foreign effect</em>: a Bend def whose body is a C function (for the native binary) and a JS twin (for the dev runner). The two ports needed <strong>eight</strong> hand-written effects:</p>
<ul>
<li><code>Http.post</code>: because there&rsquo;s no TLS, HTTP, or DNS (the official HTTP demo hard-codes an IP).</li>
<li><code>Clock.unix</code> and <code>Clock.local</code>: because <code>IO.now</code> is milliseconds since boot, with no wall clock and no timezone.</li>
<li><code>Tty.width</code>, <code>Tty.raw</code>, <code>Tty.key</code>, <code>Tty.size</code>: because there&rsquo;s no stdin, no raw mode, no terminal query. None of that exists.</li>
<li><code>Clock.millis</code>: convenience.</li>
</ul>
<p>And the official guide is clear about the trade: <strong>there&rsquo;s no ABI promise</strong>. The names are runtime internals and any release can rename them, so <em>&ldquo;rebuild your effects with every update&rdquo;</em>. In a language with nine releases in ten hours, good luck with that. Details that complete the picture: there&rsquo;s no flag to link an extra library (ghpending uses a <code>$CC</code> wrapper that injects <code>-lcurl</code>), custom handle types are WONTFIX, and including a C header mid-file worked by luck, because the runtime defines short names like <code>lock</code> and <code>u32</code>.</p>
<p>Here&rsquo;s where the conceptual point lands: <strong>the moment you cross the FFI boundary, the proof stays behind</strong>. The checker guarantees what&rsquo;s inside its model; the C code on the other side is an act of faith. Rust has exactly the same problem with <code>unsafe</code>, where the borrow checker&rsquo;s guarantees hold up to the edge of the block and inside it the proof is on you. If you want strong end-to-end guarantees, the C boundary has to be minimal or nonexistent, and in Bend today it&rsquo;s enormous for any real program.</p>
<h3>Where Bend fits<span class="hx:absolute hx:-mt-20" id="where-bend-fits"></span>
    <a href="#where-bend-fits" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Crossing the three projects with what the language offers, the winning shape is <strong>a small pure core, with a sharp spec that tests cover poorly, inside a thin shell of effects</strong>:</p>
<ul>
<li>allocators, schedulers, rate limiters, quota logic</li>
<li>deterministic game or simulation rules, especially with rollback netcode (the only domain where Base already has what&rsquo;s needed: UDP, windowing, audio)</li>
<li>consensus state machines, ledgers, matching engines</li>
<li>parsers and codecs with a round-trip law</li>
<li>CRDT merge functions (commutative, associative, idempotent)</li>
<li>access policy evaluators (like ai-jail&rsquo;s)</li>
</ul>
<p>And the design center the authors declare: <strong>the AI writes the code and the proofs, the human writes only the LAWS.bend, and the checker arbitrates</strong>. The shortest honest description I found: a <em>proof-carrying language for AI-written kernels</em>. Compared with Lean or Coq, which prove well and deliver applications poorly, Bend is far more executable.</p>
<p>One thing that surprised me in my own numbers: at first I&rsquo;d assume the natural niche was academic math research. It&rsquo;s the opposite. For mathematics it loses badly to Lean 4, Coq, and Isabelle: no tactics, almost no inference, nothing at mathlib&rsquo;s scale, no reals, and a foundation (<code>Type : Type</code>, no positivity check) that would make a mathematician&rsquo;s hair stand on end. What it does is <strong>software verification</strong>, closer to Dafny, F*, or Liquid Haskell than to a proof assistant.</p>
<p>Two limitations weigh even in that niche: Bend emits a whole program instead of a library, so the proven core can&rsquo;t be linked into a Rust application today; and the cost of proving without a lemma library is high.</p>
<h3>What would change the verdict<span class="hx:absolute hx:-mt-20" id="what-would-change-the-verdict"></span>
    <a href="#what-would-change-the-verdict" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>Recorded in <code>use-cases.md</code>, the list of what would make me reevaluate:</p>
<ul>
<li>library output with a C ABI, so a proven core can live inside a Rust or C program</li>
<li>U64/I64 and F64</li>
<li>a lemma library for Nat, lists, and ordering</li>
<li>stdin, subprocesses, TLS, and JSON in Base, or a package hub that has them</li>
<li>incremental or faster native builds (62s for 1,000 lines today)</li>
<li>an effects ABI that survives a release</li>
</ul>
<h2>The unvarnished reality<span class="hx:absolute hx:-mt-20" id="the-unvarnished-reality"></span>
    <a href="#the-unvarnished-reality" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Now my reading, and you already know I don&rsquo;t sugarcoat.</p>
<p><strong>It&rsquo;s a newborn language.</strong> It launched two days ago. Outside the language&rsquo;s own repo, a GitHub search today finds only a few dozen 48-hour spikes, almost all with zero stars, nothing that looks like a real project. The repo&rsquo;s 21 thousand stars are almost all inherited from Bend 1; the repo was renamed. The site&rsquo;s demos are all toys: a little game, Game of Life, pow2. They&rsquo;re demonstrations of possibility, with no real usage yet.</p>
<p><strong>The stdlib needs to grow by an order of magnitude.</strong> A language&rsquo;s strength lives in its standard library and ecosystem. The only historical exception to that rule was JavaScript, and that&rsquo;s a can of worms that doesn&rsquo;t fit in this article.</p>
<p><strong>The killer app is missing.</strong> Every language that made it had one. My tests point to the niche (proven pure cores, written by AI), but someone needs to turn that into an app the world wants to use. And here&rsquo;s a concrete suggestion, free of charge: Taelin could pick popular open source software he thinks would be better in Bend and rewrite it. Taelin is the exact opposite of the stubborn maintainer who turns his nose up at AI: he designed the whole language around AI. So put that to work: grab a ripgrep, a jq, a sqlite, rewrite it with AI in Bend, and publish the side-by-side comparison. Performance, lines of code, and above all what the proofs guarantee that the original doesn&rsquo;t. A real-world before/after like that is worth more than a thousand little game demos. And as a bonus, every one of those rewrites hands back the most valuable feedback there is: which stdlib holes actually hurt in a real project. Nothing reprioritizes what to implement in the next version better than real code hitting what&rsquo;s missing.</p>
<p><strong>Don&rsquo;t expect webapps, mobile, or anything day-to-day anytime soon.</strong> Maybe that&rsquo;s not even the goal, and that&rsquo;s fine too. But the <em>&ldquo;C speed&rdquo;</em> promise cuts the other way: if the differentiator is performance, it&rsquo;s easier to go straight to C, especially now, with an LLM writing the code for you.</p>
<p>And here&rsquo;s my concrete situation. I use Rust a ton. I <strong>hate</strong> the syntax, and I find the ergonomics miserable. But since the LLM writes the code and I don&rsquo;t have to deal with it by hand, I tolerate it, and in exchange I get performance, good-enough guarantees, and a gigantic ecosystem of crates and frameworks. Ecosystem. That&rsquo;s what decides it.</p>
<blockquote>
  <p>I don&rsquo;t pick a language by the elegance of the paper. I pick it by the size of the ecosystem I don&rsquo;t have to write by hand.</p>

</blockquote>
<h2>The proof-and-GPU focus, and the doubt that remains<span class="hx:absolute hx:-mt-20" id="the-proof-and-gpu-focus-and-the-doubt-that-remains"></span>
    <a href="#the-proof-and-gpu-focus-and-the-doubt-that-remains" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>One thing nagged me the whole time: Taelin clearly concentrates everything on two pillars, proofs and GPU, and I wasn&rsquo;t sure what that meant for the language&rsquo;s scope. I went and read what he himself said in the <a href="https://x.com/VictorTaelin/status/2100374221671051472"target="_blank" rel="noopener">pre-launch thread</a> and on HN, and the story is more interesting than I assumed.</p>
<p>The GPU pillar is research inheritance. Ten years of interaction nets and optimal reduction, from HVM to here. But there&rsquo;s a nuance that surprised me: Bend 2 consciously <strong>betrays</strong> his academic idealism. In his own words: <em>&ldquo;Bend2 is designed to be practical, not idealistic&rdquo;</em>, closer to C than to Haskell, because he couldn&rsquo;t make interaction nets beat the lower-order variants on common hardware. The GPU stayed as the practical application of the obsession, without becoming a fetish.</p>
<p>The proof pillar is older than the LLM era: it goes back to the Ethereum Foundation days, immutable contracts holding millions with a bug, which produced Formality. The LLM era just gave the old obsession a market outfit: <em>&ldquo;the same capabilities that proved Navier-Stokes will now be writing real proofs that your own apps are correct&rdquo;</em>.</p>
<p>And here comes my hypothesis that didn&rsquo;t survive the research. I was ready to write that the minimal stdlib was maybe <strong>on purpose</strong>, that the idea was never general-purpose programming, and that my criticism of the library desert was aiming at the wrong target. Wrong me. His words are general ambition, period: <em>&ldquo;the potential to become the most sensible choice for any vibe-coded project&rdquo;</em>, <em>&ldquo;Bend is made for the general public&rdquo;</em>, and the team <em>&ldquo;working hard to [&hellip;] make this language competitive with the mature alternatives in the market&rdquo;</em>. The only deliberate leanness is the checker&rsquo;s, no inference, verbose on purpose. The small Base is day two of life, so much so that he himself asks for a mathlib.</p>
<p>That makes my stdlib criticism bite even <strong>harder</strong>: if the declared goal is to be the choice for any vibe-coded project, the comparison with mature ecosystems is the game he chose to play.</p>
<p>And there&rsquo;s the business angle, which he admits with rare frankness: <em>&ldquo;we ship without a product&rdquo;</em>. The language is the free substrate; the sellable thing is the <a href="https://bend-lang.com/bender"target="_blank" rel="noopener">Bender</a> and the proving infrastructure behind it. Worth keeping an eye on that space.</p>
<p>The doubt that remains, and this one I register with no answer: the generalist ambition is his north star, but the reality I measured pushes Bend toward the niche of AI-written verified cores. Which of the two wins, only time will tell.</p>
<h2>The final word<span class="hx:absolute hx:-mt-20" id="the-final-word"></span>
    <a href="#the-final-word" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If I could give Taelin one piece of advice, it would be this: the hard part, the research, he&rsquo;s already done. Now comes the less glamorous part, the one that decides whether a language lives or dies: <strong>marketing and ecosystem building</strong>. Adoption, teaching material, killer app, stdlib, mathlib. Going way beyond toys and demos.</p>
<p>The example that comes to mind is Zig. Years on the road and still fighting for adoption, even with immediate practical use: <code>zig cc</code> alone is a trivial drop-in for compiling C, without using any of the language. Real apps use it; Mitchell Hashimoto&rsquo;s <a href="https://ghostty.org/"target="_blank" rel="noopener">Ghostty</a> is the famous case. But Andrew Kelley, the author, is a notoriously difficult figure, from the crowd that turns its nose up at AI, and he clearly doesn&rsquo;t understand ecosystem building, and that holds Zig back. Technical perfectionism doesn&rsquo;t mix with marketing and adoption.</p>
<p>Bend 2 has real potential. Instant checker, proofs catching bugs that tests don&rsquo;t catch, parallelism without ceremony. But potential alone means nothing if adoption doesn&rsquo;t come. I&rsquo;m rooting for it to come, and rooting double because a Brazilian is leading.</p>
<p>And one final provocation, this time for you who read this far. Thin stdlib, zero frameworks, zero third-party libraries, no killer app: all of that stops being a problem when you look from another angle and becomes an opportunity map. Everything missing in Bend is something someone will have to write, and whoever gets there first etches their name into the project&rsquo;s history. If you always wanted to contribute to impactful open source but never found room in the mature projects, where everything already exists, here&rsquo;s an empty lot waiting.</p>
<p>Those are my two cents. Don&rsquo;t take it too seriously.</p>
]]></content:encoded><category>programming-languages</category><category>artificial-intelligence</category><category>rust</category></item><item><title>The Bun in Rust Response Andrew Kelly Should Have Written</title><link>https://akitaonrails.github.io/en/2026/07/09/the-bun-in-rust-response-andrew-kelly-should-have-written/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/07/09/the-bun-in-rust-response-andrew-kelly-should-have-written/</guid><pubDate>Thu, 09 Jul 2026 17:00:00 -0300</pubDate><description>&lt;p&gt;I read Jarred Sumner&amp;rsquo;s official post, &lt;a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener"&gt;&amp;ldquo;Rewriting Bun in Rust&amp;rdquo;&lt;/a&gt;. And I&amp;rsquo;ll start by making my bias clear: I am completely in favor of Bun&amp;rsquo;s decision to rewrite in Rust. Not because Rust won some tribal war against Zig. That&amp;rsquo;s not it. I like Zig a lot. But, for Bun&amp;rsquo;s context, the decision looks understandable, serious, and well executed.&lt;/p&gt;
&lt;p&gt;What I liked most about Jarred&amp;rsquo;s post was the level of detail. He did not sell the idiotic fantasy of writing a prompt like &amp;ldquo;rewrite Bun in Rust&amp;rdquo; and waiting for a miracle. Quite the opposite. What he described was procedure: &lt;code&gt;PORTING.md&lt;/code&gt;, lifetime analysis, &lt;code&gt;LIFETIMES.tsv&lt;/code&gt;, a trial run with three files, dynamic workflows, adversarial reviewers, process correction when the agents started doing stupid things with &lt;code&gt;git stash&lt;/code&gt;, then compiler errors becoming a work queue, then smoke tests, then the whole suite, then multi-platform CI. No irresponsible vibe coding. Engineering using AI as a multiplier.&lt;/p&gt;</description><content:encoded><![CDATA[<p>I read Jarred Sumner&rsquo;s official post, <a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener">&ldquo;Rewriting Bun in Rust&rdquo;</a>. And I&rsquo;ll start by making my bias clear: I am completely in favor of Bun&rsquo;s decision to rewrite in Rust. Not because Rust won some tribal war against Zig. That&rsquo;s not it. I like Zig a lot. But, for Bun&rsquo;s context, the decision looks understandable, serious, and well executed.</p>
<p>What I liked most about Jarred&rsquo;s post was the level of detail. He did not sell the idiotic fantasy of writing a prompt like &ldquo;rewrite Bun in Rust&rdquo; and waiting for a miracle. Quite the opposite. What he described was procedure: <code>PORTING.md</code>, lifetime analysis, <code>LIFETIMES.tsv</code>, a trial run with three files, dynamic workflows, adversarial reviewers, process correction when the agents started doing stupid things with <code>git stash</code>, then compiler errors becoming a work queue, then smoke tests, then the whole suite, then multi-platform CI. No irresponsible vibe coding. Engineering using AI as a multiplier.</p>
<p>That&rsquo;s why I was disappointed by Andrew Kelley&rsquo;s response, <a href="https://andrewkelley.me/post/my-thoughts-bun-rust-rewrite.html"target="_blank" rel="noopener">&ldquo;My Thoughts on the Bun Rust Rewrite&rdquo;</a>. Andrew is Zig&rsquo;s creator. I wanted to read the best possible technical argument defending Zig. I wanted to see him explain where Bun confused a language problem with an architecture problem. I wanted to see which Zig APIs could have helped, which invariants should have been hardened, where <code>comptime</code>, allocators, arenas, <code>errdefer</code>, <code>DebugAllocator</code>, and Zig&rsquo;s new I/O design could change the analysis.</p>
<p>Instead, a large part of the text becomes an attempt at character assassination. Comments about personality, management, VC, startup culture, backstage gossip. There may even be real resentment there. I don&rsquo;t care. It does not help technically.</p>
<p>So I decided to do the exercise I expected to have read: put Claude Fable 5 and GPT 5.6 Sol as technical arbiters and ask them for the answer Andrew should have written. Not the polite answer. Not the fan-club answer. The technical answer.</p>
<p>Before handing over the microphone, one off-topic note. If you, like me, stared at those beautiful GitHub animations in Bun&rsquo;s post and thought &ldquo;I want that in my projects&rdquo;, I went there and built it. <a href="https://github.com/akitaonrails/github-visualize"target="_blank" rel="noopener"><code>github-visualize</code></a> is a self-hosted dashboard that tracks your repositories and replays commit evolution, the hourly heatmap, and the CI race until everything turns green. It&rsquo;s all open on GitHub.</p>
<p><a href="https://github.com/akitaonrails/github-visualize"target="_blank" rel="noopener"><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/07/09/bun-zig-rust/github-visualize-commit-timeline.png" alt="Animated commit timeline in github-visualize"  loading="lazy" /></a></p>
<p>But now let&rsquo;s separate support from proof. One thing is to say &ldquo;I would have done something similar&rdquo;. Another is to say &ldquo;it was technically impossible to continue in Zig&rdquo;. To answer that, we need to look at causes: which bugs were really language bugs, which were architecture problems, which were ecosystem costs, and which could have been reduced with different discipline inside Zig itself.</p>
<p>That&rsquo;s where the analysis starts.</p>
<p>First, minimum vocabulary. <strong>Use-after-free</strong> is using memory after freeing it. <strong>Double-free</strong> is freeing the same memory twice. <strong>Leak</strong> is forgetting to free. <strong>Ownership</strong> is who is responsible for destroying a resource. <strong>Lifetime</strong> is how long a reference remains valid. <strong>FFI</strong> is the boundary where Rust or Zig call C/C++ and lose part of the language guarantees. <strong>GC root</strong> is a way to tell the garbage collector: &ldquo;do not collect this object yet&rdquo;. <strong>Pinning</strong> is holding a buffer in place so it cannot be moved, detached, or invalidated while someone still has a pointer to it. <strong>Reentrancy</strong> is when a call enters the runtime again before the previous call has finished.</p>
<h2>What Rust actually buys<span class="hx:absolute hx:-mt-20" id="what-rust-actually-buys"></span>
    <a href="#what-rust-actually-buys" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The strongest thesis in Bun&rsquo;s post is simple: a large percentage of the listed bugs were use-after-free, double-free, or forgotten resources in error paths. In safe Rust, a good portion of that becomes compiler error or automatic cleanup through <code>Drop</code>, as long as the resource is modeled as an owning value. Rust does not prove the general absence of leaks: <code>Rc</code> cycles, <code>mem::forget</code>, <code>Box::leak</code>, and <code>abort</code> still exist. But it makes accidental leaking in error paths much harder.</p>
<p>That is correct. And it is a strong argument.</p>
<p>Rust&rsquo;s strong point here is not magic. It is that Rust forces certain ownership and lifetime relationships to appear in the type, instead of being scattered around as convention. When a value has an owner, when it is moved, when it leaves scope, when a destructor runs, that stops being just an agreement between programmers. The compiler participates.</p>
<p>In Bun&rsquo;s current code, you can see the pattern in miniature. A MySQL byte wrapper stores a pinned JavaScriptCore value, and <code>Drop</code> undoes the pin when the object dies:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="k">impl</span><span class="w"> </span><span class="nb">Drop</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="n">Bytes</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">fn</span> <span class="nf">drop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">if</span><span class="w"> </span><span class="o">!</span><span class="bp">self</span><span class="p">.</span><span class="n">pinned</span><span class="p">.</span><span class="n">is_empty</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="c1">// `pinned` is rooted by the caller&#39;s MarkedArgumentBuffer for the
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="c1">// lifetime of this Value (see struct doc); the FFI itself is `safe fn`.
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="n">JSC__JSValue__unpinArrayBuffer</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">pinned</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// self.slice dropped automatically
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/sql_jsc/mysql/MySQLValue.rs#L172-L180"target="_blank" rel="noopener"><code>src/sql_jsc/mysql/MySQLValue.rs</code></a>. The important detail is not the FFI call itself. It is that cleanup follows the value&rsquo;s owner.</p>
<p>This is the round Rust wins cleanly. In a small project, discipline is enough. In half a million lines, with reentrant callbacks, threadpool, libuv, sockets, TLS, JavaScriptCore, parser, bundler, package manager, and Node API, discipline becomes recurring cost.</p>
<p>Rust probably reduced the invalid state space the team needed to keep in their heads. Real win.</p>
<p>But a real win does not mean total victory.</p>
<h2>Zig was not defenseless<span class="hx:absolute hx:-mt-20" id="zig-was-not-defenseless"></span>
    <a href="#zig-was-not-defenseless" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The weakest argument you can make against Zig is pretending it is C with new syntax. It is not. Zig has explicit mechanisms to address exactly part of these problems.</p>
<p>The most obvious one is <a href="https://ziglang.org/documentation/master/#errdefer"target="_blank" rel="noopener"><code>errdefer</code></a>, the idiomatic mechanism for cleanup on error paths:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-zig" data-lang="zig"><span class="line"><span class="cl"><span class="k">fn</span><span class="w"> </span><span class="nf">readThing</span><span class="p">(</span><span class="n">alloc</span><span class="p">:</span><span class="w"> </span><span class="n">std</span><span class="p">.</span><span class="n">mem</span><span class="p">.</span><span class="n">Allocator</span><span class="p">,</span><span class="w"> </span><span class="n">fail</span><span class="p">:</span><span class="w"> </span><span class="kt">bool</span><span class="p">)</span><span class="w"> </span><span class="o">!</span><span class="p">[]</span><span class="kt">u8</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kr">const</span><span class="w"> </span><span class="n">buf</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">try</span><span class="w"> </span><span class="n">alloc</span><span class="p">.</span><span class="nf">alloc</span><span class="p">(</span><span class="kt">u8</span><span class="p">,</span><span class="w"> </span><span class="mi">16</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">errdefer</span><span class="w"> </span><span class="n">alloc</span><span class="p">.</span><span class="nf">free</span><span class="p">(</span><span class="n">buf</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="n">fail</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="k">error</span><span class="p">.</span><span class="n">Boom</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">buf</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">test</span><span class="w"> </span><span class="s">&#34;leak em caminho de erro&#34;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kr">const</span><span class="w"> </span><span class="n">alloc</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">std</span><span class="p">.</span><span class="n">testing</span><span class="p">.</span><span class="n">allocator</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">try</span><span class="w"> </span><span class="n">std</span><span class="p">.</span><span class="n">testing</span><span class="p">.</span><span class="nf">expectError</span><span class="p">(</span><span class="k">error</span><span class="p">.</span><span class="n">Boom</span><span class="p">,</span><span class="w"> </span><span class="nf">readThing</span><span class="p">(</span><span class="n">alloc</span><span class="p">,</span><span class="w"> </span><span class="kc">true</span><span class="p">));</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><a href="https://ziglang.org/documentation/master/#Report-Memory-Leaks"target="_blank" rel="noopener"><code>std.testing.allocator</code> reports leaks</a> in tests. <a href="https://github.com/ziglang/zig/blob/master/lib/std/heap/debug_allocator.zig"target="_blank" rel="noopener"><code>DebugAllocator</code></a> helps find double-free, leaks, and many invalid memory uses, including making bugs noisier by not immediately reusing addresses. This is not the same thing as a static proof of the absence of use-after-free. It is not a borrow checker. It does not cover allocation done inside C++, JavaScriptCore, BoringSSL, or any other external allocator. It only catches what goes through it and only on the paths exercised by tests.</p>
<p>The allocator&rsquo;s own comment is precise about what it provides:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-zig" data-lang="zig"><span class="line"><span class="cl"><span class="cs">//! * Captures stack traces on allocation, free, and optionally resize.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="cs">//! * Double free detection, which prints all three traces (first alloc, first</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="cs">//!   free, second free).</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="cs">//! * Leak detection, with stack traces.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="cs">//! * Never reuses memory addresses, making it easier for Zig to detect branch</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="cs">//!   on undefined values in case of dangling pointers.</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/ziglang/zig/blob/master/lib/std/heap/debug_allocator.zig#L3-L12"target="_blank" rel="noopener"><code>debug_allocator.zig</code></a>. It is a good testing and debugging tool. It is not a theorem.</p>
<p>Even so, it is wrong to say Zig had no tools. It did.</p>
<p>The difference is feedback timing. In Rust, a lot appears at compile time. In Zig, a lot appears in tests, fuzzing, instrumented allocators, ASAN, review, and architectural style. In critical code, that matters. Early feedback is better.</p>
<p>But it also matters to say the whole sentence: if the problem was lack of clear invariants in the Zig code, the answer could have been a rewrite in Rust. It could also have been an aggressive restructuring of the Zig.</p>
<p>We do not know which would have been cheaper. We only know which one Bun chose.</p>
<h2><code>Drop</code> is not just sugar, but it is not a miracle either<span class="hx:absolute hx:-mt-20" id="drop-is-not-just-sugar-but-it-is-not-a-miracle-either"></span>
    <a href="#drop-is-not-just-sugar-but-it-is-not-a-miracle-either" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Bun&rsquo;s post is right to contrast <code>defer</code> with <code>Drop</code>.</p>
<p>In Zig, you write:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-zig" data-lang="zig"><span class="line"><span class="cl"><span class="kr">var</span><span class="w"> </span><span class="n">resource</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">try</span><span class="w"> </span><span class="n">Resource</span><span class="p">.</span><span class="nf">init</span><span class="p">(</span><span class="n">allocator</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">defer</span><span class="w"> </span><span class="n">resource</span><span class="p">.</span><span class="nf">deinit</span><span class="p">();</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is explicit. It is in your face. There is no hidden control flow. That is Zig&rsquo;s philosophy: if something happens, you should be able to point to where it happens.</p>
<p>In Rust, if the type implements <code>Drop</code>, cleanup follows the owner:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="k">impl</span><span class="w"> </span><span class="nb">Drop</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="n">Resource</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">fn</span> <span class="nf">drop</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="bp">self</span><span class="p">.</span><span class="n">deinit</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This is not just syntactic sugar. <a href="https://doc.rust-lang.org/reference/destructors.html"target="_blank" rel="noopener">Rust&rsquo;s destruction rules</a> connect <code>Drop</code> to ownership, move semantics, field destruction order, and panic unwind. Ordinary safe code cannot use a value after it has been moved, nor double-drop the object as if it still owned it. It is a language guarantee.</p>
<p>But the guarantee ends where semantics the compiler does not understand begin.</p>
<p>If <code>drop</code> calls an FFI function to unprotect a JavaScriptCore value, Rust guarantees the destructor will run on normal scope and drop paths, not on <code>abort</code>, <code>mem::forget</code>, intentional leaks, or <code>Rc</code> cycles. It does not prove that the JSC pointer is still valid. It does not prove the GC has no special rule. It does not prove an asynchronous C callback will not call back after you destroyed the wrapper.</p>
<p>The comment in the same Bun wrapper shows the size of the contract that still remains outside the compiler:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="sd">/// JS could `transfer()`/detach an earlier ArrayBuffer, or drop the last
</span></span></span><span class="line"><span class="cl"><span class="sd">/// JS reference to it and force GC, while we still hold a borrowed slice
</span></span></span><span class="line"><span class="cl"><span class="sd">/// into it. Pinning the backing `ArrayBuffer` makes it non-detachable for
</span></span></span><span class="line"><span class="cl"><span class="sd">/// the duration, and the caller&#39;s stack-scoped `MarkedArgumentBuffer`
</span></span></span><span class="line"><span class="cl"><span class="sd">/// roots the wrapper so GC can&#39;t sweep the cell. `Drop` unpins.
</span></span></span><span class="line"><span class="cl"><span class="k">pub</span><span class="w"> </span><span class="k">struct</span> <span class="nc">Bytes</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="n">slice</span>: <span class="nc">ZigStringSlice</span><span class="p">,</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/sql_jsc/mysql/MySQLValue.rs#L142-L154"target="_blank" rel="noopener"><code>MySQLValue.rs</code></a>. Notice the size of the contract. It did not disappear because this is Rust. It became documented and encapsulated.</p>
<p>That is the real boundary: Rust improves the Rust side of the contract. It does not perform magic inside the JavaScriptCore heap.</p>
<h2>The FFI boundary is still hell<span class="hx:absolute hx:-mt-20" id="the-ffi-boundary-is-still-hell"></span>
    <a href="#the-ffi-boundary-is-still-hell" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Bun is not a simple CLI. Bun is a JavaScript runtime. It talks to JavaScriptCore, uWebSockets, usockets, BoringSSL, SQLite, compression libraries, system APIs, asynchronous handles, buffers that JavaScript can detach in the middle of a coercion, reentrant callbacks, and a conservative GC.</p>
<p>That is where the debate gets interesting.</p>
<p>Inside an <code>unsafe</code> block, Rust requires discipline. The <a href="https://doc.rust-lang.org/nomicon/ffi.html"target="_blank" rel="noopener">Rustonomicon on FFI</a> itself treats foreign calls and raw pointers as programmer obligations. If the binding is lying, if the real ownership does not match the type, if the foreign callback outlives the Rust object, the compiler does not save you.</p>
<p>Bun&rsquo;s binding documents the preconditions in the declaration itself:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="k">unsafe</span><span class="w"> </span><span class="k">extern</span><span class="w"> </span><span class="s">&#34;C&#34;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="sd">/// By-value `JSValue`; C++ side null-checks and reads its own heap state.
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="sd">/// No caller-side preconditions → `safe fn`.
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">safe</span><span class="w"> </span><span class="k">fn</span> <span class="nf">JSC__JSValue__unpinArrayBuffer</span><span class="p">(</span><span class="n">v</span>: <span class="nc">JSValue</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="sd">/// 0 = detached/null, 1 = FastTypedArray (GC-movable — caller should dupe;
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="sd">/// no unpin needed), 2 = pinned ArrayBuffer (caller must unpin).
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/sql_jsc/mysql/MySQLValue.rs#L859-L868"target="_blank" rel="noopener"><code>MySQLValue.rs</code></a>. The language lets you declare a foreign call safe. The obligation to prove that safety remains ours.</p>
<p>But there is an important difference: Rust lets you encapsulate that <code>unsafe</code> behind safe APIs. You can create handle types, <code>NonNull</code>, <code>PhantomData</code>, <code>Pin</code>, lifetimes marking the relationship between wrapper and runtime, wrappers that do not implement <code>Send</code> or <code>Sync</code>, RAII for GC roots, and a smaller surface for audit. The goal is not to eliminate danger. It is to shrink the place where danger lives.</p>
<p>The most didactic example in Bun&rsquo;s post is the <code>uv_close</code> bug. The first version compiled, but handed a <code>Box</code> to libuv to close asynchronously and let the <code>Box</code> fall at the end of the scope. Use-after-free followed by double-free. The fixed version in the current code makes the transfer explicit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="n">spawn</span>::<span class="n">WindowsStdioResult</span>::<span class="n">Buffer</span><span class="p">(</span><span class="n">pipe</span><span class="p">)</span><span class="w"> </span><span class="o">=&gt;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// `uv_close` is async — libuv keeps the raw handle pointer until the
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// next loop tick. Leak the Box so it outlives this scope; dropping it
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c1">// here would be a use-after-free + double-free when the callback fires.
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nb">Box</span>::<span class="n">leak</span><span class="p">(</span><span class="n">pipe</span><span class="p">).</span><span class="n">close</span><span class="p">(</span><span class="n">Subprocess</span>::<span class="n">on_pipe_close</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/runtime/api/bun/js_bun_spawn_bindings.rs#L1359-L1366"target="_blank" rel="noopener"><code>js_bun_spawn_bindings.rs</code></a>. It compiled in Rust and was still wrong at the C/async boundary. The review process caught it.</p>
<p>Here <code>leak</code> does not mean &ldquo;leak forever&rdquo;. It means removing the <code>Box</code> from Rust&rsquo;s lexical control and transferring ownership to libuv&rsquo;s asynchronous protocol, whose callback later reconstructs and frees the allocation.</p>
<p>Zig also lets you write good wrappers. The difference is that it does not have the <code>safe</code>/<code>unsafe</code> boundary as part of the language. In Zig, everything is explicit and reviewable. In Rust, a larger part of the contract can become type.</p>
<p>For a large team, that matters. It matters even more when part of the code is being generated by agents.</p>
<h2>Arenas: yes, Bun already used them<span class="hx:absolute hx:-mt-20" id="arenas-yes-bun-already-used-them"></span>
    <a href="#arenas-yes-bun-already-used-them" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here the pro-Zig argument needs to be honest. Arenas are idiomatic and excellent for certain problems: parser, AST, request lifecycle, data that dies together.</p>
<p>But Bun&rsquo;s post does not ignore arenas. It explicitly says the existing approach was already a mix of lifetimes by arena, reference counting, and &ldquo;pay really close attention&rdquo;. It even cites parser state and AST nodes as good cases for an arena.</p>
<p>The excerpt from the post is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Our current approach is a mix of:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">- arena lifetimes, where the scope of when it&#39;s accessible is clear
</span></span><span class="line"><span class="cl">  (parser state doesn&#39;t escape the calling function and so AST nodes
</span></span><span class="line"><span class="cl">  are a good choice there)
</span></span><span class="line"><span class="cl">- reference-counting
</span></span><span class="line"><span class="cl">- pay really close attention</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener">Bun&rsquo;s official post</a>. The good counter-argument is not &ldquo;use arena&rdquo;. They already did. The good counter-argument is asking whether the remaining boundaries could have been hardened in Zig.</p>
<p>So the correct response is not &ldquo;you forgot arenas&rdquo;. They did not forget.</p>
<p>The correct response is: arena solves the case where lifetime is clear. It does not, by itself, solve a socket handle that closes asynchronously, a callback that runs later, an SSL session that needs a specific <code>free</code>, a GC root, a pointer that may be invalidated by reentrancy, or an object that crosses several API layers with implicit ownership.</p>
<p>Arena is a good tool. It is not a religion.</p>
<p>If Bun had hardened the boundaries more in Zig, maybe part of the rewrite&rsquo;s benefit could have come from that: more restricted APIs, fewer raw pointers crossing subsystems, handles with explicit ownership, allocators instrumented by default in tests, fuzzing aimed at reentrancy, wrappers that force <code>deinit</code>, less &ldquo;pay attention&rdquo;.</p>
<p>But that would also be a rewrite. Just inside Zig.</p>
<h2>The borrow checker helps, but not where the internet thinks<span class="hx:absolute hx:-mt-20" id="the-borrow-checker-helps-but-not-where-the-internet-thinks"></span>
    <a href="#the-borrow-checker-helps-but-not-where-the-internet-thinks" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>A lot of people talk about the borrow checker as if it were a universal bug detector. It is not. The borrow checker is the part of the Rust compiler that prevents certain invalid borrows: two mutable owners at the same time, a live reference after the owner dies, use after move. That is a lot. It is not everything.</p>
<p>It does not understand the JavaScriptCore heap. It does not understand every GC rule. It does not automatically understand that a C callback can trigger JS, that JS can detach an <code>ArrayBuffer</code>, that it can reenter the runtime, that it can invalidate a pointer captured earlier. These invariants need to be modeled.</p>
<p>But when they are modeled, Rust charges you.</p>
<p>A small example in Bun: a <code>uSockets</code> wrapper that turns a C pointer into a Rust borrow tied to <code>&amp;mut self</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="k">impl</span><span class="w"> </span><span class="n">PacketBuffer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">pub</span><span class="w"> </span><span class="k">fn</span> <span class="nf">get_peer</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">index</span>: <span class="nc">c_int</span><span class="p">)</span><span class="w"> </span>-&gt; <span class="kp">&amp;</span><span class="nc">mut</span><span class="w"> </span><span class="n">sockaddr_storage</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// SAFETY: uSockets guarantees a non-null, properly-aligned peer pointer for
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// indices &lt; packet count. The returned storage lives inside the C-owned packet
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// buffer, which is exclusively loaned to the data callback for its duration; no
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// other Rust or C path holds a reference to it. The reborrow of `&amp;mut self`
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// ties the returned lifetime to this handle, so the borrow checker prevents
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="c1">// obtaining a second overlapping `&amp;mut` via `get_peer`/`get_payload`.
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">unsafe</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="o">*</span><span class="n">us_udp_packet_buffer_peer</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span><span class="w"> </span><span class="n">index</span><span class="p">)</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/uws_sys/udp.rs#L200-L209"target="_blank" rel="noopener"><code>src/uws_sys/udp.rs</code></a>. The <code>unsafe</code> still exists. But after the wrapper creates the reference, the borrow checker can prevent certain abuses on the Rust side.</p>
<p>And Bun is not only JavaScript heap. It is parser, bundler, module resolver, package manager, file watcher, HTTP protocol, buffers, caches, tables, queues, process state, glue code. In these parts, local ownership matters a lot. <code>Vec</code>, <code>String</code>, slices, maps, wrappers, enums, finite states: all of that becomes easier to refactor with the compiler slapping your hand.</p>
<p>The honest argument is not &ldquo;the borrow checker solves Bun&rdquo;. It is: the borrow checker reduces many classes of error on Bun&rsquo;s Rust side, and that frees human attention for the part that remains hard.</p>
<p>That is strong enough. No need to exaggerate.</p>
<h2><code>comptime</code>: here Zig has a real advantage<span class="hx:absolute hx:-mt-20" id="comptime-here-zig-has-a-real-advantage"></span>
    <a href="#comptime-here-zig-has-a-real-advantage" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The best technical counterexample in Bun&rsquo;s own post is <code>Output.pretty</code>.</p>
<p>In Zig, the format string could be a <code>comptime</code> parameter. Bun&rsquo;s own post puts the difference like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="c1">// Zig:
</span></span></span><span class="line"><span class="cl"><span class="k">pub</span><span class="w"> </span><span class="n">inline</span><span class="w"> </span><span class="k">fn</span> <span class="nf">pretty</span><span class="p">(</span><span class="n">comptime</span><span class="w"> </span><span class="n">fmt</span>: <span class="nc">string</span><span class="p">,</span><span class="w"> </span><span class="n">args</span>: <span class="nc">anytype</span><span class="p">)</span><span class="w"> </span><span class="n">void</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">Output</span><span class="p">.</span><span class="n">pretty</span><span class="p">(</span><span class="s">&#34;&lt;r&gt;{f}&lt;r&gt;&#34;</span><span class="p">,</span><span class="w"> </span><span class="p">.{</span><span class="n">hyperlink</span><span class="p">});</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">// Primeira API Rust:
</span></span></span><span class="line"><span class="cl"><span class="k">pub</span><span class="w"> </span><span class="k">fn</span> <span class="nf">pretty</span><span class="p">(</span><span class="n">payload</span>: <span class="nc">impl</span><span class="w"> </span><span class="n">PrettyFmtInput</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="n">Output</span>::<span class="n">pretty</span><span class="p">(</span><span class="fm">format_args!</span><span class="p">(</span><span class="s">&#34;&lt;r&gt;</span><span class="si">{}</span><span class="s">&lt;r&gt;&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">hyperlink</span><span class="p">));</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This allowed processing color and formatting markers in the literal known at compile time, before mixing runtime arguments. When porting to Rust, the first version became a function. Result: the tag parser touched bytes that came from the arguments. Issue <a href="https://github.com/oven-sh/bun/issues/30693"target="_blank" rel="noopener">#30693</a> shows the regression in port/canary involving an OSC 8 hyperlink escape and <code>&lt;r&gt;</code>, not a failure released in stable.</p>
<p>The bug was exactly the contamination between template and runtime argument:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">TerminalHyperlink::Display emits an OSC 8 hyperlink:
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">\x1b]8;;URL\x1b\ai\x1b]8;;\x1b\
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">The closing string terminator ST is the final two bytes `\x1b\`.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">This is then wrapped via `Output::pretty(format_args!(&#34;&lt;r&gt;{}&lt;r&gt;&#34;, hyperlink))`.
</span></span><span class="line"><span class="cl">The rendered string tail is `...\x1b\&lt;r&gt;`.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/issues/30693"target="_blank" rel="noopener">issue #30693</a>.</p>
<p>Then they fixed it using a macro.</p>
<p>The current code makes the difference explicit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="sd">/// Write to stdout with `&lt;tag&gt;` color expansion.
</span></span></span><span class="line"><span class="cl"><span class="sd">/// Function form: performs the `&lt;tag&gt;` → ANSI rewrite at runtime on the rendered
</span></span></span><span class="line"><span class="cl"><span class="sd">/// payload (using stdout&#39;s colour state). Prefer the `pretty!` macro for literal
</span></span></span><span class="line"><span class="cl"><span class="sd">/// templates so the rewrite happens at compile time.
</span></span></span><span class="line"><span class="cl"><span class="cp">#[inline(always)]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">pub</span><span class="w"> </span><span class="k">fn</span> <span class="nf">pretty</span><span class="p">(</span><span class="n">payload</span>: <span class="nc">impl</span><span class="w"> </span><span class="n">PrettyFmtInput</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">let</span><span class="w"> </span><span class="n">buf</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">payload</span><span class="p">.</span><span class="n">into_pretty_buf</span><span class="p">(</span><span class="n">enable_ansi_colors_stdout</span><span class="p">());</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">write_bytes</span><span class="p">(</span><span class="n">Destination</span>::<span class="n">Stdout</span><span class="p">,</span><span class="w"> </span><span class="o">&amp;</span><span class="n">buf</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And right below:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="sd">/// Compile-time `&lt;tag&gt;` → ANSI escape rewriter.
</span></span></span><span class="line"><span class="cl"><span class="sd">///
</span></span></span><span class="line"><span class="cl"><span class="sd">/// This must be a proc-macro because it consumes a string literal and emits
</span></span></span><span class="line"><span class="cl"><span class="sd">/// a new string literal usable as a `format_args!` template.
</span></span></span><span class="line"><span class="cl"><span class="sd">///
</span></span></span><span class="line"><span class="cl"><span class="sd">/// `pretty_fmt!(&#34;&lt;red&gt;{s}&lt;r&gt;&#34;, true)`  → `&#34;\x1b[31m{}\x1b[0m&#34;`
</span></span></span><span class="line"><span class="cl"><span class="sd">/// `pretty_fmt!(&#34;&lt;red&gt;{s}&lt;r&gt;&#34;, false)` → `&#34;{}&#34;`
</span></span></span><span class="line"><span class="cl"><span class="k">pub</span><span class="w"> </span><span class="k">use</span><span class="w"> </span><span class="n">bun_core_macros</span>::<span class="n">pretty_fmt</span><span class="p">;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/bun_core/output.rs#L1765-L1805"target="_blank" rel="noopener"><code>src/bun_core/output.rs</code></a>.</p>
<p>This demonstrates a legitimate strength of Zig: <code>comptime</code> is the language itself running at compile time. You do not switch to a parallel system. In Rust, macros are powerful and mature, but they are another mechanism. For some kinds of API, Zig is simply more direct.</p>
<p>But let&rsquo;s not turn a porting bug into universal proof against Rust. The problem there was not &ldquo;Rust is fragile&rdquo;. It was the wrong translation of a <code>comptime</code> API into a runtime function. Bun&rsquo;s own post recognizes that and fixes it with a macro.</p>
<p>The correct reading is simpler: Zig had a structural guarantee there. The first Rust version lost that guarantee. That cost a regression in port/canary.</p>
<p>Point for Zig.</p>
<h2>Binary size and performance: careful with cheap causality<span class="hx:absolute hx:-mt-20" id="binary-size-and-performance-careful-with-cheap-causality"></span>
    <a href="#binary-size-and-performance-careful-with-cheap-causality" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Bun&rsquo;s post shows good numbers: smaller binary, some faster benchmarks, better startup in certain scenarios.</p>
<p>The numbers from the post:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Bun v1.4.0 (canary) — Windows — 76 MB
</span></span><span class="line"><span class="cl">Bun v1.3.14          — Windows — 94 MB
</span></span><span class="line"><span class="cl">Bun v1.4.0 (canary) — Linux   — 70 MB
</span></span><span class="line"><span class="cl">Bun v1.3.14          — Linux   — 88 MB
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Bun.serve  — 169.6k → 177.7k — +4.8%
</span></span><span class="line"><span class="cl">node:http  — 103.8k → 108.5k — +4.5%
</span></span><span class="line"><span class="cl">fastify    —  91.5k →  95.9k — +4.8%</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener">Bun&rsquo;s official post</a>.</p>
<p>I believe the numbers. I do not believe in simple attribution.</p>
<p>A rewrite changes many things at the same time. It changes code layout, removes debt, changes toolchain, touches LTO, changes how generics/comptime are instantiated, changes linker, changes dead code, changes ICU, changes internal organization. When all of that happens together, you cannot look at the graph and say: &ldquo;it was Rust&rdquo;.</p>
<p>You also cannot say: &ldquo;it was only the rewrite&rdquo;.</p>
<p>The post itself talks about a combination: Rust, ICU changes, Identical Code Folding, LTO, codegen, accumulated cleanup. LTO is optimization at the final link stage, when the compiler sees more of the program before generating the binary. Identical Code Folding is the linker merging identical functions into one. <a href="https://andrewkelley.me/post/my-thoughts-bun-rust-rewrite.html"target="_blank" rel="noopener">Andrew is right</a> to point out that Zig also supports LTO and that some of these gains could have been pursued before. But that does not prove the result would be the same. It only proves causality is mixed.</p>
<p>Bun&rsquo;s <code>Cargo.toml</code> shows there is explicit build engineering in this story:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># Cargo&#39;s defaults (lto=false, codegen-units=16) leave us at ~105 crates × 16</span>
</span></span><span class="line"><span class="cl"><span class="c"># CGUs ≈ 1680 separately-optimized units with NO cross-crate inlining beyond</span>
</span></span><span class="line"><span class="cl"><span class="c"># `#[inline]`-annotated leaf fns. `lto = &#34;fat&#34;` + `codegen-units = 1` collapses</span>
</span></span><span class="line"><span class="cl"><span class="c"># the whole Rust crate graph into one LLVM module, matching Zig&#39;s shape.</span>
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">profile</span><span class="p">.</span><span class="nx">release</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">lto</span> <span class="p">=</span> <span class="s2">&#34;fat&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">codegen-units</span> <span class="p">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="nx">debug</span> <span class="p">=</span> <span class="s2">&#34;line-tables-only&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">strip</span> <span class="p">=</span> <span class="s2">&#34;none&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">panic</span> <span class="p">=</span> <span class="s2">&#34;abort&#34;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/Cargo.toml#L117-L135"target="_blank" rel="noopener"><code>Cargo.toml</code></a>.</p>
<p>And there is LTO configuration crossing Rust and C++:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="cm">/**
</span></span></span><span class="line"><span class="cl"><span class="cm"> * Cross-language LTO: rustc emits LLVM bitcode (`-Clinker-plugin-lto`) into
</span></span></span><span class="line"><span class="cl"><span class="cm"> * `libbun_rust.a` so the final lld `-flto=thin` link sees through Rust↔C++
</span></span></span><span class="line"><span class="cl"><span class="cm"> * call edges. When false but `lto` is true, both halves still LTO
</span></span></span><span class="line"><span class="cl"><span class="cm"> * independently (C++ via `-flto=thin`, Rust via `[profile.release] lto =
</span></span></span><span class="line"><span class="cl"><span class="cm"> * &#34;fat&#34;`); only the cross-language inlining is lost.
</span></span></span><span class="line"><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="cl"><span class="nx">crossLangLto</span>: <span class="kt">boolean</span><span class="p">;</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/scripts/build/config.ts#L118-L129"target="_blank" rel="noopener"><code>scripts/build/config.ts</code></a>. It is not &ldquo;Rust won&rdquo;. It is language, toolchain, linker, configuration, and code working together.</p>
<p>Same thing for stack usage and parser depth. The post attributes improvement to Rust emitting better <a href="https://llvm.org/docs/LangRef.html#llvm-lifetime-start-intrinsic"target="_blank" rel="noopener"><code>llvm.lifetime.start/end</code></a> and allowing stack slot reuse. Codegen detail? Yes. But a codegen detail delivered by today&rsquo;s toolchain counts. Zig itself still has an <a href="https://github.com/ziglang/zig/issues/23475"target="_blank" rel="noopener">open issue about stack allocations that do not disappear at the end of scope</a>. For the user, it matters little whether the advantage comes from the pure language, the compiler, or backend maturity. What matters is that it works today.</p>
<p>The mechanism described by Bun is this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Rust&#39;s LLVM IR codegen emits LLVM&#39;s llvm.lifetime.start and
</span></span><span class="line"><span class="cl">llvm.lifetime.end intrinsics for stack variables when they are no longer
</span></span><span class="line"><span class="cl">in use, which lets LLVM reuse stack space slots.</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the Zig ticket says, bluntly, that this case is still not solved:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Block stack allocations do not disappear at end of block scope #23475</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>There is also the architectural solution: iterative parser with an explicit stack on the heap. Works in Zig and Rust. But it is more work.</p>
<p>Honest reading: the numbers favor Bun&rsquo;s Rust version in that experiment. They do not prove general language superiority.</p>
<h2>Miri, crates, and the real world<span class="hx:absolute hx:-mt-20" id="miri-crates-and-the-real-world"></span>
    <a href="#miri-crates-and-the-real-world" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There are Rust advantages that have no direct equivalent in Zig today.</p>
<p>Crates is the obvious one. The Rust ecosystem is large, mature, and practical. For a project the size of Bun, a good off-the-shelf library is real engineering value. Zig is still maturing on this point.</p>
<p><a href="https://github.com/rust-lang/miri"target="_blank" rel="noopener"><code>Miri</code></a> is also a real advantage. It detects classes of undefined behavior in executed Rust code: out-of-bounds, use-after-free, uninitialized data, alignment problems, data races in certain cases. The project itself documents the limits: it is not a formal proof, it evaluates the executions you provide, and it has restricted support for FFI and platform APIs. It does not replace testing. But to validate isolated <code>unsafe</code> abstractions, it is a tool Zig does not have in the same way.</p>
<p>Bun does not merely cite Miri in the abstract. It is in the toolchain:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">toolchain</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">channel</span> <span class="p">=</span> <span class="s2">&#34;nightly-2026-05-06&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nx">components</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;rust-src&#34;</span><span class="p">,</span> <span class="s2">&#34;rustfmt&#34;</span><span class="p">,</span> <span class="s2">&#34;clippy&#34;</span><span class="p">,</span> <span class="s2">&#34;miri&#34;</span><span class="p">,</span> <span class="s2">&#34;llvm-tools&#34;</span><span class="p">]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/rust-toolchain.toml#L1-L24"target="_blank" rel="noopener"><code>rust-toolchain.toml</code></a>.</p>
<p>And there is an internal test explaining exactly the use:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-rust" data-lang="rust"><span class="line"><span class="cl"><span class="c1">// Run under Miri (`bun run rust:miri -p bun_ptr`): every path here walks raw
</span></span></span><span class="line"><span class="cl"><span class="c1">// pointers through `Box::into_raw` / `heap::take`, so Tree Borrows is what
</span></span></span><span class="line"><span class="cl"><span class="c1">// proves the ref/deref/destructor handoff does not alias or use-after-free.
</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://github.com/oven-sh/bun/blob/fc865b398e51de8a95ddde4bca36408b26d40884/src/ptr/ref_count.rs#L1313-L1315"target="_blank" rel="noopener"><code>src/ptr/ref_count.rs</code></a>. It does not prove the whole program. It exercises a dangerous abstraction under a model made to detect undefined behavior on those executed paths.</p>
<p>There is also hiring, tooling, documentation, IDE, review culture, CI, examples, people in the market. This is not a political detail. It is engineering. A language exists inside an ecosystem.</p>
<p>If Bun were a smaller project, with a smaller team, less pressure, and less Node-compatible surface area, maybe I would weigh this differently. But Bun became a runtime used by millions of monthly downloads. The margin for &ldquo;let&rsquo;s maintain perfect discipline&rdquo; shrinks.</p>
<h2>Async: Zig may have a better answer, but it is not Bun&rsquo;s present yet<span class="hx:absolute hx:-mt-20" id="async-zig-may-have-a-better-answer-but-it-is-not-buns-present-yet"></span>
    <a href="#async-zig-may-have-a-better-answer-but-it-is-not-buns-present-yet" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Zig removed the old async/await and is <a href="https://kristoff.it/blog/zig-new-async-io/"target="_blank" rel="noopener">redesigning I/O around <code>std.Io</code></a>, passed by the caller in a way similar to <code>Allocator</code>. The idea is good: avoid classic function coloring, make the function less coupled to blocking, threaded, or event-driven execution, and treat I/O as an explicit capability.</p>
<p>The proposed format is like this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-zig" data-lang="zig"><span class="line"><span class="cl"><span class="kr">const</span><span class="w"> </span><span class="n">std</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">@import</span><span class="p">(</span><span class="s">&#34;std&#34;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span><span class="w"> </span><span class="n">Io</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">std</span><span class="p">.</span><span class="n">Io</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">fn</span><span class="w"> </span><span class="nf">saveData</span><span class="p">(</span><span class="n">io</span><span class="p">:</span><span class="w"> </span><span class="n">Io</span><span class="p">,</span><span class="w"> </span><span class="n">data</span><span class="p">:</span><span class="w"> </span><span class="p">[]</span><span class="kr">const</span><span class="w"> </span><span class="kt">u8</span><span class="p">)</span><span class="w"> </span><span class="o">!</span><span class="kt">void</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kr">const</span><span class="w"> </span><span class="n">file</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">try</span><span class="w"> </span><span class="n">Io</span><span class="p">.</span><span class="n">Dir</span><span class="p">.</span><span class="nf">cwd</span><span class="p">().</span><span class="nf">createFile</span><span class="p">(</span><span class="n">io</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;save.txt&#34;</span><span class="p">,</span><span class="w"> </span><span class="p">.{});</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">defer</span><span class="w"> </span><span class="n">file</span><span class="p">.</span><span class="nf">close</span><span class="p">(</span><span class="n">io</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">try</span><span class="w"> </span><span class="n">file</span><span class="p">.</span><span class="nf">writeAll</span><span class="p">(</span><span class="n">io</span><span class="p">,</span><span class="w"> </span><span class="n">data</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kr">const</span><span class="w"> </span><span class="n">out</span><span class="p">:</span><span class="w"> </span><span class="n">Io</span><span class="p">.</span><span class="n">File</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">.</span><span class="nf">stdout</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">try</span><span class="w"> </span><span class="n">out</span><span class="p">.</span><span class="nf">writeAll</span><span class="p">(</span><span class="n">io</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;save complete&#34;</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://kristoff.it/blog/zig-new-async-io/"target="_blank" rel="noopener">Kristoff on Zig&rsquo;s new async I/O</a>. <code>io</code> is a capability passed explicitly, like <code>Allocator</code>.</p>
<p>I like this direction a lot.</p>
<p>But it is still in transition. The <a href="https://github.com/ziglang/zig/issues/23446"target="_blank" rel="noopener">tracking issue</a> remains open: there is a prototype, roadmap, discussion, parts landing gradually. It is not a stable foundation Bun could bet on today without cost.</p>
<p>So the argument here cannot be &ldquo;Zig already solved it&rdquo;. The correct formulation is: Zig is moving toward an async/I/O design that may be cleaner in some aspects than the traditional colored model. But Bun needed to make a decision with what exists now, not with what promises to exist later.</p>
<p>That is always cruel to young languages. But it is real.</p>
<h2>The rewrite process matters more than the language<span class="hx:absolute hx:-mt-20" id="the-rewrite-process-matters-more-than-the-language"></span>
    <a href="#the-rewrite-process-matters-more-than-the-language" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The best part of Jarred&rsquo;s post is not Rust. It is method.</p>
<p>The mental loop of the post is literally this:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="c1">// Pseudocode, not real code:
</span></span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">task</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="p">((</span><span class="nx">task</span> <span class="o">=</span> <span class="nx">todoList</span><span class="p">.</span><span class="nx">pop</span><span class="p">()))</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="nx">task</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">feedback</span> <span class="o">=</span> <span class="kr">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">all</span><span class="p">([</span><span class="nx">review</span><span class="p">(</span><span class="nx">result</span><span class="p">),</span> <span class="nx">review</span><span class="p">(</span><span class="nx">result</span><span class="p">)]);</span>
</span></span><span class="line"><span class="cl">  <span class="kr">await</span> <span class="nx">apply</span><span class="p">(</span><span class="nx">feedback</span><span class="p">,</span> <span class="nx">result</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Then compiler errors became a queue:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">~16,000 compiler errors left
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">error: deref *mut EventLoop before field access
</span></span><span class="line"><span class="cl">error[E0034]: multiple applicable items in scope
</span></span><span class="line"><span class="cl">error: event_loop/EventLoopTimer.rs: port Timespec::ns from bun.zig</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And the per-crate cycle was explicit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">- For each crate, run cargo check, group the output by file and save the errors to a file
</span></span><span class="line"><span class="cl">- Fix all the compiler errors within that crate
</span></span><span class="line"><span class="cl">- 2 adversarial reviewers for the crate&#39;s changes
</span></span><span class="line"><span class="cl">- 1 fixer applies the fixes</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Source: <a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener">Bun&rsquo;s official post</a>.</p>
<p>He did not tell Claude to rewrite an entire runtime and go to sleep. He built loops. Separated implementer from reviewer. Used different context for adversarial review. Did a trial run. Fixed the workflow when agents started tripping over each other. Banned dangerous commands. Split worktrees. Turned compiler errors into a queue. Ran the same TypeScript suite because the suite did not depend on the runtime language. Used multi-platform CI as judge.</p>
<p>That is the part everyone should study.</p>
<p>The model did not replace engineering. The model executed work inside an engineering process. When the process was bad, the result was bad. When the process improved, the result improved.</p>
<p>It is the detail the tribal discussion ignores. The story is not &ldquo;Claude rewrote Bun&rdquo;. The story is &ldquo;an engineer turned a giant migration into several verifiable loops and used Claude to accelerate each loop&rdquo;.</p>
<p>Much more interesting.</p>
<h2>So did Zig fail?<span class="hx:absolute hx:-mt-20" id="so-did-zig-fail"></span>
    <a href="#so-did-zig-fail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>No.</p>
<p>Zig did not fail because Bun chose Rust. Rust also did not become the universal answer because Bun chose Rust.</p>
<p>What the public evidence shows is more boring: that code, at that moment, was too expensive to evolve with the level of confidence the team wanted. Rust bought predictability. Bought compiler feedback. Bought ecosystem. Bought an explicit safe/unsafe boundary. Bought tooling. Bought an environment where AI agents can bang against compiler errors and move forward mechanically.</p>
<p>That is enough to justify the decision.</p>
<p>But it is not enough to prove Zig was technically unviable for Bun. Many cited problems also look like boundary problems: implicit ownership, overly permissive APIs, allocation without a visible contract, resources crossing layers without a type carrying the owner, tests that maybe did not capture difficult invariants, and the human cost of reviewing all of that.</p>
<p>Those things could have been attacked in Zig. Maybe an aggressive Zig restructuring would have solved a lot. Maybe not. Maybe it would cost more than the port. Maybe it would cost less, but deliver less confidence. We do not have the counterfactual.</p>
<p>And that is exactly why the conclusion needs to be calibrated.</p>
<p>I support Jarred&rsquo;s decision as a product and engineering decision. His post is one of the most useful reports I have ever seen about a large AI-assisted rewrite. But liking the decision does not force me to pretend it was the only possible technical conclusion.</p>
<p>And there is one important disclaimer. <a href="https://bun.com/blog/bun-in-rust"target="_blank" rel="noopener">Anthropic acquired Bun in December 2025</a>, and Jarred opens the post by saying that he and the Bun team now work there. Anthropic sells Claude Code. Jarred had every institutional incentive to use as much Claude as possible and turn this rewrite into a public demonstration of his new employer&rsquo;s product. That does not prove bad faith, nor does it invalidate a technical result. But it adds bias to the decision and to the way the case is presented. Ignoring that conflict of incentives would be as naive as ignoring Andrew&rsquo;s resentment over Bun leaving Zig.</p>
<p>The best reading is less dramatic: Bun chose to pay a large cost now to reduce recurring maintenance costs later. Rust was a defensible choice, maybe a great one for that team at that moment. Zig remained an excellent language, but would have required another kind of reengineering: more explicit invariants, less informal ownership, more tools enabled by default, harder boundaries.</p>
<p>Andrew could have written that. He could have defended Zig on technical ground. He could have said: &ldquo;Rust bought some real guarantees, but a large part of the problem was architecture and process; here is how Zig could have attacked the same causes.&rdquo; That would have been a useful post.</p>
<p>We do not need to declare a winner. Language does not replace architecture. It only changes which errors become cheap to detect and which still depend on human discipline.</p>
<p>Bun chose to trade part of that discipline for compiler. I think it is a good bet.</p>
<p>I do not think it buries Zig.</p>
]]></content:encoded><category>programming-languages</category><category>rust</category><category>zig</category></item><item><title>Playing with TUIs and LLMs - Ratatui+Bubbletea</title><link>https://akitaonrails.github.io/en/2026/06/08/playing-with-tuis-llms-ratatui-bubbletea/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/06/08/playing-with-tuis-llms-ratatui-bubbletea/</guid><pubDate>Mon, 08 Jun 2026 16:00:00 -0300</pubDate><description>&lt;p&gt;Before getting to the new toy, a quick reminder: &lt;a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener"&gt;ai-jail&lt;/a&gt; and &lt;a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener"&gt;ai-memory&lt;/a&gt; are still getting plenty of attention. Issues, pull requests, small fixes, packaging improvements, environment bugs, documentation tweaks. The stuff nobody sees in a launch post, but that turns an experiment into something you can use every day.&lt;/p&gt;
&lt;p&gt;I like this phase. The project stops being &amp;ldquo;what I imagined in my setup&amp;rdquo; and becomes &amp;ldquo;what breaks on somebody else&amp;rsquo;s machine.&amp;rdquo; Then Windows shows up. macOS. Arch. AUR. Weird paths. Tokens saved somewhere else. Different shell. Different terminal. Annoying? Yes. Necessary? Also yes.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Before getting to the new toy, a quick reminder: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a> and <a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a> are still getting plenty of attention. Issues, pull requests, small fixes, packaging improvements, environment bugs, documentation tweaks. The stuff nobody sees in a launch post, but that turns an experiment into something you can use every day.</p>
<p>I like this phase. The project stops being &ldquo;what I imagined in my setup&rdquo; and becomes &ldquo;what breaks on somebody else&rsquo;s machine.&rdquo; Then Windows shows up. macOS. Arch. AUR. Weird paths. Tokens saved somewhere else. Different shell. Different terminal. Annoying? Yes. Necessary? Also yes.</p>
<p>But today&rsquo;s main dish is not ai-jail or ai-memory. It&rsquo;s TUI. More specifically, my recent habit of writing small terminal tools in Rust with <a href="https://ratatui.rs/"target="_blank" rel="noopener">Ratatui</a>, while wanting them to look like <a href="https://github.com/charmbracelet/bubbletea"target="_blank" rel="noopener">Bubble Tea</a>.</p>
<h2>The app I won&rsquo;t open<span class="hx:absolute hx:-mt-20" id="the-app-i-wont-open"></span>
    <a href="#the-app-i-wont-open" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Over the last few weeks I also worked on a private project I do not plan to open source. It&rsquo;s a TUI to track my US bank account, investments, assets, positions, covered calls, open calls, all that boring grown-up stuff. I won&rsquo;t publish the code because this tool was born glued to my financial workflow, my spreadsheets, my statements, and my internal hacks. Opening it would turn a tool for myself into free support for other people&rsquo;s bank bugs. No thanks.</p>
<p>But I can show the general look. I censored what needed censoring:</p>
<p><strong>Obvious note, but I&rsquo;ll make it explicit: every value, ticker, position, and date shown in the screenshot is bogus demonstration data. It is not real account data, not my real portfolio, and not investment advice.</strong></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/private-portfolio-censored.png" alt="Private portfolio TUI in a terminal, with dashboard tabs and a holdings table. The bank name was censored."  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/private-allocation-censored.png" alt="Private TUI showing an asset allocation view, with sensitive names censored."  loading="lazy" /></p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/private-open-calls-censored.png" alt="Private TUI showing an open calls tab with bogus demonstration data and sensitive names censored."  loading="lazy" /></p>
<p>Every time I build a small TUI, the same question comes back: what stack should I use?</p>
<p>If I were writing everything by hand, I would probably pick Python or Go. They are more comfortable for this kind of small utility. Python because it is direct, full of libraries, great for scripts that talk to APIs and parse JSON. Go because it produces simple binaries, cross-platform support is sane, and the CLI ergonomics are good.</p>
<p>But today I rarely write all that code by hand. I orchestrate Claude, Codex, OpenCode, ask for reviews, ask for refactors, ask for tests. In that setup, Rust&rsquo;s lack of ergonomics hurts less, because the LLM is the one fighting the borrow checker, trait bounds, and annoying lifetimes. I stay closer to reviewer and end user. And then Rust starts looking very attractive.</p>
<p>Good tooling. <code>cargo</code> is great. Tests are easy to run. Cross compile is manageable. The final binary is clean. Dumb memory bugs mostly disappear. I am not saying Rust is magically better than Go or Python for every TUI. It isn&rsquo;t. But with an LLM writing most of it, the cost that would usually make me avoid Rust drops a lot. And current models are surprisingly competent with Rust, as long as you make them run tests, fix warnings, and you don&rsquo;t accept the first version as if Moses brought it down from the mountain.</p>
<h2>Ratatui is good, but I wanted the Charm look<span class="hx:absolute hx:-mt-20" id="ratatui-is-good-but-i-wanted-the-charm-look"></span>
    <a href="#ratatui-is-good-but-i-wanted-the-charm-look" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://ratatui.rs/"target="_blank" rel="noopener">Ratatui</a> is a Rust library for building full terminal interfaces: dashboards, interactive apps, tables, lists, gauges, layouts, the whole thing we used to call &ldquo;modern ncurses.&rdquo; It grew out of <code>tui-rs</code>, and today it is probably the obvious path if you want to build a serious TUI in Rust.</p>
<p>Ratatui works well. My problem is taste: by default, I don&rsquo;t find it pretty. Not ugly in the &ldquo;this looks like a DOS program from 1993&rdquo; sense. You can build good-looking stuff with it. But many Ratatui TUIs fall into that square, gray, functional look, like a datacenter admin panel.</p>
<p>I like the <a href="https://charm.sh/"target="_blank" rel="noopener">Charmbracelet</a> vibe more. <code>gum</code>, for example, is a collection of commands that make shell scripts interactive without turning them into a festival of <code>read -p</code> and <code>case</code>. Prompt, select, filter, spinner, table, pager, styling. You can make a bash script feel like a decent app in minutes.</p>
<p>And then there is <a href="https://github.com/charmbracelet/bubbletea"target="_blank" rel="noopener">Bubble Tea</a>, in Go, inspired by Elm architecture. You have a model, receive messages, update state, render a view. Simple idea, very good fit for terminal apps: state comes in, event comes in, view goes out. Charm also has visual taste I like: good colors, decent spacing, light components, nothing that looks like a WinForms form thrown into a terminal.</p>
<p>So I wanted this: stay in Rust with Ratatui, but steal the feeling of Bubble Tea. Not the whole runtime. Not a perfect clone. Just the visual direction and a few components.</p>
<p>That is how <a href="https://github.com/akitaonrails/ratatui-bubbletea"target="_blank" rel="noopener">ratatui-bubbletea</a> was born.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/ratatui-bubbletea-example.png" alt="ratatui-bubbletea demo running in a terminal, with SelectList, Spinner, Theme, ThemedTable, TextInput, Progress, and Viewport in a Charm-like style."  loading="lazy" /></p>
<p>The project does not replace Ratatui. It sits on top. The idea is to offer a theme and component layer inspired by Bubble Tea/Bubbles, while still rendering through Ratatui.</p>
<p>Right now it is split into crates:</p>
<ul>
<li><code>ratatui-bubbletea-theme</code>, with palette, helpers, and shared styles.</li>
<li><code>ratatui-bubbletea-components</code>, with ready-made components like select list, spinner, progress, table, text input, viewport, and help.</li>
<li><code>ratatui-tea</code>, optional, for people who want ergonomics closer to <code>Model</code>, <code>Msg</code>, and <code>Cmd</code>, without pretending Rust became Go.</li>
</ul>
<p>The README has the full examples and documentation for what exists today. For now, I treat it as a small toolkit. It serves my apps, and if it serves somebody else, fine. If it doesn&rsquo;t, no drama either. The goal was to stop reimplementing the same theme and the same widgets every time I started a new TUI.</p>
<h2>ai-usagebar as the public example<span class="hx:absolute hx:-mt-20" id="ai-usagebar-as-the-public-example"></span>
    <a href="#ai-usagebar-as-the-public-example" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since I won&rsquo;t open the financial app, I needed a public example using this aesthetic. So I updated my <a href="https://github.com/akitaonrails/ai-usagebar"target="_blank" rel="noopener">ai-usagebar</a>.</p>
<p>If you don&rsquo;t remember it, I explained the project in <a href="/en/2026/05/24/i-built-a-waybar-widget-for-omarchy-to-monitor-llm-usage-ai-usagebar/">&ldquo;I Built a Waybar Widget for Omarchy to Monitor LLM Plan Usage: ai-usagebar&rdquo;</a>. It started as a Waybar widget to show LLM plan usage: Claude, OpenAI Codex/ChatGPT, Z.AI, OpenRouter. One glance at the bar and I know whether I am close to the limit and which vendor to use next.</p>
<p>Later it also became a standalone TUI. And thanks to contributions, it now supports Windows too. That is funny because its origin is completely Linux/Omarchy/Waybar. But that is open source: somebody uses it in another environment, finds friction, sends a PR, and now the project is a little less provincial.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/ai-usagebar.png" alt="ai-usagebar in TUI mode showing an OpenAI usage dashboard, vendor tabs, limit gauges, and footer shortcuts."  loading="lazy" /></p>
<p>In ai-usagebar, the visual change will not alter anybody&rsquo;s life. It is a simple screen. Tabs, gauges, text, shortcuts. But it is a good example because it is small, real, tested, packaged, and does not depend on my private app. You can look at the code and see how I applied the theme layer.</p>
<h2>The scratch-my-own-itch apps<span class="hx:absolute hx:-mt-20" id="the-scratch-my-own-itch-apps"></span>
    <a href="#the-scratch-my-own-itch-apps" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>All these projects follow the same logic: I have an itch, I make a small tool, I use it, adjust it, package it. No startup roadmap. No manifesto. Just a tool that fixes something annoying in my own setup.</p>
<p>One recent example is <a href="https://github.com/akitaonrails/clock-tui"target="_blank" rel="noopener">clock-tui</a>. It did not start with me. It was an old Rust project, <code>tclock</code>, that looked abandoned. I wanted a big clock on my second vertical monitor. That monitor sits on the side, and I like having a huge desk clock taking up part of the screen. I used the original clock-tui for a while.</p>
<p>But an entire vertical screen just to show the time is wasteful. I already had <a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener">ghpending</a>, which lists open issues and PRs across all my projects. It is my &ldquo;is anybody waiting for an answer?&rdquo; in CLI form. I also wanted to see my agenda.</p>
<p>So I made another small app: <a href="https://github.com/akitaonrails/google-calendar-tui"target="_blank" rel="noopener">google-calendar-tui</a>. It uses the Gmail accounts already registered in GNOME Online Accounts and summarizes my upcoming appointments in the terminal. Read-only. No new OAuth ritual. No extra secret config scattered somewhere weird.</p>
<p>With those two pieces, I modernized clock-tui and added auto-refreshing widget layout support. Now the clock takes the screen the way I wanted, but around it I also get GitHub pending work and Google Calendar appointments.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/tclock.png" alt="tclock on a vertical monitor, with a large clock, GitHub pending widget on the left, and Google Calendar agenda on the right."  loading="lazy" /></p>
<p>This is exactly the kind of thing I would never build if it cost me an entire weekend. But with an LLM, it becomes a sequence of small tasks: read the old project, modernize dependencies, run tests, add a widget module, call ghpending, call google-calendar-tui, organize layout, fix bugs, package. It still takes work. But it is supervision and refinement work, not fighting boilerplate.</p>
<h2>Tweaking Geary without a fork<span class="hx:absolute hx:-mt-20" id="tweaking-geary-without-a-fork"></span>
    <a href="#tweaking-geary-without-a-fork" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Another itch came from GNOME.</p>
<p>Since I wanted to centralize my accounts in GNOME Online Accounts, I started using <a href="https://wiki.gnome.org/Apps/Geary"target="_blank" rel="noopener">Geary</a> and GNOME Calendar on the second monitor too. I like the idea: accounts configured once, desktop apps reusing the same infrastructure, no tiny program inventing its own login flow.</p>
<p>The problem is that Geary has a fixed three-column layout. On a normal screen, fine. On a vertical monitor, it gets cramped. The folder column, message list, and email body fight for space. I did not want to maintain a Geary fork just to hide a sidebar. Forking a large GTK app is debt. You blink and spend the rest of your life resolving merge conflicts.</p>
<p>So I made a GTK module: <a href="https://github.com/akitaonrails/geary-hide-sidebar-module"target="_blank" rel="noopener">geary-hide-sidebar-module</a>. It injects behavior into Geary at runtime and lets me collapse/expand the sidebar, with a shortcut and auto-collapse for narrow windows. No rebuilding Geary. No fork to maintain. Just a small module with one very specific job.</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/06/08/ratatui-bubbletea/geary-sidebar-hidden.png" alt="Geary with the sidebar collapsed, showing the email list and message body in a more comfortable layout for a vertical screen."  loading="lazy" /></p>
<p>This is a good lesson: sometimes you do not need to fork the whole application. You can adjust it from the outside. GTK_MODULES exists. Wrappers exist. Config exists. CSS exists. Scripts exist. Not every customization has to become eternal maintenance of somebody else&rsquo;s code.</p>
<h2>Small code got cheap<span class="hx:absolute hx:-mt-20" id="small-code-got-cheap"></span>
    <a href="#small-code-got-cheap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>That is why I keep insisting on this idea of small apps. With current LLMs, it became very easy to implement a specific idea to make your environment fit your needs. No waiting for someone else to have the exact same pain. No searching GitHub for three hours for a tool that does 80% of what you want and 40% of what you don&rsquo;t want.</p>
<p>You describe the itch, create a repo, ask for a prototype, test it, ask for refactoring, ask for tests, ask for packaging. If it sucks, throw it away. If it works, install it and use it.</p>
<p>I do not let this turn into random junk dumped in <code>~/bin</code> with no owner. My apps have tests. They have automated builds. Many have GitHub releases, AUR packages, normal installation with <code>yay</code> on Arch. They use XDG paths, so config goes to <code>~/.config</code>, cache goes to <code>~/.cache</code>, data goes to <code>~/.local/share</code>. Organized. Upgradable. Removable. Something another person can clone and contribute to.</p>
<p>Here are all the links in one place:</p>
<ul>
<li><a href="https://github.com/akitaonrails/ratatui-bubbletea"target="_blank" rel="noopener">ratatui-bubbletea</a></li>
<li><a href="https://github.com/akitaonrails/ai-usagebar"target="_blank" rel="noopener">ai-usagebar</a></li>
<li><a href="https://github.com/akitaonrails/clock-tui"target="_blank" rel="noopener">clock-tui</a></li>
<li><a href="https://github.com/akitaonrails/ghpending"target="_blank" rel="noopener">ghpending</a></li>
<li><a href="https://github.com/akitaonrails/google-calendar-tui"target="_blank" rel="noopener">google-calendar-tui</a></li>
<li><a href="https://github.com/akitaonrails/geary-hide-sidebar-module"target="_blank" rel="noopener">geary-hide-sidebar-module</a></li>
<li><a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a></li>
<li><a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a></li>
</ul>
<p>If you use any of them and it breaks in your setup, open an issue. If you know how to fix it, send a PR. I actually use these tools, so useful bug reports tend to turn into fixes fast.</p>
<p>My practical rule is this: customize your environment without turning everything into duct tape. Build small tools. Test them. Package them. Document them. Let the LLM carry the boring weight and use your brain to decide what is worth keeping.</p>
<p>That is the fun part of programming that had gone missing for a bit. Now it is back.</p>
]]></content:encoded><category>ai-usagebar</category><category>developer-tools</category><category>rust</category><category>open-source</category></item><item><title>I Built a Memory System for Coding Agents: ai-memory</title><link>https://akitaonrails.github.io/en/2026/05/23/i-built-memory-system-for-coding-agents-ai-memory/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/05/23/i-built-memory-system-for-coding-agents-ai-memory/</guid><pubDate>Sat, 23 May 2026 16:30:00 -0300</pubDate><description>&lt;p&gt;&lt;img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/23/ai-memory/ai-memory-logo.png" alt="ai-memory project logo: circular pictogram with a stylized chip opening “wings” like books/pages on each side, next to the ai-memory wordmark in italics." loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;Five days ago I &lt;a href="https://akitaonrails.github.io/en/2026/05/18/ai-agent-memory-karpathy-llm-wiki-agentmemory/"&gt;wrote a long post on coding-agent memory&lt;/a&gt; where I recommended &lt;a href="https://github.com/rohitg00/agentmemory"target="_blank" rel="noopener"&gt;agentmemory&lt;/a&gt; as the answer. After a week running it in personal production, I&amp;rsquo;m walking it back. This post explains what went wrong and the open source project I started building to fix it: &lt;a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener"&gt;ai-memory&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Why agentmemory didn&amp;rsquo;t pan out&lt;span class="hx:absolute hx:-mt-20" id="why-agentmemory-didnt-pan-out"&gt;&lt;/span&gt;
&lt;a href="#why-agentmemory-didnt-pan-out" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;agentmemory has the right ideas (Karpathy&amp;rsquo;s LLM Wiki, tiered consolidation, automatic-capture hooks, MCP) but the implementation has structural problems that hurt in daily use. The main ones I hit in practice:&lt;/p&gt;</description><content:encoded><![CDATA[<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/23/ai-memory/ai-memory-logo.png" alt="ai-memory project logo: circular pictogram with a stylized chip opening “wings” like books/pages on each side, next to the ai-memory wordmark in italics."  loading="lazy" /></p>
<p>Five days ago I <a href="/en/2026/05/18/ai-agent-memory-karpathy-llm-wiki-agentmemory/">wrote a long post on coding-agent memory</a> where I recommended <a href="https://github.com/rohitg00/agentmemory"target="_blank" rel="noopener">agentmemory</a> as the answer. After a week running it in personal production, I&rsquo;m walking it back. This post explains what went wrong and the open source project I started building to fix it: <a href="https://github.com/akitaonrails/ai-memory"target="_blank" rel="noopener">ai-memory</a>.</p>
<h2>Why agentmemory didn&rsquo;t pan out<span class="hx:absolute hx:-mt-20" id="why-agentmemory-didnt-pan-out"></span>
    <a href="#why-agentmemory-didnt-pan-out" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>agentmemory has the right ideas (Karpathy&rsquo;s LLM Wiki, tiered consolidation, automatic-capture hooks, MCP) but the implementation has structural problems that hurt in daily use. The main ones I hit in practice:</p>
<ul>
<li><strong>BM25 reindex on every restart.</strong> The index persists to a file (<code>mem:index:bm25.bin</code>) through a remote KV called iii-engine. With few observations it flies, but pass 10K observations and <code>state::set</code> starts timing out, the file stays at ~96 empty bytes, and every restart pays ~5 minutes rebuilding from scratch (<a href="https://github.com/rohitg00/agentmemory/issues/309"target="_blank" rel="noopener">issue #309</a>, still open).</li>
<li><strong>5-second data-loss window.</strong> All persistence flows through a 5s debounce on <code>IndexPersistence</code>. When the 30s <code>state::set</code> timeout blows up, the Node process dies taking everything that was in memory with it (<a href="https://github.com/rohitg00/agentmemory/issues/204"target="_blank" rel="noopener">#204</a>).</li>
<li><strong>Two config-read paths in the same codebase.</strong> <code>process.env</code> directly in one place, <code>getMergedEnv()</code> in another. The classic &ldquo;I set the variable and nothing changed&rdquo; (<a href="https://github.com/rohitg00/agentmemory/issues/456"target="_blank" rel="noopener">#456</a>).</li>
<li><strong>Wrong hook key on ~47% of Claude Code tool calls.</strong> The hook reads <code>data.tool_output</code>, but Claude Code sends <code>tool_response</code>. Six weeks of observations silently vanishing (<a href="https://github.com/rohitg00/agentmemory/issues/539"target="_blank" rel="noopener">#539</a>).</li>
<li><strong>Engine runs from the caller&rsquo;s working directory.</strong> Windows users thought they&rsquo;d lost memories because every terminal opened the state store in a different path (<a href="https://github.com/rohitg00/agentmemory/issues/303"target="_blank" rel="noopener">#303</a>, still open).</li>
</ul>
<p>There&rsquo;s more. I wrote it all up <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/issues-agentmemory.md"target="_blank" rel="noopener">here</a> in my research repo. The author is responsive, the repo has 15.7K stars, and even so the architecture chosen (TypeScript MCP + separate iii-engine Rust binary + 4 ports, 3 processes, in-memory indexes persisted via remote KV) accumulates a class of bug you can&rsquo;t close without rewriting half the system. The shape of the problem is structural, not a lack of effort from the maintainer.</p>
<p>Same story with every other solution I tried: either it&rsquo;s incomplete, or too fixated on some specific technology (proprietary vector DB, a knowledge graph that needs Neo4j, an elaborate LLM pipeline), or it demands that the user yell <code>write_note</code> every time something&rsquo;s worth saving. None of them &ldquo;stay out of the way&rdquo; enough for me to trust them in long sessions.</p>
<h2>What I wanted from day one<span class="hx:absolute hx:-mt-20" id="what-i-wanted-from-day-one"></span>
    <a href="#what-i-wanted-from-day-one" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When I started my <a href="/en/2026/05/14/wrapping-up-my-ai-marathon-success-or-failure/">37-day vibe coding marathon</a> back in February, I knew I needed some kind of knowledge organizer. It took more than 500 hours of experimentation to crystallize what I actually wanted.</p>
<p>If you use just one of the three (only Claude Code, or only Codex, or only opencode) and stay there, you probably don&rsquo;t need anything extra. The three of them already do a reasonable job of managing memory within the session. Claude Code has three levels of compaction (time-gap microcompact, token-threshold autocompact, experimental sessionMemoryCompact). Codex has per-model auto_compact_token_limit. opencode has anchored compaction with a 20K buffer. I covered this in detail <a href="/en/2026/05/18/ai-agent-memory-karpathy-llm-wiki-agentmemory/">in the previous post</a>.</p>
<p>The one extra discipline worth adopting in that scenario is to keep a <code>docs/</code> folder in the project with markdown files organized by topic, and ask the agent to save there when something important happens. Research you don&rsquo;t want to redo, known gotchas, why you picked option A over B. Works. Takes discipline.</p>
<p>The problem shows up for people like me, jumping between agents in the same project. I use Claude Code by default (I find the harness, the subagent orchestrator, and the long-task planning superior). But when Claude gets stuck, I fire up Codex in the same directory just to &ldquo;stir things up.&rdquo; Codex thinks more &ldquo;outside the box&rdquo; and sometimes finds a solution Claude didn&rsquo;t see. Then I go back to Claude to implement carefully.</p>
<p>This back-and-forth between agents is where external memory really matters. Without it:</p>
<ul>
<li>You close Claude Code, open Codex in the same directory, and Codex knows nothing about the last 4 hours of discussion.</li>
<li>You have to manually ask Claude to write a handoff note to <code>./HANDOFF.md</code>, then ask Codex to read it. Every time.</li>
<li>Projects you left untouched for weeks: you open them again, forgot why you decided against Redis, forgot what the bug in library X was.</li>
</ul>
<p>Karpathy nailed the concept in his <a href="https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f"target="_blank" rel="noopener">LLM Wiki</a>, which I went deep on in the previous post. But there&rsquo;s one specific line from the gist that stuck in my head:</p>
<blockquote>
  <p><code>index.md</code> is &ldquo;surprisingly good at ~100 sources, a few hundred pages, no embeddings needed&rdquo;.</p>

</blockquote>
<p>That line summed up what I wanted. <strong>You don&rsquo;t need complicated technology.</strong> Simple storage (markdown in git), simple index (SQLite with FTS5 and links), simple install (a binary or container), automatic use (hooks that fire on their own), zero maintenance (memory decays without the user touching anything).</p>
<p>That&rsquo;s why I started ai-memory.</p>
<h2>Research before code<span class="hx:absolute hx:-mt-20" id="research-before-code"></span>
    <a href="#research-before-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Instead of just diving into code, we dug through the source and issues of the relevant projects to understand what each one got right and wrong. The research lives in the <a href="https://github.com/akitaonrails/ai-memory/tree/main/docs"target="_blank" rel="noopener">repo itself</a>:</p>
<ul>
<li><strong><a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-karpathy-llm-wiki.md"target="_blank" rel="noopener">research-karpathy-llm-wiki.md</a></strong> — what Karpathy literally wrote, separated from what the community attributes (paraphrasing) to him. Three core principles: compile instead of retrieve, three layers (raw / wiki / schema), three operations (ingest / query / lint). The conceptual blueprint.</li>
<li><strong><a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-agentmemory.md"target="_blank" rel="noopener">research-agentmemory.md</a></strong> — technical breakdown of agentmemory: four consolidation tiers (working → episodic → semantic → procedural), supersession with <code>is_latest=false</code> instead of delete, exponential decay, cluster reflection. I kept agentmemory&rsquo;s ideas; I changed the implementation.</li>
<li><strong><a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-basic-memory.md"target="_blank" rel="noopener">research-basic-memory.md</a></strong> — Python, MCP-native, plain markdown on disk. The model is explicitly manual: the agent calls <code>write_note</code> when it decides to save. Good storage model, terrible capture model (it depends on the agent remembering to call).</li>
<li><strong><a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/research-cognee.md"target="_blank" rel="noopener">research-cognee.md</a></strong> — cognify pipeline (extract → chunk → graph + summarize → embed → persist) with a triple knowledge graph + vector + relational store. Excellent for a well-funded enterprise agent that wants semantic relationships between entities. Way too heavy for &ldquo;coding session memory.&rdquo;</li>
</ul>
<p>The synthesis I took away: <strong>agentmemory has the right ideas. The rest is execution.</strong></p>
<p>The idea I like most from agentmemory, which it inherited partially from LLM Wiki v2, is the <strong>sleep-style consolidation</strong>: short memory keeps raw detail, long memory keeps consolidating and summarizing, and long-term memory that nobody touches eventually gets forgotten. It doesn&rsquo;t pile up cruft. There&rsquo;s <code>accessCount</code>, geometric exponential decay, spaced-repetition reinforcement. It&rsquo;s similar to (and inspired by) what a human does while sleeping.</p>
<p>In ai-memory I kept that structure but swapped the engineering underneath:</p>
<ul>
<li><strong>TypeScript → single Rust binary.</strong> No separate remote KV. No 3 processes. One Rust binary, with a data directory and an Axum server.</li>
<li><strong>Persistent index in SQLite + FTS5.</strong> Native. No reindex on every restart. WAL mode, 5s busy_timeout.</li>
<li><strong>Single writer in a dedicated thread via mpsc.</strong> No 5s loss window, no 30s timeout blowing up.</li>
<li><strong>Plain markdown on disk as source of truth.</strong> SQLite is just a derived index. You can open the wiki in Obsidian. You can <code>grep</code> it. You can version it with git (and it versions automatically).</li>
<li><strong>Per-project isolation in the physical layout on disk.</strong> <code>&lt;wiki_root&gt;/&lt;workspace_id&gt;/&lt;project_id&gt;/...</code> keyed by stable UUIDs. Renaming a project is a column update. Deleting a project is <code>rm -rf</code> on a subdir. Same page name in two projects doesn&rsquo;t collide.</li>
<li><strong>Embedding and LLM are optional.</strong> Without a provider key, the system still works with FTS5, link expansion, and raw-observation fallback. Add embeddings when you want vector re-ranking, and Claude Haiku or GPT-5.4-mini when you want quality consolidated pages.</li>
</ul>
<h2>How it works day to day<span class="hx:absolute hx:-mt-20" id="how-it-works-day-to-day"></span>
    <a href="#how-it-works-day-to-day" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Interaction with the agents happens through two mechanisms: <strong>hooks/extensions</strong> (automatic capture) and <strong>MCP</strong> (on-demand query).</p>
<p>Claude Code and Codex use shell hooks. OpenCode uses a TypeScript plugin. OMP/Pi uses a TypeScript extension. Cursor and Gemini CLI use JSON lifecycle config. They all emit equivalent events (<code>SessionStart</code>, <code>UserPromptSubmit</code>, <code>PreToolUse</code>, <code>PostToolUse</code>, <code>PreCompact</code>, <code>Stop</code>, <code>SessionEnd</code>) as fire-and-forget POSTs to ai-memory&rsquo;s HTTP server (running at <code>127.0.0.1:49374</code> by default). The agent never waits. If ai-memory is down, the session continues normally, just without capture. Claude Desktop and OpenClaw are MCP-only: they can query memory, but they don&rsquo;t automatically capture lifecycle events.</p>
<p>On the agent side, the MCP server exposes tools the LLM can call when it sees fit:</p>
<table>
  <thead>
      <tr>
          <th>You say…</th>
          <th>Agent calls…</th>
          <th>What happens</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>&ldquo;Have we talked about X?&rdquo;</td>
          <td><code>memory_query</code></td>
          <td>FTS5 + link/graph-neighbour RRF over compiled wiki pages, optional vector re-ranking, and raw-observation fallback when wiki search misses</td>
      </tr>
      <tr>
          <td>(before proposing architecture) implicit</td>
          <td><code>memory_query</code></td>
          <td>The routing snippet tells the agent to check past decisions before proposing something new</td>
      </tr>
      <tr>
          <td>&ldquo;Catch me up&rdquo; / &ldquo;I&rsquo;ve been away&rdquo;</td>
          <td><code>memory_explore</code></td>
          <td>Prose digest. Verbosity scales with time since last activity</td>
      </tr>
      <tr>
          <td>&ldquo;Where did we leave off?&rdquo;</td>
          <td>(handoff already in context)</td>
          <td>The <code>SessionStart</code> hook already prepended the handoff before your first prompt</td>
      </tr>
      <tr>
          <td>&ldquo;Save context for next session&rdquo;</td>
          <td><code>memory_handoff_begin</code></td>
          <td>Writes a terse handoff with <code>open_questions</code> + <code>next_steps</code></td>
      </tr>
      <tr>
          <td>&ldquo;Consolidate this session&rdquo;</td>
          <td><code>memory_consolidate</code></td>
          <td>Manual trigger of what SessionEnd does automatically</td>
      </tr>
      <tr>
          <td>&ldquo;Audit the wiki&rdquo;</td>
          <td><code>memory_lint</code></td>
          <td>Detects stale pages, contradictions, suggests rules</td>
      </tr>
      <tr>
          <td>&ldquo;Show me stats&rdquo;</td>
          <td><code>memory_status</code> / <code>memory_briefing</code></td>
          <td>Counts pages, sessions, observations, and recent activity</td>
      </tr>
  </tbody>
</table>
<p>The routing snippet that lands in <code>CLAUDE.md</code> (or <code>AGENTS.md</code> for Codex/OpenCode/Cursor/Gemini/OMP) is what teaches the agent when to call which tool. You install it with one command: ask the agent &ldquo;install the ai-memory routing in this project&rdquo; and it invokes the MCP tool <code>memory_install_self_routing</code>, gets back the canonical snippet, and writes it to the right file. Idempotent.</p>
<h2>Handoff between agents in action<span class="hx:absolute hx:-mt-20" id="handoff-between-agents-in-action"></span>
    <a href="#handoff-between-agents-in-action" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Here&rsquo;s the concrete scenario that motivated the project:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>$ claude
&gt; &#34;Refactoring auth. JWT rotation is broken; I&#39;m going to try
   session cookies as an alternative.&#34;
[works for an hour]
&gt; /exit

$ codex   # same directory, hours or days later
[ai-memory&#39;s SessionStart hook fetches the handoff and prepends it to context]
&gt; &#34;Picking up: you were investigating session cookies as an
   alternative to broken JWT rotation. Continue?&#34;</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>You did nothing special. At Claude Code&rsquo;s <code>SessionEnd</code>, ai-memory consolidated the session&rsquo;s observations into a <code>sessions/&lt;uuid&gt;.md</code> page, created a typed handoff with <code>open_questions</code> + <code>next_steps</code>, and marked it &ldquo;pending.&rdquo; At Codex&rsquo;s <code>SessionStart</code>, the server saw the pending handoff, fetched it, and injected it into the initial context. The agent reads and continues.</p>
<p>Same scenario if you abandon the project for two months and come back. <code>memory_explore</code> auto-tunes verbosity: less than an hour since the last session, it returns one line. More than 30 days, it returns a full catch-up of what happened.</p>
<h2>Adopting ai-memory on an existing project<span class="hx:absolute hx:-mt-20" id="adopting-ai-memory-on-an-existing-project"></span>
    <a href="#adopting-ai-memory-on-an-existing-project" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you install ai-memory on a project with months of history, the wiki starts empty and the first sessions break even — you&rsquo;re populating, not retrieving. <code>ai-memory bootstrap</code> solves this. It collects your <code>git log</code>, README, <code>docs/</code>, module headers, and project-rules files (<code>CLAUDE.md</code> / <code>AGENTS.md</code>), then sends them to the server which uses the LLM to generate seed wiki pages:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-project
</span></span><span class="line"><span class="cl">ai-memory bootstrap</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Costs about $0.05 with Claude Haiku 4.5 (budget capped at 50K input tokens). Run with <code>--dry-run</code> first to see what would be sent without paying. From there the wiki starts with real context, and every session builds on top.</p>
<h2>Install: three commands<span class="hx:absolute hx:-mt-20" id="install-three-commands"></span>
    <a href="#install-three-commands" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I reduced the install to the bare essentials. The recommended path is Docker, because the server stays isolated and updates are one line. Nothing else needed:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Wrapper script (~3KB) that runs the binary inside the container</span>
</span></span><span class="line"><span class="cl">mkdir -p ~/.local/bin
</span></span><span class="line"><span class="cl">curl -fsSL https://raw.githubusercontent.com/akitaonrails/ai-memory/main/bin/ai-memory <span class="se">\
</span></span></span><span class="line"><span class="cl">    -o ~/.local/bin/ai-memory
</span></span><span class="line"><span class="cl">chmod +x ~/.local/bin/ai-memory
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Start the server (loopback-only, no auth — single-user laptop)</span>
</span></span><span class="line"><span class="cl">docker run -d --name ai-memory <span class="se">\
</span></span></span><span class="line"><span class="cl">    --restart unless-stopped <span class="se">\
</span></span></span><span class="line"><span class="cl">    -p 127.0.0.1:49374:49374 <span class="se">\
</span></span></span><span class="line"><span class="cl">    -v ai-memory-data:/data <span class="se">\
</span></span></span><span class="line"><span class="cl">    -e <span class="nv">AI_MEMORY_LLM_PROVIDER</span><span class="o">=</span>anthropic <span class="se">\
</span></span></span><span class="line"><span class="cl">    -e <span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>sk-ant-... <span class="se">\
</span></span></span><span class="line"><span class="cl">    akitaonrails/ai-memory:latest
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Wire up your agent</span>
</span></span><span class="line"><span class="cl">ai-memory install-mcp   --client claude-code --apply
</span></span><span class="line"><span class="cl">ai-memory install-hooks --agent  claude-code --apply</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Done. Open Claude Code as usual. Every prompt and tool call now lands in ai-memory, and the next session you open in this project will see a handoff with where you left off.</p>
<p>Repeat step 3 with <code>--agent codex</code>, <code>--agent opencode</code>, <code>--agent omp</code>/<code>pi</code>, <code>--client cursor</code>, <code>--client gemini-cli</code>, <code>--client pi</code>/<code>omp</code>, etc. for other agents. Claude Desktop and OpenClaw are MCP-only. Idempotent: re-running replaces only the ai-memory entry, preserving any other hooks/MCP servers you have. It writes a <code>.bak-&lt;timestamp&gt;</code> backup before every modifying write.</p>
<p>The project README covers the variations (<a href="https://github.com/akitaonrails/ai-memory#quick-start"target="_blank" rel="noopener">Docker compose</a>, homelab on the LAN with a bearer token, <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/install.md"target="_blank" rel="noopener">no-Docker install</a>, source build). Follow <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/install.md"target="_blank" rel="noopener">docs/install.md</a> if your setup wanders off the happy path.</p>
<h2>Useful commands worth knowing<span class="hx:absolute hx:-mt-20" id="useful-commands-worth-knowing"></span>
    <a href="#useful-commands-worth-knowing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Once installed, the day-to-day is just using the agents. But it&rsquo;s worth knowing a handful of commands for when you need them:</p>
<ul>
<li><strong><code>ai-memory upgrade</code></strong> — self-upgrades the wrapper + <code>docker pull</code>s the new image + re-stages hook scripts/plugins/extensions. Idempotent. If you have a separate remote server, this only refreshes the local client; you update the server separately.</li>
<li><strong><code>ai-memory bootstrap</code></strong> — seeds the wiki from git log + README + docs/ + project rules. For projects that existed before ai-memory came along.</li>
<li><strong><code>ai-memory purge-project --project &lt;name&gt; --confirm</code></strong> — wipes an entire project. Atomic: SQLite rows, the wiki subdir, neighbors untouched.</li>
<li><strong><code>ai-memory rename-project --from &lt;name&gt; --to &lt;new&gt;</code></strong> — renames. One column in the DB, no file moves on disk. No side effects.</li>
<li><strong><code>ai-memory backup</code></strong> — snapshot of the data directory (wiki + SQLite). <code>ai-memory restore</code> undoes it.</li>
<li><strong><code>ai-memory lint</code> / <code>ai-memory forget-sweep</code> / <code>ai-memory embed</code></strong> — manual maintenance. Lint and forget-sweep also run on the server schedule by default; embedding backfill exists but stays off by default because it may call a paid provider.</li>
<li><strong><code>ai-memory generate-auth-token</code></strong> — when you&rsquo;re about to expose the server beyond loopback (see <code>docs/deploy.md</code>).</li>
</ul>
<h2>Spelunking the wiki in the browser<span class="hx:absolute hx:-mt-20" id="spelunking-the-wiki-in-the-browser"></span>
    <a href="#spelunking-the-wiki-in-the-browser" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The server can also mount a read-only web UI at <code>http://127.0.0.1:49374/web</code> when started with <code>--enable-web</code>. Same binary, same port, no extra process. It&rsquo;s handy for three practical things: auditing what the agent&rsquo;s been saving, checking whether the consolidated pages still look coherent, and sharing a single page with a teammate without giving them access to the agent.</p>
<p>The homepage lists detected projects, each with a page count and time since last activity:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/23/ai-memory/ai-memory-web-projects.png" alt="ai-memory /web homepage: project cards (llm-coding-benchmark, nes-to-sms, ai-jail, .config, ai-memory, distrobox-gaming, scratch) each with page counts and “just now”, orange banner warning that this is LLM-optimised memory, not human-curated documentation."  loading="lazy" /></p>
<p>Notice the orange warning at the top: the wiki is optimised for LLM consumption, with pages compiled from session observations by the LLM consolidator. For human-curated canonical documentation, use the project&rsquo;s own <code>docs/</code>. The <code>/web</code> UI is for spelunking.</p>
<p>Each project is isolated by UUID in the physical layout on disk. Two pages with the same path in different projects don&rsquo;t collide. Click a card and you land in the project&rsquo;s page tree:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/23/ai-memory/ai-memory-web-project-view.png" alt="ai-memory project view: left sidebar with the tree grouped by kind (concepts/, decisions/, gotchas/), right column lists recent activity with kind badges (fact, decision) and relative timestamps."  loading="lazy" /></p>
<p>The sidebar groups the tree by kind: <code>concepts/</code> (durable knowledge), <code>decisions/</code> (architectural calls), <code>gotchas/</code> (footguns), <code>_rules/</code> (project rules suggested for promotion into <code>CLAUDE.md</code>), <code>sessions/</code> (per-session logs), and <code>bootstrap.md</code> (the manifest generated by <code>ai-memory bootstrap</code>). The right column shows recent activity.</p>
<p>Click any page and you land in rendered markdown:</p>
<p><img src="https://new-uploads-akitaonrails.s3.us-east-2.amazonaws.com/2026/05/23/ai-memory/ai-memory-web-page.png" alt="Individual page “Per-project UUID-namespaced disk layout” rendered: “semantic” and “fact” badges at the top, “updated just now” and “created 1 hour ago” timestamps, markdown body with an ASCII code block showing the directory tree."  loading="lazy" /></p>
<p>The rendering has syntax highlighting, tier badges (<code>working</code>/<code>episodic</code>/<code>semantic</code>/<code>procedural</code>) and kind badges (<code>decision</code>/<code>gotcha</code>/<code>rule</code>/<code>fact</code>), breadcrumbs, the supersession chain visible. Search combines FTS5, link expansion, and embeddings when configured; light/dark mode follows the OS preference via <code>prefers-color-scheme</code>.</p>
<p>To edit the wiki you open the files directly in any markdown editor (Obsidian works great). The web UI is read-only by design: the LLM consolidator is the one writing, humans just read.</p>
<h2>Why Docker<span class="hx:absolute hx:-mt-20" id="why-docker"></span>
    <a href="#why-docker" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Picking Docker as the recommended path was deliberate. A few practical wins:</p>
<ul>
<li><strong>Updates are one line.</strong> <code>ai-memory upgrade</code> pulls the new image, refreshes the wrapper, re-stages hooks/plugins/extensions. You don&rsquo;t compile Rust, don&rsquo;t manage libsqlite versions, don&rsquo;t fight with mise/Node version churn the way Node-based solutions force you to.</li>
<li><strong>Real isolation.</strong> The server runs in the container, reads only <code>/data</code> (named volume), doesn&rsquo;t pollute your system. To clean up, it&rsquo;s <code>docker volume rm</code>.</li>
<li><strong>Same image across setups.</strong> Laptop, desktop, homelab. Same amd64 or arm64 image.</li>
<li><strong>Auto restart.</strong> <code>--restart unless-stopped</code> brings the server back when you restart Docker, or when the machine boots (assuming <code>systemctl enable docker</code>).</li>
<li><strong>No source build needed.</strong> Compiling the Rust 1.95+ toolchain takes about 3 minutes in release. For end users, that&rsquo;s unnecessary ceremony. Docker abstracts it away.</li>
</ul>
<p>There&rsquo;s a no-Docker path in docs/install.md for anyone who prefers it. But Docker is where the UX is best.</p>
<h2>Which LLM for consolidation<span class="hx:absolute hx:-mt-20" id="which-llm-for-consolidation"></span>
    <a href="#which-llm-for-consolidation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>This is a case where <strong>you don&rsquo;t need a heavy model</strong>. Consolidation is text in, text out: the agent records raw observations during the session, on <code>SessionEnd</code> the LLM compiles 1-5 wiki pages classified as <code>concept</code> / <code>decision</code> / <code>gotcha</code> / <code>rule</code>. No complex reasoning, no code. Opus 4.7 or GPT-5.5 thinking are overkill — expensive and slow for a task that accepts a light model.</p>
<p>To pick the default we ran serious A/B testing across 6 different providers against 5 synthetic fixtures, each designed to stress a specific failure mode: multi-page extraction, restraint (resisting the urge to fabricate a page when the session is trivial), correct classification of <code>rule</code> vs <code>gotcha</code> vs <code>decision</code>, and topic separation. Full writeup with parse-rate, latency, and quality tables per fixture is at <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/llm-provider-comparison.md"target="_blank" rel="noopener"><code>docs/llm-provider-comparison.md</code></a>. The summary table:</p>
<table>
  <thead>
      <tr>
          <th>Model</th>
          <th>Cost</th>
          <th>Latency</th>
          <th>Verdict</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Claude Haiku 4.5</strong></td>
          <td>~$0.02/run</td>
          <td>7s</td>
          <td><strong>The default I picked.</strong> Best classification and restraint, no hallucination.</td>
      </tr>
      <tr>
          <td>GPT-5.4-mini (OpenAI)</td>
          <td>~$0.005/run</td>
          <td>4s</td>
          <td>Cheaper, faster alternative. Mild over-classification on trivial sessions.</td>
      </tr>
      <tr>
          <td>qwen3:32b (Ollama local)</td>
          <td>$0</td>
          <td>92s</td>
          <td>Free if you have a local server. Latency invisible in the background.</td>
      </tr>
      <tr>
          <td>DeepSeek V4 Flash</td>
          <td>~$0.005/run</td>
          <td>22s</td>
          <td>Works, but no edge over GPT-mini or Haiku.</td>
      </tr>
      <tr>
          <td>Sonnet 4.5</td>
          <td>~$0.06/run</td>
          <td>11s</td>
          <td>Displaced by Haiku. 3× the cost, more hallucination.</td>
      </tr>
      <tr>
          <td>Kimi-K2.6</td>
          <td>n/a</td>
          <td>hangs</td>
          <td>INELIGIBLE. Reasoning model burns the token budget internally before emitting content.</td>
      </tr>
  </tbody>
</table>
<p>Why Haiku ended up as default: hosted (always available), best at <strong>restraint</strong> (resists fabricating pages when the session is trivial, crucial for the wiki not to turn into a pile of empty pages), and best at <strong>classification</strong> (consistently gets <code>kind: rule</code> right, which is what makes auto-routing to <code>_rules/&lt;slug&gt;.md</code> work). $0.02 per consolidation is negligible for personal use.</p>
<p>But the user has legitimate alternatives:</p>
<ul>
<li><strong>GPT-5.4-mini</strong> if budget is tight (5× cheaper than Haiku, 2× faster). The only measurable defect was fabricating a <code>decisions/docs-spelling.md</code> page on a typo-fix session. Over-classification, but mild.</li>
<li><strong>qwen3:32b on Ollama</strong> if you have a local server with ~20GB of RAM/VRAM. Zero cost per consolidation, fidelity comparable to the top hosted models. The ~92s latency doesn&rsquo;t matter because consolidation runs in the background.</li>
<li><strong>DeepSeek V4 Flash</strong> works, but GPT-mini matches it on quality and beats it on speed.</li>
</ul>
<p>If you want to test other models, the eval harness is at <a href="https://github.com/akitaonrails/ai-memory/tree/main/evals"target="_blank" rel="noopener"><code>evals/</code></a> as a separate Rust crate. Add a JSON fixture, run <code>cargo run -p ai-memory-eval --release</code>, compare two providers side by side. Outputs land in <code>evals/runs/&lt;timestamp&gt;/{baseline,candidate}/</code> with the exact <code>.raw.txt</code> the model answered plus <code>.meta.json</code> for latency and parse rate. PRs with new models tested are very welcome.</p>
<h2>Status: beta, needs users<span class="hx:absolute hx:-mt-20" id="status-beta-needs-users"></span>
    <a href="#status-beta-needs-users" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>ai-memory is at the <code>v0.2-complete</code> tag on GitHub. The main features work: automatic capture, MCP, optional web UI, bootstrap, purge/rename/backup, bearer auth, browsing, FTS5 + graph/link-neighbour RRF search, optional embeddings, consolidation, decay, scheduled maintenance, and handoff across Claude Code / Codex / OpenCode / Cursor / Gemini CLI / OMP-Pi. I&rsquo;ve been running it in personal production for a few weeks.</p>
<p>But it&rsquo;s beta. It needs many more users banging on it for me to find the bugs that don&rsquo;t show up on my machine. Claude Desktop and OpenClaw work as MCP-only clients; some exotic MCP clients still need real testing. The most polished integration today is Claude Code, followed by Codex, OpenCode, and OMP/Pi.</p>
<p>If you&rsquo;ve made it this far, consider installing and sending feedback. Issues at <a href="https://github.com/akitaonrails/ai-memory/issues"target="_blank" rel="noopener">github.com/akitaonrails/ai-memory/issues</a>. PRs are very welcome — the code is Rust, MIT licensed, and the architecture is documented in <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/ARCHITECTURE.md"target="_blank" rel="noopener"><code>docs/ARCHITECTURE.md</code></a> and <a href="https://github.com/akitaonrails/ai-memory/blob/main/docs/design-decisions.md"target="_blank" rel="noopener"><code>docs/design-decisions.md</code></a>. Even a bug report of &ldquo;I installed it, it didn&rsquo;t run on my setup&rdquo; helps — it means I assumed something about environments that doesn&rsquo;t match yours.</p>
<p>The core motivation stays the Karpathy line. Agent memory is text. Text lives on disk. You index it with SQLite. You compile with an LLM when the work is worth it. You forget what nobody looks at anymore. You stay out of the way. And when you switch agents in the middle of a project, the next one already knows where you left off.</p>
<p>That&rsquo;s what ai-memory tries to deliver.</p>
]]></content:encoded><category>ai-memory</category><category>coding-agents</category><category>rust</category></item><item><title>I Rewrote OpenClaw in Rust. Did It Work? | FrankClaw</title><link>https://akitaonrails.github.io/en/2026/03/16/rewrote-openclaw-in-rust-frankclaw/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/16/rewrote-openclaw-in-rust-frankclaw/</guid><pubDate>Mon, 16 Mar 2026 08:00:00 -0300</pubDate><description>&lt;p&gt;Before anything else: FrankClaw is still in heavy alpha. It works for simple tasks, but I haven&amp;rsquo;t tested complex workflows. If you want to help, open Issues on &lt;a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener"&gt;GitHub&lt;/a&gt; with whatever you find. There&amp;rsquo;s a lot to test.&lt;/p&gt;
&lt;p&gt;It &amp;ldquo;works,&amp;rdquo; but this project was more about the exercise. With that out of the way, let me tell you why I did it.&lt;/p&gt;
&lt;h2&gt;The problem with OpenClaw&lt;span class="hx:absolute hx:-mt-20" id="the-problem-with-openclaw"&gt;&lt;/span&gt;
&lt;a href="#the-problem-with-openclaw" class="subheading-anchor" aria-label="Permalink for this section"&gt;&lt;/a&gt;&lt;/h2&gt;&lt;p&gt;&lt;a href="https://github.com/openclaw/openclaw"target="_blank" rel="noopener"&gt;OpenClaw&lt;/a&gt; is a gateway that connects messaging channels (Telegram, Discord, Slack, WhatsApp, etc.) to AI providers (OpenAI, Anthropic, Ollama). You configure it, bring up the server, and you can chat with LLMs straight from Telegram or any other channel. It&amp;rsquo;s a popular project with plenty of activity.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Before anything else: FrankClaw is still in heavy alpha. It works for simple tasks, but I haven&rsquo;t tested complex workflows. If you want to help, open Issues on <a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener">GitHub</a> with whatever you find. There&rsquo;s a lot to test.</p>
<p>It &ldquo;works,&rdquo; but this project was more about the exercise. With that out of the way, let me tell you why I did it.</p>
<h2>The problem with OpenClaw<span class="hx:absolute hx:-mt-20" id="the-problem-with-openclaw"></span>
    <a href="#the-problem-with-openclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><a href="https://github.com/openclaw/openclaw"target="_blank" rel="noopener">OpenClaw</a> is a gateway that connects messaging channels (Telegram, Discord, Slack, WhatsApp, etc.) to AI providers (OpenAI, Anthropic, Ollama). You configure it, bring up the server, and you can chat with LLMs straight from Telegram or any other channel. It&rsquo;s a popular project with plenty of activity.</p>
<p>Too much activity, actually.</p>
<p>I did a depth-1 clone of the repo and ran <code>tokei</code>:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TypeScript files (without tests)</td>
          <td>3,794</td>
      </tr>
      <tr>
          <td>Lines of TypeScript</td>
          <td>~1,247,000</td>
      </tr>
      <tr>
          <td>Test files</td>
          <td>2,799</td>
      </tr>
      <tr>
          <td>Dependencies (root package.json)</td>
          <td>73</td>
      </tr>
  </tbody>
</table>
<p>Over a million lines of TypeScript. The 2,799 test files sound like a lot in absolute numbers, but proportional to the codebase size, the coverage is low. Most of the code lives in 29 packages of a monorepo with 21 channel extensions.</p>
<p>I went looking for more commits to understand the development pace. In the 100 commits I managed to pull, all of them landed in just 2 days (March 9 and 10). ~50 commits per day, from 42 different contributors. Vibe coding to the extreme.</p>
<p>The conclusion is the one you&rsquo;re imagining: enormous volumes of AI-generated code being dumped into a repository at a speed that makes serious human review impossible. And that bothered me enough to go investigate further.</p>
<h2>The security audit<span class="hx:absolute hx:-mt-20" id="the-security-audit"></span>
    <a href="#the-security-audit" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I asked Claude to do a complete security audit of OpenClaw&rsquo;s code. The <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/OPENCLAW_SECURITY_AUDIT.md"target="_blank" rel="noopener">report</a> found:</p>
<table>
  <thead>
      <tr>
          <th>Severity</th>
          <th>Count</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CRITICAL</td>
          <td>7</td>
      </tr>
      <tr>
          <td>HIGH</td>
          <td>9</td>
      </tr>
      <tr>
          <td>MEDIUM</td>
          <td>12</td>
      </tr>
      <tr>
          <td>LOW</td>
          <td>6</td>
      </tr>
      <tr>
          <td>INFO</td>
          <td>5</td>
      </tr>
  </tbody>
</table>
<p>Seven critical vulnerabilities. Let me list a few:</p>
<ul>
<li>Timing side-channel in token comparison — <code>safeEqualSecret()</code> does an early return on the type-check, letting an attacker distinguish malformed tokens from wrong tokens by measuring latency.</li>
<li><code>eval()</code> in the browser tool — arbitrary JavaScript execution with no sandbox.</li>
<li>Shell with no allowlist — any tool can run any command on the system.</li>
<li>Slack webhooks with no signature verification at all.</li>
<li>Transcripts and config in plaintext on disk, no encryption.</li>
<li>No effective rate limiting — IPs can be spoofed if the operator configures trusted proxies broadly.</li>
</ul>
<p>Those are just the ones Claude found in an automated scan. There are probably more.</p>
<p>I&rsquo;m not going to run that on my machine. Not even inside a Docker container. A gateway that receives webhooks from the internet, executes shell commands, connects to AI APIs with your keys, and stores conversation history, all of it with 7 known critical vulnerabilities? No, thanks.</p>
<p>So I did what any rational developer would do: I decided to build my own.</p>
<h2>The first attempt: Claude Code<span class="hx:absolute hx:-mt-20" id="the-first-attempt-claude-code"></span>
    <a href="#the-first-attempt-claude-code" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I started the most direct way. Cloned OpenClaw, pointed Claude Code at the code, and asked: &ldquo;rewrite this in Rust.&rdquo;</p>
<p>Didn&rsquo;t work. The codebase is too big. Over a million lines of TypeScript spread across 29 packages. Claude can&rsquo;t keep all of it in context. The initial result was very incomplete: lots of types created but no implementation, <code>todo!()</code> everywhere, too much boilerplate and not enough functionality.</p>
<p>I switched to Codex 5.4 to test. Same thing: I asked it to analyze and rewrite. It improved a bit in certain aspects, but the fundamental problem is the same. No AI today takes a project of that size and rewrites it in one go. The context doesn&rsquo;t fit.</p>
<h2>The technique that actually works<span class="hx:absolute hx:-mt-20" id="the-technique-that-actually-works"></span>
    <a href="#the-technique-that-actually-works" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>What works is going slowly. One step at a time.</p>
<p>Ask Claude (or Codex) to analyze the original code in stages. Make a long plan detailing each feature. Then implement one feature at a time in Rust, with tests, commit, and repeat. It&rsquo;s tedious, but it produces code that compiles and actually works.</p>
<p>The reason is simple: the original code is so massive that no AI agent (and not even several in parallel) can keep all of it in context at the same time. You have to decide what matters, implement that, validate, and move on to the next thing.</p>
<p>And you have to decide what to cut. OpenClaw has 21 channel extensions: Google Chat, iMessage, IRC, Teams, Matrix, Mattermost, Nostr, Twitch&hellip; I don&rsquo;t need any of those. I kept the mainstream channels: Web, Telegram, Discord, Slack, Signal, WhatsApp and Email. TTS? Out. Polls? Out. WhatsApp Web through Baileys? Out, I use the official Cloud API. They&rsquo;re features that add complexity without proportional value.</p>
<h2>Discovering IronClaw<span class="hx:absolute hx:-mt-20" id="discovering-ironclaw"></span>
    <a href="#discovering-ironclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>In the middle of development, I came across <a href="https://github.com/nichochar/iron-claw"target="_blank" rel="noopener">IronClaw</a>, which presents itself as &ldquo;OpenClaw in Rust.&rdquo; Great, I thought. Let me see what they did.</p>
<p>I cloned the repo and asked Claude to write a <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/IRONCLAW_COMPARISON.md"target="_blank" rel="noopener">comparison report</a>. IronClaw has good things. I adopted 12 features:</p>
<p>Circuit breaker with retry and exponential backoff for LLM provider resilience. Credential leak detection in the output. LLM response cache with SHA-256 of the prompt. Cost tracking with budget guards (warning at 80%, block at 100%). Extended thinking for Claude 3.7+ and o1. MCP client for external tool servers. Lifecycle hooks on inbound, tool calls and outbound. Smart model routing that sends simple queries to cheaper models. Tunnel support (cloudflared, ngrok, tailscale). Interactive REPL (<code>frankclaw chat</code>). Routines with event triggers beyond cron. Job state machine with auto-repair.</p>
<p>But IronClaw depends on PostgreSQL + pgvector, has a WASM sandbox (wasmtime adds ~10MB), and is part of the NEAR AI ecosystem. I want a single binary with embedded SQLite and zero external dependencies.</p>
<h2>What FrankClaw brings from OpenClaw<span class="hx:absolute hx:-mt-20" id="what-frankclaw-brings-from-openclaw"></span>
    <a href="#what-frankclaw-brings-from-openclaw" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The OpenClaw core is there: 7 messaging channels, multi-provider with failover, agent runtime, command system, skills, subagents, browser automation through CDP, bash tool with allowlist, cron jobs, interactive REPL. Plus the 12 IronClaw features I listed above.</p>
<p>But several pieces had to be rewritten in a way the original didn&rsquo;t. Context compaction, for example, uses a sliding window with token estimation, message pruning and automatic repair of tool pairs that get orphaned when the context is cut. Provider failover is now model-aware: if you ask for a Claude model and the current provider is OpenAI, it skips automatically instead of erroring out. The canvas renders SVG, HTML and Markdown with revision conflict detection. Things that in OpenClaw either didn&rsquo;t exist or were half-built.</p>
<p>Then came the phase of adding what was missing for parity. FrankClaw today has 30+ LLM tools: <code>web_fetch</code> (SSRF-safe with HTML-to-text), <code>web_search</code> (Brave API), <code>file_read</code>/<code>file_write</code>/<code>file_edit</code> (sandboxed in the workspace with path traversal protection), <code>pdf_read</code>, <code>image_describe</code> (through vision models), <code>audio_transcribe</code>, <code>sessions_list</code>/<code>sessions_history</code>/<code>sessions_delete</code>, <code>message_send</code>/<code>message_react</code>, <code>cron_list</code>/<code>cron_add</code>/<code>cron_remove</code>, <code>config_get</code> (auto-redacts secrets), <code>agents_list</code>, <code>memory_get</code>/<code>memory_search</code>, plus the browser tools (<code>browser_open</code>, <code>browser_extract</code>, <code>browser_snapshot</code>, <code>browser_click</code>, <code>browser_type</code>, <code>browser_wait</code>, <code>browser_press</code>, <code>browser_sessions</code>, <code>browser_close</code>, <code>browser_aria</code>).</p>
<p>Some additions that don&rsquo;t exist in the original OpenClaw: a memory/RAG system with SQLite FTS5 and embeddings (OpenAI, Gemini, Voyage) that automatically syncs workspace files. An OpenAI-compatible API (<code>/v1/chat/completions</code> and <code>/v1/models</code>), so any client that speaks that protocol (Cursor, Continue, Open WebUI) can use FrankClaw as a backend with no adaptation. A <code>ratatui</code> TUI for people who prefer the terminal. Interactive approval of destructive tools before execution.</p>
<p>Smaller things that make a difference in practice. You can configure multiple API keys per provider with round-robin and automatic backoff, so if one key hits the rate limit, the next one takes over. The model catalog already knows context windows and costs of OpenAI and Anthropic models without you having to configure them. URL extraction from messages has a private IP blocklist against SSRF. The command system accepts inline directives (<code>/think</code>, <code>/model</code>) in addition to aliases.</p>
<p>On the operational side: ACP (Agent Client Protocol) over JSON-RPC 2.0 on top of NDJSON for people who want to integrate programmatically. Plugin system with manifests and enable/disable lifecycle. i18n with 9 locales via <code>FRANKCLAW_LANG</code>. Workspace identity files (<code>SOUL.md</code>, <code>IDENTITY.md</code>) to define the bot&rsquo;s personality per project. Channel health monitor with auto-restart. WebSocket with ping keepalive that survives proxy and tunnel timeouts. <code>frankclaw start/stop/status</code> for people who want to run it as a daemon with PID tracking. And the entire configuration migrated from JSON to TOML.</p>
<h2>Hardening: where the real difference is<span class="hx:absolute hx:-mt-20" id="hardening-where-the-real-difference-is"></span>
    <a href="#hardening-where-the-real-difference-is" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/OPENCLAW_SECURITY_AUDIT.md"target="_blank" rel="noopener">audit report</a> we ran on OpenClaw found 7 critical and 9 high vulnerabilities. FrankClaw fixes all of them:</p>
<table>
  <thead>
      <tr>
          <th>Area</th>
          <th>OpenClaw</th>
          <th>FrankClaw</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Token comparison</td>
          <td>SHA-256 + timingSafeEqual with early return that leaks timing</td>
          <td>Constant-time byte-by-byte comparison, no early returns</td>
      </tr>
      <tr>
          <td>Shell execution</td>
          <td>No mandatory allowlist</td>
          <td>Deny-all by default + binary allowlist + metacharacter rejection + optional ai-jail sandbox</td>
      </tr>
      <tr>
          <td>Browser tool</td>
          <td><code>eval()</code> with no sandbox</td>
          <td>CDP with 15s timeout, SSRF guard, crash recovery, ARIA inspection</td>
      </tr>
      <tr>
          <td>Slack webhook</td>
          <td>Zero signature verification</td>
          <td>HMAC-SHA256 with replay protection</td>
      </tr>
      <tr>
          <td>Discord webhook</td>
          <td>Hardcoded placeholder</td>
          <td>Ed25519 with timestamp validation</td>
      </tr>
      <tr>
          <td>Cryptography</td>
          <td>Plaintext on disk</td>
          <td>ChaCha20-Poly1305 on sessions and config</td>
      </tr>
      <tr>
          <td>Password hashing</td>
          <td>No password authentication at all</td>
          <td>Argon2id (t=3, m=64MB, p=4)</td>
      </tr>
      <tr>
          <td>File permissions</td>
          <td>0o644 (world-readable)</td>
          <td>0o600 (owner-only)</td>
      </tr>
      <tr>
          <td>Prompt injection</td>
          <td>Basic sanitization</td>
          <td>Unicode Cc/Cf stripping + boundary tags + 2MB limit</td>
      </tr>
      <tr>
          <td>Malware scanning</td>
          <td>None</td>
          <td>Optional VirusTotal on uploads</td>
      </tr>
      <tr>
          <td>Input validation</td>
          <td>No limits</td>
          <td>255 byte IDs, 800 byte session keys, configurable WS frames</td>
      </tr>
      <tr>
          <td>SSRF</td>
          <td>Partial protection</td>
          <td>Full blocklist (RFC 1918, loopback, CGNAT, link-local) + DNS rebinding defense</td>
      </tr>
      <tr>
          <td>Tool execution</td>
          <td>No user confirmation</td>
          <td>Interactive approval for mutating/destructive tools</td>
      </tr>
  </tbody>
</table>
<p>FrankClaw compiles with <code>#![forbid(unsafe_code)]</code> in all 13 crates. Zero unsafe blocks.</p>
<p>And the audit didn&rsquo;t stop at OpenClaw. We did a <a href="https://github.com/akitaonrails/frankclaw/blob/master/docs/AUDIT_PLAN.md"target="_blank" rel="noopener">per-component audit</a> in 14 phases comparing each part of FrankClaw against the original: channels, providers, runtime, tools, sessions, crypto, cron, webhooks. All documented.</p>
<h2>Deploy: how to install<span class="hx:absolute hx:-mt-20" id="deploy-how-to-install"></span>
    <a href="#deploy-how-to-install" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>FrankClaw runs with Docker Compose. Three containers: gateway, headless Chromium (for browser tools), and Cloudflare tunnel (to receive webhooks).</p>
<h3>1. Clone and configure<span class="hx:absolute hx:-mt-20" id="1-clone-and-configure"></span>
    <a href="#1-clone-and-configure" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git clone https://github.com/akitaonrails/frankclaw.git
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> frankclaw
</span></span><span class="line"><span class="cl">cp .env.docker.example .env.docker</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Edit <code>.env.docker</code> with your keys:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Providers de IA (preencha os que usar)</span>
</span></span><span class="line"><span class="cl"><span class="nv">OPENAI_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Canais (preencha os que quiser usar)</span>
</span></span><span class="line"><span class="cl"><span class="nv">TELEGRAM_BOT_TOKEN</span><span class="o">=</span>         <span class="c1"># via @BotFather</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_TOKEN</span><span class="o">=</span>             <span class="c1"># Meta Business Platform</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_PHONE_ID</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">WHATSAPP_VERIFY_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">DISCORD_BOT_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SLACK_BOT_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl"><span class="nv">SLACK_APP_TOKEN</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Embedding providers (só se usar memória/RAG)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># GEMINI_API_KEY=</span>
</span></span><span class="line"><span class="cl"><span class="c1"># VOYAGE_API_KEY=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opcional: criptografia de sessions (recomendado)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Gere com: openssl rand -base64 32</span>
</span></span><span class="line"><span class="cl"><span class="nv">FRANKCLAW_MASTER_KEY</span><span class="o">=</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opcional: scan de malware em uploads</span>
</span></span><span class="line"><span class="cl"><span class="nv">VIRUSTOTAL_API_KEY</span><span class="o">=</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>2. Configure the gateway<span class="hx:absolute hx:-mt-20" id="2-configure-the-gateway"></span>
    <a href="#2-configure-the-gateway" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>The <code>frankclaw.toml</code> file defines agents, models and channels. Use the wizard or the examples:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Generate a base config with the web channel</span>
</span></span><span class="line"><span class="cl">cargo run -- onboard --channel web
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Or copy from the examples</span>
</span></span><span class="line"><span class="cl">ls examples/channels/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For each channel, the CLI has ready-made templates:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- config-example --channel telegram
</span></span><span class="line"><span class="cl">cargo run -- config-example --channel whatsapp</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>3. Cloudflare Tunnel (to receive webhooks)<span class="hx:absolute hx:-mt-20" id="3-cloudflare-tunnel-to-receive-webhooks"></span>
    <a href="#3-cloudflare-tunnel-to-receive-webhooks" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><p>If you&rsquo;re going to use channels that need a webhook (Telegram, Discord, Slack, WhatsApp), you need a public tunnel. The Docker Compose already includes cloudflared:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Copy your Cloudflare credentials</span>
</span></span><span class="line"><span class="cl">cp docker/cloudflared/config.yml.example docker/cloudflared/config.yml
</span></span><span class="line"><span class="cl">cp ~/.cloudflared/&lt;tunnel-id&gt;.json docker/cloudflared/credentials.json
</span></span><span class="line"><span class="cl">cp ~/.cloudflared/cert.pem docker/cloudflared/cert.pem
</span></span><span class="line"><span class="cl"><span class="c1"># Edit config.yml with your hostname</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<h3>4. Bring it up<span class="hx:absolute hx:-mt-20" id="4-bring-it-up"></span>
    <a href="#4-bring-it-up" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose up -d</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The gateway comes up on port 18789 (internal to Docker). cloudflared routes external traffic. Chromium stays on the internal network for browser tools.</p>
<p>To test locally without Docker:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- chat</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Opens the interactive REPL straight in the terminal (it now also has a <code>ratatui</code> TUI with dark mode and tabs). No gateway, no webhook. Good for validating that the AI provider is responding before configuring channels.</p>
<h3>Validation<span class="hx:absolute hx:-mt-20" id="validation"></span>
    <a href="#validation" class="subheading-anchor" aria-label="Permalink for this section"></a></h3><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cargo run -- check     <span class="c1"># validates config</span>
</span></span><span class="line"><span class="cl">cargo run -- doctor    <span class="c1"># full diagnostic</span>
</span></span><span class="line"><span class="cl">cargo run -- audit     <span class="c1"># security audit with severity ratings</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p><code>audit</code> is the one I like the most. It checks whether you have encryption enabled, whether file permissions are correct, whether webhooks have signature verification, whether the bash tool is in deny-all. It exits with a non-zero exit code when it finds critical issues, so you can drop it in CI.</p>
<h2>The development process<span class="hx:absolute hx:-mt-20" id="the-development-process"></span>
    <a href="#the-development-process" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The project has 178 commits in ~5 days of work (March 10-16). Almost 57 thousand lines of Rust in 120 files, organized in 13 crates.</p>
<p>The commits tell the story. The first few dozens were scaffolding: workspace structure, basic types, the HTTP/WebSocket gateway, first version of the channel adapters. Mass-generated code, lots of incomplete pieces.</p>
<p>Then the channel adapters started, one by one:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>236aa1c - Minimal Discord channel adapter
fd67017 - Minimal Slack channel adapter
9f51373 - Minimal Signal channel adapter
1052e47 - WhatsApp channel webhook adapter
035f86e - Email channel adapter (IMAP inbound, SMTP outbound)</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>When the basic channels were in place, the IronClaw integration came in one big commit:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>c87ab32 - IronClaw-derived features: circuit breaker, retry, leak detection, cache, cost tracking, extended thinking</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>And then came the hardening. That was the phase where I had to step in manually the most, because Claude generates functional code but doesn&rsquo;t think about attack vectors on its own:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>db34198 - Prompt injection sanitization, external content wrapping, prompt size limit
5719b34 - Optional VirusTotal malware scanning for file uploads
ccd2b2b - Harden input validation across all user-facing entry points
aa918ee - Optional ai-jail sandbox for bash tool
2d7b1df - Security audit command with severity-rated findings
d12cc97 - 3-tier ToolRiskLevel system replacing binary browser mutation flag
21e0c91 - Timing-safe token comparison in WhatsApp, crypto audit tests
e240c1b - Webhook replay prevention with timestamp verification
876a78c - Gateway &amp; media: SSRF redirect validation, filename hardening</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>22 security hardening commits. Plus 10 more component audits. Each finding became a commit with a fix.</p>
<p>Then came the per-channel audits, each one uncovering different edge cases:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>43b085f - Discord audit: HELLO timeout, fatal close codes, message chunking
12c7cff - Telegram audit: caption overflow, parse fallback, edit idempotency
f515062 - WhatsApp audit: message type filtering, send error classification
3c42aff - Slack audit: fatal auth errors, send error classification</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Browser tools needed extra attention. A headless Chrome that gets URLs from an LLM is an obvious attack vector:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>3217a96 - Browser automation: CDP timeout, SSRF guard, session limits, crash recovery
d98a803 - Gate mutating browser tools behind operator approval
014f56e - Browser screenshot/ARIA tools for accessibility tree inspection</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>In the more recent commits, the project started diverging from OpenClaw:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><pre><code>5d73c4d - OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models)
d832c36 - Memory/RAG system with SQLite FTS5, embeddings, and file sync
2b05f47 - Interactive tool approval for mutating/destructive tools
49034eb - Web console: dark mode, 8 tabs, focus mode, tool sidebar
9f51a18 - TUI, Gemini/Voyage embeddings, plugin management, ACP protocol
3c0703b - Config migration from JSON to TOML
eb130ad - Channel health monitor with auto-restart and rate limiting
2c3ea7e - Workspace bootstrap files (SOUL.md, IDENTITY.md) to system prompt
9df61bf - Model-aware failover routing, canvas SVG rendering
c7ba108 - WebSocket ping keepalive and auto-reconnect to web console</code></pre></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The OpenAI-compatible API is the one I use the most day to day. Cursor, Continue, Open WebUI, anything that speaks the OpenAI protocol can use FrankClaw as a backend without touching anything on the client side.</p>
<h2>The numbers<span class="hx:absolute hx:-mt-20" id="the-numbers"></span>
    <a href="#the-numbers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>Value</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Commits</td>
          <td>178</td>
      </tr>
      <tr>
          <td>Days of work</td>
          <td>~5</td>
      </tr>
      <tr>
          <td>Lines of Rust</td>
          <td>56,586</td>
      </tr>
      <tr>
          <td>Rust files</td>
          <td>120</td>
      </tr>
      <tr>
          <td>Crates</td>
          <td>13</td>
      </tr>
      <tr>
          <td>LLM tools</td>
          <td>30+</td>
      </tr>
      <tr>
          <td>Security hardening commits</td>
          <td>22</td>
      </tr>
      <tr>
          <td>Audit commits</td>
          <td>10</td>
      </tr>
      <tr>
          <td>Supported channels</td>
          <td>7</td>
      </tr>
      <tr>
          <td>AI providers</td>
          <td>9 (OpenAI, Anthropic, Ollama, Google, OpenRouter, Copilot, Groq, Together, DeepSeek)</td>
      </tr>
      <tr>
          <td>OpenClaw critical vulnerabilities fixed</td>
          <td>7/7</td>
      </tr>
      <tr>
          <td>OpenClaw high vulnerabilities fixed</td>
          <td>9/9</td>
      </tr>
      <tr>
          <td>Unsafe blocks in the code</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>Compared to OpenClaw:</p>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>OpenClaw (TS)</th>
          <th>FrankClaw (Rust)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Lines of code</td>
          <td>~1,247,000</td>
          <td>56,586</td>
      </tr>
      <tr>
          <td>Source files</td>
          <td>3,794</td>
          <td>120</td>
      </tr>
      <tr>
          <td>Runtime dependencies</td>
          <td>73</td>
          <td>~40 crates</td>
      </tr>
      <tr>
          <td>Channels</td>
          <td>28</td>
          <td>7</td>
      </tr>
      <tr>
          <td>Critical vulnerabilities</td>
          <td>7 known</td>
          <td>0</td>
      </tr>
  </tbody>
</table>
<p>The numbers aren&rsquo;t directly comparable. OpenClaw has 21 channel extensions I cut, a more complete web UI, and niche features I didn&rsquo;t port. But the core (gateway, mainstream channels, providers, runtime, sessions, tools, memory) is there with 22x less code.</p>
<h2>How to help (beta testing)<span class="hx:absolute hx:-mt-20" id="how-to-help-beta-testing"></span>
    <a href="#how-to-help-beta-testing" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>FrankClaw works for basic conversation through Web and Telegram (I tested both). WhatsApp works for simple messages. Discord, Slack, Signal and Email are implemented but haven&rsquo;t had extensive testing. We haven&rsquo;t done any complex workflows yet.</p>
<p>If you want to test it: clone the repository, bring it up with Docker Compose, configure at least one channel (Telegram is the easiest) and try to use it normally. Send messages, test tool calls, try to break the system. Open Issues on GitHub with whatever you find.</p>
<p>What I know needs more eyes: workflows with tools (browser, bash, MCP), subagent orchestration, failover between providers, session persistence with encryption, smart routing between models, scheduled jobs, the memory/RAG system, and the OpenAI-compatible API. Basically everything that goes beyond &ldquo;send a message, get a reply.&rdquo;</p>
<p>You don&rsquo;t have to be a Rust developer. The bigger value is in using the system in ways I didn&rsquo;t think of and finding the edge cases that only show up under real use.</p>
<p>FrankClaw doesn&rsquo;t replace OpenClaw today. OpenClaw has more channels, more features, more people working on it. But it carries the weight of over a million lines of TypeScript generated at 50 commits per day by dozens of contributors, with documented critical vulnerabilities. FrankClaw is the alternative for anyone who looks at that and thinks &ldquo;I&rsquo;m not running this code on my machine.&rdquo;</p>
<p>But I&rsquo;ll be honest: as fun as it was to build, I personally don&rsquo;t know if I need this. FrankClaw is a generic gateway, designed to be flexible, to connect any channel to any provider, with an agent runtime, tools, subagents, jobs, hooks. It&rsquo;s a lot of infrastructure.</p>
<p>What I&rsquo;ve discovered over the last few months is that I can build custom bots for specific tasks much faster. In 1 day I have a bot working, focused on what I need, without carrying the weight of a generic framework. That&rsquo;s what I did with <a href="/en/2026/02/20/discord-as-an-admin-panel-behind-the-m-akita-chronicles/">Marvin</a> on the newsletter project, for instance. A custom-built Discord bot that does exactly what I need and nothing else.</p>
<p>A generic gateway like FrankClaw makes more sense for someone who wants a unified interface between several chat channels and several AI models without coding. If that&rsquo;s your case, give it a shot. If you&rsquo;re a developer and you know what you want, maybe a custom bot will serve you better. Up to you.</p>
<p>The <a href="https://github.com/akitaonrails/frankclaw"target="_blank" rel="noopener">repository is here</a>. AGPL-3.0.</p>
]]></content:encoded><category>frank</category><category>llms</category><category>artificial-intelligence</category><category>rust</category></item><item><title>ai-jail: Sandbox for AI Agents — From Shell Script to Real Tool</title><link>https://akitaonrails.github.io/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/</link><guid isPermaLink="true">https://akitaonrails.github.io/en/2026/03/01/ai-jail-sandbox-for-ai-agents-from-shell-script-to-real-tool/</guid><pubDate>Sun, 01 Mar 2026 14:00:00 -0300</pubDate><description>&lt;p&gt;This post is a direct follow-up to &lt;a href="https://akitaonrails.github.io/en/2026/01/10/ai-agents-locking-down-your-system/"&gt;AI Agents: Locking Down Your System&lt;/a&gt;, where I showed how to use bubblewrap to build a manual jail for your AI agents. If you haven&amp;rsquo;t read it, read it before continuing.&lt;/p&gt;
&lt;p&gt;&amp;ndash;&lt;/p&gt;
&lt;p&gt;In January I published a ~170-line shell script that built a sandbox with bubblewrap to run Claude Code, OpenCode, Crush and any other AI agent. It worked. It solved the problem. But it was a bash script dropped in &lt;code&gt;~/.local/bin/&lt;/code&gt; that you had to copy, paste, and pray you wouldn&amp;rsquo;t need to customize too much.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This post is a direct follow-up to <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">AI Agents: Locking Down Your System</a>, where I showed how to use bubblewrap to build a manual jail for your AI agents. If you haven&rsquo;t read it, read it before continuing.</p>
<p>&ndash;</p>
<p>In January I published a ~170-line shell script that built a sandbox with bubblewrap to run Claude Code, OpenCode, Crush and any other AI agent. It worked. It solved the problem. But it was a bash script dropped in <code>~/.local/bin/</code> that you had to copy, paste, and pray you wouldn&rsquo;t need to customize too much.</p>
<p>Two months of using that script every day showed me its limits. I wanted per-project configuration. I wanted macOS support for the devs on my team. I wanted to stop editing bash arrays every time I needed an extra directory. And I wanted something someone could install with <code>brew install</code> or <code>cargo install</code> in 10 seconds, without reading 170 lines of script.</p>
<p>Result: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">ai-jail</a>. A Rust tool, ~880KB, 4 dependencies, 124 tests, that does exactly what that script did and more. I&rsquo;ll explain what changed and why you should be using it.</p>
<h2>The Problem (again, for those who skipped the previous post)<span class="hx:absolute hx:-mt-20" id="the-problem-again-for-those-who-skipped-the-previous-post"></span>
    <a href="#the-problem-again-for-those-who-skipped-the-previous-post" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>AI coding agents need access to your filesystem. They need to run compilers, linters, grep, ls, make, cargo, npm. The minimum to be useful. The problem is that along with that access comes the ability to read <code>~/.aws/credentials</code>, exfiltrate your SSH keys, or run an <code>rm -rf</code> outside the project directory.</p>
<p>It isn&rsquo;t paranoia. Supply-chain attacks are real. Every other week some NPM, PyPI or RubyGems lib gets compromised. If the agent runs <code>npm install</code> and a malicious post-install script tries to exfiltrate your data, the only thing between the attacker and your credentials is whatever barrier you set up beforehand.</p>
<p>The answer is a sandbox. Specifically, one that lets the agent work in the project directory with the tools it needs, but makes the entire rest of the system invisible.</p>
<h2>From Script to Tool<span class="hx:absolute hx:-mt-20" id="from-script-to-tool"></span>
    <a href="#from-script-to-tool" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>The shell script from the previous post already solved this with bubblewrap. ai-jail solves the same problem, but addresses the limitations that two months of daily use revealed:</p>
<table>
  <thead>
      <tr>
          <th>Shell script</th>
          <th>ai-jail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Configuration by editing bash arrays</td>
          <td>Per-project <code>.ai-jail</code> TOML file</td>
      </tr>
      <tr>
          <td>Linux only</td>
          <td>Linux + macOS</td>
      </tr>
      <tr>
          <td>Hardcoded GPU/Docker/Display</td>
          <td>Auto-detection with flags to turn things off</td>
      </tr>
      <tr>
          <td>No dry-run</td>
          <td><code>--dry-run --verbose</code> shows everything</td>
      </tr>
      <tr>
          <td>No lockdown</td>
          <td><code>--lockdown</code> for paranoid mode</td>
      </tr>
      <tr>
          <td>Copy/paste to install</td>
          <td><code>brew install</code>, <code>cargo install</code>, <code>mise</code></td>
      </tr>
      <tr>
          <td>No bootstrap</td>
          <td><code>--bootstrap</code> generates permission configs for Claude/Codex/OpenCode</td>
      </tr>
  </tbody>
</table>
<p>The core logic is the same: bubblewrap creates isolated PID, UTS and IPC namespaces, mounts <code>$HOME</code> as an ephemeral tmpfs, and only mounts the project directory with write access. The difference is that all of that is now configurable without editing code.</p>
<h2>Installation<span class="hx:absolute hx:-mt-20" id="installation"></span>
    <a href="#installation" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Four ways:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Homebrew (macOS and Linux)</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Cargo</span>
</span></span><span class="line"><span class="cl">cargo install ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Mise</span>
</span></span><span class="line"><span class="cl">mise use -g ubi:akitaonrails/ai-jail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Direct binary from GitHub Releases</span>
</span></span><span class="line"><span class="cl">curl -fsSL https://github.com/akitaonrails/ai-jail/releases/latest/download/ai-jail-linux-x86_64.tar.gz <span class="p">|</span> tar xz
</span></span><span class="line"><span class="cl">sudo mv ai-jail /usr/local/bin/</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On Linux, bubblewrap needs to be installed separately: <code>pacman -S bubblewrap</code> (Arch), <code>apt install bubblewrap</code> (Debian/Ubuntu), <code>dnf install bubblewrap</code> (Fedora). On macOS no extra dependency is needed.</p>
<h2>Basic Usage<span class="hx:absolute hx:-mt-20" id="basic-usage"></span>
    <a href="#basic-usage" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Claude Code inside the sandbox</span>
</span></span><span class="line"><span class="cl">ai-jail claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run Codex</span>
</span></span><span class="line"><span class="cl">ai-jail codex
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Run OpenCode</span>
</span></span><span class="line"><span class="cl">ai-jail opencode
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Plain bash for debugging</span>
</span></span><span class="line"><span class="cl">ai-jail bash
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Any command</span>
</span></span><span class="line"><span class="cl">ai-jail -- python script.py</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>On the first run, it creates an <code>.ai-jail</code> file in the project directory:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># ai-jail sandbox configuration</span>
</span></span><span class="line"><span class="cl"><span class="c"># Edit freely. Regenerate with: ai-jail --clean --init</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">command</span> <span class="p">=</span> <span class="p">[</span><span class="s2">&#34;claude&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nx">rw_maps</span> <span class="p">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl"><span class="nx">ro_maps</span> <span class="p">=</span> <span class="p">[]</span></span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>This file is committable to the repo. When another dev on your team clones the project and runs <code>ai-jail</code>, the same configuration applies.</p>
<p>If you want to add extra directories, you can do it from the CLI or directly in the TOML:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Extra directory with write access</span>
</span></span><span class="line"><span class="cl">ai-jail --rw-map ~/Projects/shared-lib claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Extra read-only directory</span>
</span></span><span class="line"><span class="cl">ai-jail --map /opt/datasets claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Want to see what the sandbox is going to do without running anything?</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --dry-run --verbose claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>It shows every mount point, every isolation flag, the full bubblewrap command. No surprises.</p>
<h2>Why Bubblewrap on Linux<span class="hx:absolute hx:-mt-20" id="why-bubblewrap-on-linux"></span>
    <a href="#why-bubblewrap-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I evaluated the alternatives before deciding. The <a href="https://github.com/akitaonrails/ai-jail/blob/master/docs/sandbox-alternatives.md"target="_blank" rel="noopener">full analysis document</a> is in the repository, but the short version is:</p>
<p>Bubblewrap (bwrap) is the sandbox Flatpak uses to isolate every desktop app. ~50KB binary, ~4000 lines of C, maintained by the GNOME team. It runs without root using <code>CLONE_NEWUSER</code> to create namespaces without elevated privileges. It&rsquo;s packaged in every relevant Linux distro and tested at scale by millions of Flatpak installations.</p>
<p>I considered and rejected the alternatives. Firejail requires setuid root, and trusting setuid to protect against agents already running on your system is contradictory. nsjail and minijail are designed for production environments (Google uses them internally), too complex for a dev workstation. systemd-nspawn requires root and is meant for system containers, not for isolating a single process.</p>
<p>Landlock is a different case. It doesn&rsquo;t replace bubblewrap — it has nothing to do with namespaces or mount isolation. But it complements. Landlock is a Linux Security Module that controls access at the VFS level, independent of mount namespaces. That closes vectors bwrap alone doesn&rsquo;t cover: escape paths through <code>/proc</code>, symlink tricks inside permitted mounts, and it serves as a safety net against bugs in the namespace machinery itself. As of v0.4.0, ai-jail applies Landlock automatically on kernels 5.13+ as defense-in-depth. It uses ABI V3 (Linux 6.2+) with graceful degradation to V1 on older kernels, and turns into a silent no-op if the kernel doesn&rsquo;t support it. If it causes problems with some specific tool, <code>--no-landlock</code> turns it off.</p>
<p>Bubblewrap hits the exact sweet spot: real isolation without root, on every distro, and simple enough to wrap in an 880KB binary.</p>
<h2>What the Sandbox Does on Linux<span class="hx:absolute hx:-mt-20" id="what-the-sandbox-does-on-linux"></span>
    <a href="#what-the-sandbox-does-on-linux" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>When you run <code>ai-jail claude</code>, this is what happens:</p>
<p>The agent runs in isolated PID, UTS and IPC namespaces, with hostname <code>ai-sandbox</code>, and dies automatically if the parent dies (<code>--die-with-parent</code>).</p>
<p>The filesystem is mounted in a specific sequence (bubblewrap is order-dependent). <code>/usr</code>, <code>/etc</code>, <code>/opt</code>, <code>/sys</code> come in read-only for system tools. <code>/dev</code> and <code>/proc</code> are mounted for device and process access. <code>/tmp</code> and <code>/run</code> come in as fresh tmpfs. GPUs auto-detected (<code>/dev/nvidia*</code>, <code>/dev/dri</code>). Docker socket, X11/Wayland, <code>/dev/shm</code>, all auto-detected and mounted if they exist.</p>
<p>The most important part is how the home directory is handled. <code>$HOME</code> is mounted as an empty tmpfs. Then, selectively, dotfiles get layered on top. <code>.gnupg</code>, <code>.aws</code>, <code>.ssh</code>, <code>.mozilla</code>, <code>.sparrow</code> are never mounted (sensitive data). <code>.claude</code>, <code>.crush</code>, <code>.codex</code>, <code>.aider</code>, <code>.config</code>, <code>.cargo</code>, <code>.cache</code>, <code>.docker</code> come in as read-write because the agents need to write here. Everything else comes in read-only. Inside <code>~/.config</code>, browser subdirectories are hidden behind tmpfs: <code>BraveSoftware</code>, <code>Bitwarden</code>. Same in <code>~/.cache</code>: <code>BraveSoftware</code>, <code>chromium</code>, <code>spotify</code>, <code>nvidia</code>. The agent can&rsquo;t even see those directories exist.</p>
<p>The current project directory is the only place with write permission (besides the tool dotdirs). The agent modifies the code, but doesn&rsquo;t touch anything else.</p>
<h2>macOS: sandbox-exec<span class="hx:absolute hx:-mt-20" id="macos-sandbox-exec"></span>
    <a href="#macos-sandbox-exec" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>On macOS, the backend is <code>sandbox-exec</code> with SBPL (Sandbox Profile Language) profiles. It&rsquo;s a legacy Apple API, officially deprecated but with no public replacement. It works today, but Apple may remove it in the future.</p>
<p>ai-jail generates an SBPL profile at runtime that mirrors the same logic as Linux:</p>
<ul>
<li>Default deny on everything</li>
<li>Allows process operations (exec, fork, signal)</li>
<li>Allows network (except in lockdown)</li>
<li>Allows global reads, denies sensitive paths (<code>.gnupg</code>, <code>.aws</code>, <code>.ssh</code>, <code>~/Library/Keychains</code>, <code>~/Library/Mail</code>)</li>
<li>Allows writes only in the project directory, tool dotfiles, and <code>/tmp</code></li>
</ul>
<p>The limitations are real. GPU (Metal) and Display (Cocoa) are system-level on macOS, sandbox-exec can&rsquo;t restrict them. The <code>--no-gpu</code> and <code>--no-display</code> flags simply have no effect on macOS. Cross-platform parity is approximate, not exact.</p>
<p>Even with those limitations, it&rsquo;s better than running the agent completely open. sandbox-exec protects against access to sensitive filesystem areas and, in lockdown, removes write and network permissions.</p>
<h2>Windows: Not Supported (And Probably Never Will Be)<span class="hx:absolute hx:-mt-20" id="windows-not-supported-and-probably-never-will-be"></span>
    <a href="#windows-not-supported-and-probably-never-will-be" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It&rsquo;s not for lack of interest, it&rsquo;s lack of primitives. Windows has no userspace equivalent to Linux namespaces. No mount API like bubblewrap. AppContainers exist but use a completely different security model, require admin privileges, and mapping bwrap functionality to AppContainers would effectively mean writing another project from scratch.</p>
<p>The Windows answer is WSL 2:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Inside WSL 2 (real Linux kernel)</span>
</span></span><span class="line"><span class="cl">sudo apt install bubblewrap
</span></span><span class="line"><span class="cl">cargo install ai-jail
</span></span><span class="line"><span class="cl"><span class="c1"># or: mise use -g ubi:akitaonrails/ai-jail</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /mnt/c/Users/you/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>WSL 2 runs an actual Linux kernel. Bubblewrap works normally. Windows files are accessible at <code>/mnt/c/</code>. I/O performance is slower across the 9p mount, but it works. For large projects, cloning inside <code>~/Projects/</code> on the Linux side improves performance considerably.</p>
<h2>Lockdown Mode<span class="hx:absolute hx:-mt-20" id="lockdown-mode"></span>
    <a href="#lockdown-mode" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>For workloads you really don&rsquo;t trust, there&rsquo;s <code>--lockdown</code>:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --lockdown bash</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>Lockdown does everything normal mode does, but goes further. The project gets mounted read-only (not read-write). GPU, Docker, Display and mise are disabled. <code>--rw-map</code> and <code>--map</code> flags are ignored. <code>$HOME</code> becomes pure tmpfs, no host dotfiles. On Linux, the network is cut with <code>--unshare-net</code> and the environment is wiped with <code>--clearenv</code>. On macOS, environment variables are wiped and write and network rules are removed from the SBPL.</p>
<p>It&rsquo;s the most restrictive sandbox you can build short of using a VM. Useful for auditing third-party code or running agents on projects you don&rsquo;t know.</p>
<h2>Bootstrap: Automatic Permission Configuration<span class="hx:absolute hx:-mt-20" id="bootstrap-automatic-permission-configuration"></span>
    <a href="#bootstrap-automatic-permission-configuration" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p><code>ai-jail --bootstrap</code> generates permission configurations for the tools you use:</p>
<div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ai-jail --bootstrap</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>For <strong>Claude Code</strong>, it generates <code>~/.claude/settings.json</code> with allow/deny/ask lists. Allows git status, diff, log, ls, grep, cargo, npm, python, docker compose. Blocks rm -rf, sudo, chmod 777, git push &ndash;force. Asks before git push, rm, docker run.</p>
<p>For <strong>Codex</strong>, it generates <code>~/.codex/config.toml</code> with <code>approval_policy = &quot;on-request&quot;</code>.</p>
<p>For <strong>OpenCode</strong>, it generates <code>~/.config/opencode/opencode.json</code> with bash, edit, write permissions.</p>
<p>Before overwriting any file, it makes an automatic backup (<code>settings.json.bak</code>). And it rejects operations if the target is a symlink, to avoid path traversal.</p>
<p>It&rsquo;s exactly the content I put in manually in the <a href="/en/2026/01/10/ai-agents-locking-down-your-system/">previous post</a>, but automated and tested.</p>
<h2>But Claude Code Already Has Its Own Sandbox<span class="hx:absolute hx:-mt-20" id="but-claude-code-already-has-its-own-sandbox"></span>
    <a href="#but-claude-code-already-has-its-own-sandbox" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>It does. Since October 2025, Claude Code has offered a runtime sandbox via the <code>/sandbox</code> command. And guess what it uses underneath? Bubblewrap on Linux and sandbox-exec on macOS. The same stack.</p>
<p>But the differences matter.</p>
<p>ai-jail is tool-agnostic. It works with Claude, Codex, OpenCode, Crush, and any command. Claude&rsquo;s sandbox only protects Claude. If tomorrow you switch agents, ai-jail keeps working the same.</p>
<p>The thing that bothers me most is the escape hatch. When a command fails because of a sandbox restriction, Claude can retry with <code>dangerouslyDisableSandbox</code>, falling back to the normal permission flow. You can disable that (<code>&quot;allowUnsandboxedCommands&quot;: false</code>), but it&rsquo;s opt-out, not opt-in. In ai-jail, there is no escape hatch. The process runs inside bwrap or sandbox-exec, period. There&rsquo;s no way for the agent to decide on its own to leave the sandbox.</p>
<p>Another practical difference: <code>.ai-jail</code> lives in the project directory and can be committed to the repo. Any dev who clones the project inherits the same sandbox policy. Claude&rsquo;s sandbox depends on a global <code>settings.json</code>.</p>
<p>When run inside Docker, Claude&rsquo;s sandbox falls back to an <code>enableWeakerNestedSandbox</code> mode that, in the words of its own documentation, <em>&ldquo;considerably weakens security&rdquo;</em>. ai-jail wasn&rsquo;t designed to run inside Docker (it runs directly on the dev&rsquo;s workstation), so this problem doesn&rsquo;t exist.</p>
<p>About the network: Claude&rsquo;s sandbox routes traffic through a proxy and allows/blocks by domain. ai-jail in normal mode inherits the host network; in lockdown, it cuts the entire network with <code>--unshare-net</code>. Claude&rsquo;s approach is more granular; ai-jail&rsquo;s is simpler and harder to circumvent.</p>
<p>The two aren&rsquo;t mutually exclusive. You can run Claude&rsquo;s sandbox inside ai-jail. ai-jail handles filesystem isolation; Claude&rsquo;s sandbox adds per-domain network filtering. Security layers stack.</p>
<h2>Why Not Use &ndash;dangerously-skip-permissions Without a Jail<span class="hx:absolute hx:-mt-20" id="why-not-use-dangerously-skip-permissions-without-a-jail"></span>
    <a href="#why-not-use-dangerously-skip-permissions-without-a-jail" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>I&rsquo;ll be blunt: if you run Claude Code with <code>--dangerously-skip-permissions</code> without any sandbox, you&rsquo;re trusting blindly that the LLM will never execute anything destructive. And you&rsquo;re trusting that none of your project&rsquo;s dependencies has been compromised in a supply-chain attack.</p>
<p>Every <code>--dangerously</code> flag has that name for a reason. Claude Code is explicit: that mode exists for CI/CD and automation in environments that are already isolated (containers, throwaway VMs). Not for your personal workstation with <code>~/.aws/credentials</code>, <code>~/.gnupg/</code>, SSH keys, and your browser&rsquo;s password vault.</p>
<p>With ai-jail, the agent has total autonomy inside the sandbox. It does whatever it wants in the project directory, uses the tools it needs, and can&rsquo;t access anything outside what was explicitly permitted.</p>
<h2>ai-jail + Git: The Safety Net You Already Have<span class="hx:absolute hx:-mt-20" id="ai-jail--git-the-safety-net-you-already-have"></span>
    <a href="#ai-jail--git-the-safety-net-you-already-have" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>There&rsquo;s something I haven&rsquo;t mentioned yet that changes the risk calculus: if your project is in a Git repo, with a remote on GitHub/GitLab, and the agent doesn&rsquo;t have permission to <code>git push</code>, the damage it can cause is limited to the local directory.</p>
<p>Think about it. The worst-case scenario inside ai-jail is the agent corrupting every file in the project. Annoying? Sure. Catastrophic? No. You run <code>git checkout .</code> and you&rsquo;re back to the last commit. If it corrupts <code>.git</code> somehow (unlikely, but possible), you delete the directory and clone again. The remote was never touched.</p>
<p>That&rsquo;s why ai-jail&rsquo;s <code>--bootstrap</code> puts <code>git push</code> on the &ldquo;ask&rdquo; list (ask before running), not the &ldquo;allow&rdquo; list. And <code>git push --force</code> goes straight to &ldquo;deny&rdquo;. The agent can commit locally all it wants, can create branches, can rebase. None of that affects the remote. When it comes time to push, you review what it did and decide whether it goes live.</p>
<p>That combination, sandbox for the filesystem + Git for the code + manual push, already gives you a very reasonable security level for daily use. ai-jail protects your personal data and the system. Git protects your code. And the decision to publish stays yours.</p>
<p>If you want to go further, the next two sections cover additional layers.</p>
<h2>ai-jail vs Dev Containers<span class="hx:absolute hx:-mt-20" id="ai-jail-vs-dev-containers"></span>
    <a href="#ai-jail-vs-dev-containers" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Since I wrote ai-jail, the most frequent question is: <em>&ldquo;why not use Dev Containers?&rdquo;</em>. The short answer is that one doesn&rsquo;t replace the other. They solve different problems.</p>
<p>Dev Containers (the <a href="https://containers.dev"target="_blank" rel="noopener">containers.dev</a> spec) define a complete development environment in a <code>devcontainer.json</code>. You specify base image, tools, VS Code extensions, environment variables, and the editor mounts everything for you in a Docker container. Docker also recently launched Docker Sandboxes, which go further and run each agent in a microVM with Firecracker, with hardware isolation.</p>
<p>ai-jail does none of that. It doesn&rsquo;t define an environment. It doesn&rsquo;t install tools. It doesn&rsquo;t run a Docker image. It takes the environment that already exists on your machine and restricts what the process can access.</p>
<p>In practice, the difference is:</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>Dev Container</th>
          <th>ai-jail</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>What it does</td>
          <td>Defines and provisions a complete isolated environment</td>
          <td>Restricts process access to the existing filesystem</td>
      </tr>
      <tr>
          <td>Setup</td>
          <td><code>devcontainer.json</code> + Docker</td>
          <td><code>.ai-jail</code> TOML + bubblewrap</td>
      </tr>
      <tr>
          <td>Startup</td>
          <td>Seconds (image pull, container build)</td>
          <td>Milliseconds (fork + exec of bwrap)</td>
      </tr>
      <tr>
          <td>Tools</td>
          <td>Whatever you put in the image</td>
          <td>Whatever&rsquo;s already installed on your machine</td>
      </tr>
      <tr>
          <td>GPU</td>
          <td>Requires NVIDIA Container Toolkit configuration</td>
          <td>Auto-detects <code>/dev/nvidia*</code> and <code>/dev/dri</code></td>
      </tr>
      <tr>
          <td>Daemon</td>
          <td>Requires Docker daemon running</td>
          <td>Nothing besides bwrap</td>
      </tr>
      <tr>
          <td>Reproducibility</td>
          <td>High (fixed image)</td>
          <td>Depends on what&rsquo;s installed on the host</td>
      </tr>
      <tr>
          <td>Network isolation</td>
          <td>Docker Sandboxes: per-domain firewall</td>
          <td>Lockdown: cuts everything with <code>--unshare-net</code></td>
      </tr>
  </tbody>
</table>
<p>Dev Container makes more sense when you need the whole team to have exactly the same environment, or when the project has dependencies nobody wants to install on the host, or for running non-interactive agents in CI/CD. Docker Sandboxes with microVM are the strongest isolation that exists outside a dedicated VM.</p>
<p>ai-jail makes more sense when you already have the environment configured and want instant startup with no Docker daemon. Or when you use tools that are annoying to run inside a container (CUDA, Wayland, mise). Or simply when you want the same protection for any agent, not just the ones with devcontainer integration.</p>
<p>And you can combine them. I know people who run ai-jail inside a Dev Container to get environment reproducibility + filesystem restriction. Security layers stack.</p>
<h2>Immutable Operating Systems: The Last Layer<span class="hx:absolute hx:-mt-20" id="immutable-operating-systems-the-last-layer"></span>
    <a href="#immutable-operating-systems-the-last-layer" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>If you want to take security seriously, the third layer is the operating system itself.</p>
<p>Immutable systems like <a href="https://fedoraproject.org/atomic-desktops/silverblue/"target="_blank" rel="noopener">Fedora Silverblue</a>, <a href="https://nixos.org/"target="_blank" rel="noopener">NixOS</a>, and <a href="https://aeondesktop.github.io/"target="_blank" rel="noopener">openSUSE Aeon</a> have a read-only root filesystem. The base system can&rsquo;t be modified by any process, even with root. Updates are atomic: they either apply completely or not at all. And if something goes wrong, you roll back to the previous image in one reboot.</p>
<p>In practice, that means even if an AI agent escaped the sandbox (exploiting a kernel vulnerability, for example), it couldn&rsquo;t modify the system persistently. On the next reboot, the system returns to the declared state. On NixOS, the entire system is defined by a configuration file (<code>configuration.nix</code>). On Silverblue, the base is an OSTree image that gets atomic updates via <code>rpm-ostree</code>.</p>
<p>For developers, the catch is: your dev tools run in containers (Toolbox/Distrobox on Silverblue, <code>nix-shell</code> on NixOS). The base system stays untouched. Desktop apps come via Flatpak, which already runs in a sandbox. The result is that the host&rsquo;s attack surface is minimal.</p>
<p>Fedora Silverblue is probably the most accessible entry point. It&rsquo;s already Fedora underneath, with GNOME, works with hardware Fedora supports, and Toolbox gives you a containerized Fedora Server where you install whatever you want without touching the host. NixOS is more powerful (full reproducibility, declarative rollback), but the learning curve is real.</p>
<p>The full combination looks like this: the immutable OS handles the system (read-only filesystem, atomic updates, one-reboot rollback). ai-jail handles the session (isolated namespace, ephemeral home, sensitive data invisible). And Git handles the code (remote untouched as long as the agent doesn&rsquo;t have push).</p>
<p>None of those layers is perfect on its own. But the attack that punches through all three at the same time — escaping the namespace, persisting on a read-only filesystem, and corrupting a Git remote — is a scenario I&rsquo;d be comfortable calling unlikely.</p>
<p>The best part is that none of them requires changing how you work. ai-jail is one command before your agent. Git you already use. And an immutable OS is an installation, not a workflow change.</p>
<h2>Technical Details (For Those Who Care)<span class="hx:absolute hx:-mt-20" id="technical-details-for-those-who-care"></span>
    <a href="#technical-details-for-those-who-care" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>Written in Rust with 124 tests and 4 dependencies: <code>lexopt</code> (CLI parsing without clap), <code>serde</code> + <code>toml</code> (config), <code>nix</code> (Unix syscalls). No async runtime, no color framework (raw ANSI), ~880KB binary with LTO and strip.</p>
<p>Signal handling is done correctly: SIGINT, SIGTERM, and SIGHUP are forwarded to the child process. The handler only calls <code>libc::kill</code>, which is async-signal-safe. Process reaping uses <code>waitpid</code> in a loop with retry on EINTR.</p>
<p>Temporary files (like the custom <code>/etc/hosts</code> the sandbox mounts) use RAII: a <code>SandboxGuard</code> that implements <code>Drop</code> in Rust. If the parent process dies for any reason, cleanup happens.</p>
<p>Configuration compatibility is guaranteed by development policy: never remove fields, never rename, new fields always with <code>#[serde(default)]</code>, unknown fields are silently ignored. Regression tests for old <code>.ai-jail</code> formats guarantee that updating the binary never breaks existing configs. There are 32 tests just for config.</p>
<h2>Roadmap<span class="hx:absolute hx:-mt-20" id="roadmap"></span>
    <a href="#roadmap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>What&rsquo;s left:</p>
<ul>
<li>More tool backends in bootstrap: Aider, Cursor, Windsurf. As more agents standardize permission configuration files.</li>
<li>Profile sharing for monorepos, so you don&rsquo;t have to configure each service separately.</li>
</ul>
<h2>Installation and First Use (Quick Recap)<span class="hx:absolute hx:-mt-20" id="installation-and-first-use-quick-recap"></span>
    <a href="#installation-and-first-use-quick-recap" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><div class="hextra-code-block hx:relative hx:mt-6 hx:first:mt-0 hx:group/code">

<div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Install</span>
</span></span><span class="line"><span class="cl">brew tap akitaonrails/tap <span class="o">&amp;&amp;</span> brew install ai-jail
</span></span><span class="line"><span class="cl"><span class="c1"># or: cargo install ai-jail</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. On Linux, install bubblewrap</span>
</span></span><span class="line"><span class="cl">sudo pacman -S bubblewrap  <span class="c1"># Arch</span>
</span></span><span class="line"><span class="cl"><span class="c1"># sudo apt install bubblewrap  # Debian/Ubuntu</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Enter the project and run</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/Projects/my-app
</span></span><span class="line"><span class="cl">ai-jail claude
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 4. (Optional) Generate permission configs</span>
</span></span><span class="line"><span class="cl">ai-jail --bootstrap
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 5. (Optional) See what the sandbox does</span>
</span></span><span class="line"><span class="cl">ai-jail --dry-run --verbose claude</span></span></code></pre></div></div><div class="hextra-code-copy-btn-container hx:opacity-0 hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 hx:top-0">
  <button
    class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
    title="Copy code"
  >
    <div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
  </button>
</div>
</div>
<p>The <code>.ai-jail</code> file it creates can be committed to your repo. From then on, any dev who clones the project runs <code>ai-jail claude</code> and gets the same sandbox.</p>
<h2>Conclusion<span class="hx:absolute hx:-mt-20" id="conclusion"></span>
    <a href="#conclusion" class="subheading-anchor" aria-label="Permalink for this section"></a></h2><p>January&rsquo;s shell script solved the problem. ai-jail solves the problem properly. Per-project config, macOS support, lockdown mode, permission bootstrap, dry-run for auditing, and an 880KB binary that installs in 10 seconds.</p>
<p>If you use AI agents to code, run them in a sandbox. The LLM&rsquo;s good intentions are no guarantee of anything, and supply-chain attacks don&rsquo;t pick their victims. Process isolation is the barrier that works.</p>
<p>The project is GPL-3.0 and is on GitHub: <a href="https://github.com/akitaonrails/ai-jail"target="_blank" rel="noopener">github.com/akitaonrails/ai-jail</a></p>
<p>Issues and PRs are welcome.</p>
]]></content:encoded><category>ai-jail</category><category>coding-agents</category><category>security</category><category>rust</category></item></channel></rss>