<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: johnohhh1</title>
    <description>The latest articles on DEV Community by johnohhh1 (@johnohhh1).</description>
    <link>https://dev.to/johnohhh1</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1381778%2F2f75661f-ad1a-4ed6-ab21-691b1918b382.png</url>
      <title>DEV Community: johnohhh1</title>
      <link>https://dev.to/johnohhh1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/johnohhh1"/>
    <language>en</language>
    <item>
      <title>I built an MCP server for glucose data that refuses to lie</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Sat, 07 Nov 2026 00:05:27 +0000</pubDate>
      <link>https://dev.to/johnohhh1/i-built-an-mcp-server-for-glucose-data-that-refuses-to-lie-2e7i</link>
      <guid>https://dev.to/johnohhh1/i-built-an-mcp-server-for-glucose-data-that-refuses-to-lie-2e7i</guid>
      <description>&lt;p&gt;Ask an AI assistant for a glucose reading through most integrations and you'll get a number back. &lt;em&gt;132 mg/dL.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Great. From when?&lt;/p&gt;

&lt;p&gt;Thirty seconds ago? Two hours ago, before the phone lost signal? Is "the last 7 days" actually seven days of data, or the twelve hours the API happened to return? When a language model gets a bare number, it does the natural thing: it treats the number as current and complete, and it sounds confident about it.&lt;/p&gt;

&lt;p&gt;For most data that's an annoyance. For continuous glucose monitor (CGM) data, it's the whole problem.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/johnohhh1/librelink-mcp-rs" rel="noopener noreferrer"&gt;&lt;strong&gt;librelink-mcp-rs&lt;/strong&gt;&lt;/a&gt;: a local &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; server, in Rust, that reads FreeStyle Libre data from a LibreLinkUp follower account. Its one design rule is that &lt;strong&gt;every answer says how fresh, how complete, and how trustworthy it is&lt;/strong&gt;. It's MIT licensed and at 1.0 now.&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/johnohhh1" rel="noopener noreferrer"&gt;
        johnohhh1
      &lt;/a&gt; / &lt;a href="https://github.com/johnohhh1/librelink-mcp-rs" rel="noopener noreferrer"&gt;
        librelink-mcp-rs
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Privacy-conscious Rust MCP server for LibreLinkUp sharing data, with eight compatible tools and honest data freshness.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;librelink-mcp-rs&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Give your AI assistant honest, private access to FreeStyle Libre glucose data.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A local &lt;a href="https://modelcontextprotocol.io/" rel="nofollow noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt; server, written in Rust, that reads a LibreLinkUp follower account and answers with readings that say exactly how fresh, how complete, and how trustworthy they are.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/johnohhh1/librelink-mcp-rs/actions/workflows/ci.yml" rel="noopener noreferrer"&gt;&lt;img src="https://github.com/johnohhh1/librelink-mcp-rs/actions/workflows/ci.yml/badge.svg" alt="CI"&gt;&lt;/a&gt;
&lt;a href="https://github.com/johnohhh1/librelink-mcp-rs/releases/latest" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/c24e6717ae23f946fa668c9b93175f3f28615cc09fddb22a8eb9b15aa50f54d3/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f762f72656c656173652f6a6f686e6f686868312f6c696272656c696e6b2d6d63702d7273" alt="Release"&gt;&lt;/a&gt;
&lt;a href="https://www.rust-lang.org/" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/82c0e212e26fbfcfa621b59da350d9554f6e0687762c1bb1cb6beaab8dc8b03c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f527573742d312e38392532422d4445413538343f6c6f676f3d72757374266c6f676f436f6c6f723d7768697465" alt="Rust 1.89+"&gt;&lt;/a&gt;
&lt;a href="https://github.com/johnohhh1/librelink-mcp-rs/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/b8cadaa967891081f8f165695470689986c028821dd8a040132f6e661795dc0d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c7565" alt="MIT license"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-alert markdown-alert-important"&gt;
&lt;p class="markdown-alert-title"&gt;Important&lt;/p&gt;
&lt;p&gt;Unofficial and not affiliated with Abbott. It describes data; it does not diagnose or give treatment advice. Use a LibreLinkUp &lt;strong&gt;follower&lt;/strong&gt; account, and enter credentials only in the local &lt;code&gt;setup&lt;/code&gt; prompt, never in a chat.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Why this one&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It never pretends.&lt;/strong&gt; A stale reading is labeled &lt;code&gt;stale&lt;/code&gt;, not served as live. A 12-hour graph is never passed off as a 7-day summary. A gap stays a gap, with a cause attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It gets the math right.&lt;/strong&gt; Time in range is weighted by time, not by sample count, and GMI appears only when the consensus 14-day, 70%-coverage bar is met. On the validated setup, 14-day…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/johnohhh1/librelink-mcp-rs" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;Here are the five problems that shaped it, and how each one ended up enforced by a test rather than by good intentions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 1: the timestamps don't have timezones
&lt;/h2&gt;

&lt;p&gt;LibreLinkUp is an unofficial, reverse-engineered API. Every reading comes with two clock strings, and neither has an offset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="s2"&gt;"11/5/2026 9:54:32 PM"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"FactoryTimestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"11/6/2026 2:54:32 AM"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The community consensus is that &lt;code&gt;Timestamp&lt;/code&gt; is the sharer's phone wall clock and &lt;code&gt;FactoryTimestamp&lt;/code&gt; is UTC. But "community consensus" isn't something I wanted to hard-code silently, because if it's wrong, every freshness calculation is wrong.&lt;/p&gt;

&lt;p&gt;So the server only &lt;em&gt;infers&lt;/em&gt; UTC when the two clocks are consistent with that story. The gap between them has to look like a real timezone offset, and the factory time can't be in the future:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;infer_factory_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NaiveDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;NaiveDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;gap_seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;display&lt;/span&gt;&lt;span class="nf"&gt;.signed_duration_since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.num_seconds&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// Real timezone offsets are quarter-hour aligned and within this range.&lt;/span&gt;
    &lt;span class="c1"&gt;// This is a consistency check, not a timezone assertion from the API.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gap_seconds&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;gap_seconds&lt;/span&gt;&lt;span class="nf"&gt;.abs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;observed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_naive_utc_and_offset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Utc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.then_some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When it passes, the reply says so: &lt;code&gt;time_basis: "factory_utc_inferred"&lt;/code&gt;. When it doesn't, freshness is &lt;code&gt;unknown&lt;/code&gt;, never a guess. I checked the inference against a live account (a −300 minute gap, matching the phone's Eastern time) and against public captures from a UTC+2 summer account, so it holds across two timezones and both DST states.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule that falls out of this:&lt;/strong&gt; fetching a reading never makes it current. Retrieval time and observation time are separate fields, and a cached response keeps its original retrieval time so it can't look fresher than it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: a careless client can lock the account
&lt;/h2&gt;

&lt;p&gt;The first version used an existing Rust crate for the API. Reading its source, I found it would log in again and retry after &lt;em&gt;any&lt;/em&gt; failed request, including a &lt;code&gt;429 Too Many Requests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Think about what that means when an agent fires four tool calls in a row while the API is throttling: eight requests and a stream of fresh logins. That's exactly the pattern that gets an account locked. And this account is a caregiver's window into someone's glucose.&lt;/p&gt;

&lt;p&gt;I replaced the crate with a small owned client (eight endpoints, &lt;code&gt;reqwest&lt;/code&gt; plus rustls) behind a &lt;code&gt;DataSource&lt;/code&gt; trait. It logs in only when there's no valid session, or once after a &lt;code&gt;401&lt;/code&gt;. Nothing else triggers a login. The core guarantee is a test that counts logins against a mock server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[tokio::test]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;throttling_never_triggers_a_login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;MockServer&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;mount_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;in_one_hour&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GRAPH_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;ResponseTemplate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="nf"&gt;.read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="nf"&gt;.unwrap_err&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="py"&gt;.code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"rate_limited"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nd"&gt;assert_eq!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;logins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every failure test in the suite asserts the login count, not just the error code.&lt;/p&gt;

&lt;p&gt;Some errors can't be fixed by retrying at all, so retrying them only costs requests. Those &lt;strong&gt;latch until restart&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;latches_until_restart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;FetchError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;matches!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="py"&gt;.code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"authentication_failed"&lt;/span&gt;
            &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"client_version_rejected"&lt;/span&gt;
            &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"request_rejected"&lt;/span&gt;
            &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"patient_selection_changed"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The background collector exits with status 78 on a latched error, and the shipped systemd unit sets &lt;code&gt;RestartPreventExitStatus=78&lt;/code&gt;. So a wrong password produces one failed login, not one every five minutes until Abbott locks the account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: "the last 7 days" from a 12-hour API
&lt;/h2&gt;

&lt;p&gt;The LibreLinkUp graph covers about twelve hours. Ask the naive version for weekly stats and you get twelve hours of numbers wearing a "7 days" label.&lt;/p&gt;

&lt;p&gt;Two fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, coverage is always reported.&lt;/strong&gt; Every summary carries its first and last observation, span, cadence, and gaps. If the data doesn't reach the edges of the requested window, the reply says &lt;code&gt;period_status: "partial"&lt;/code&gt;. Every interior gap gets a cause:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no_data_from_api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The collector polled during the gap, and the API had nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no_data_in_export&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The device's own exported history has nothing there (a sensor change, say)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;before_collection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before collection started; the cause is unknown, and the label says so&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;collector_not_running&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nobody was watching&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That third one came from a live test. The first version called a gap in the backfilled history &lt;code&gt;collector_not_running&lt;/code&gt;, which sounds authoritative but was simply wrong: the collector hadn't started yet, so it couldn't know why that data was missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, real history.&lt;/strong&gt; An opt-in &lt;code&gt;collect&lt;/code&gt; command polls once a minute (a 60-second floor enforced in code, plus jitter) into a local SQLite database. &lt;code&gt;import-csv&lt;/code&gt; backfills older history from a LibreView export, offline. While the collector runs, &lt;code&gt;serve&lt;/code&gt; answers every tool from the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  LibreLinkUp API ──collect (60 s)──▶ ┌──────────────┐
  LibreView CSV  ───import-csv──────▶ │ local SQLite │──read-only──▶ serve ◀──stdio──▶ MCP client
                                      └──────────────┘                ▲
  LibreLinkUp API ·········· live fallback when no collector ·········┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only one process ever talks to Abbott. I verified that by running &lt;code&gt;serve&lt;/code&gt; under &lt;code&gt;strace&lt;/code&gt;: across every tool call, zero &lt;code&gt;AF_INET&lt;/code&gt; connections. Every reply carries &lt;code&gt;data_source: "local_store"&lt;/code&gt; or &lt;code&gt;"live_api"&lt;/code&gt;, so the model knows which world it's in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: counting samples quietly skews the statistics
&lt;/h2&gt;

&lt;p&gt;With the collector running, the database mixes two densities: graph points every five minutes, and current readings every minute. Compute "percent of readings in range" over that, and the most recent hour, the one with minute-by-minute data, outweighs everything else.&lt;/p&gt;

&lt;p&gt;The fix is to weight by time, not by count. Each reading stands for the time until the next one, capped at 15 minutes so a gap is never bridged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_SAMPLE_MINUTES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;timed&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;.map&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timed&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.map_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;|(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)|&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;until&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.num_seconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test that guards it builds twelve hours of in-range readings every 15 minutes, plus one hour of minute-by-minute high readings. By sample count, "high" is over half the data. By time, it's 1/13. Only the second number describes reality.&lt;/p&gt;

&lt;p&gt;From there the server reports the five international consensus ranges by time, plus a time-weighted mean, SD and CV. GMI (an estimate of A1C) is gated the way the &lt;a href="https://pmc.ncbi.nlm.nih.gov/articles/PMC6973648/" rel="noopener noreferrer"&gt;consensus paper&lt;/a&gt; recommends: a window of at least 14 days with at least 70% coverage, or you get &lt;code&gt;null&lt;/code&gt; and a reason (&lt;code&gt;window_too_short&lt;/code&gt;, &lt;code&gt;coverage_too_low&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The acceptance test I'm proudest of:&lt;/strong&gt; after importing a real 14-day LibreView export, I compared the server's figures with the AGP report LibreView itself generated from the same data. Every range band agreed within 1 percentage point, the mean within 1 mg/dL, CV within 0.1, and coverage within half a point. The vendor's own report was the test oracle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: privacy you can actually test
&lt;/h2&gt;

&lt;p&gt;"We take privacy seriously" isn't a property. These are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credentials&lt;/strong&gt; live only in the OS credential store (Keychain, Credential Manager, Secret Service). No MCP tool accepts a password; there's a test that &lt;code&gt;configure_credentials&lt;/code&gt; rejects a password argument without echoing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The patient&lt;/strong&gt; is stored as a salted SHA-256 pseudonym, with a random salt per database. A test plants a recognizable patient ID and byte-searches the database, &lt;em&gt;including the WAL and shared-memory sidecar files&lt;/em&gt;, to prove it never lands on disk. The sidecars are where a leak would actually hide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSV import&lt;/strong&gt; reads only the timestamp, record type, and glucose columns. Insulin, carbs, notes, and the name line at the top of the export never make it into storage or logs. A fixture fills those columns with marker strings, and the test asserts none of them appear anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors&lt;/strong&gt; use fixed messages and public codes. A test harness plants markers in fake credentials and fake server responses, drives every error path, and asserts no marker reaches stdout or stderr. A panic prints a fixed message with no payload unless you opt in.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it was tested
&lt;/h2&gt;

&lt;p&gt;A few habits did most of the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic data only, in the repo.&lt;/strong&gt; No real readings in fixtures, issues, or history. Real data only touched the live checks on my own machine, and those were recorded as "what was checked", never as values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutation checks for the important tests.&lt;/strong&gt; For every key test, deliberately break the code it guards, confirm the test fails, and restore. That caught tests that passed for the wrong reason more than once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A live check before every release.&lt;/strong&gt; Each version was smoke-tested against a real account before tagging, and that caught real bugs: a gap mislabeled on backfilled data, and a &lt;code&gt;serve&lt;/code&gt; that would have quietly migrated the database out from under a running older collector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boring CI, on purpose.&lt;/strong&gt; Linux, macOS and Windows, the minimum supported Rust (1.89), and &lt;code&gt;cargo-deny&lt;/code&gt; for advisories and licenses. A Windows-only test failure turned out to be the test reading the clock twice; the fix was to capture the time once per fixture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's 149 tests over about 10,000 lines of Rust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The elephant in the room
&lt;/h2&gt;

&lt;p&gt;This uses an unofficial API. Abbott's newer LibreLinkUp app encrypts its responses, and the older plain-JSON endpoints this project uses could be shut off.&lt;/p&gt;

&lt;p&gt;The project will &lt;strong&gt;not&lt;/strong&gt; try to circumvent that encryption. It will detect a changed API and say so (&lt;code&gt;api_changed&lt;/code&gt;), and because history lives in your local database, everything you've already collected or imported keeps working if the live API ever goes away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Release binaries are on the &lt;a href="https://github.com/johnohhh1/librelink-mcp-rs/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; for Linux, macOS and Windows, along with a Claude Desktop bundle. Or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--locked&lt;/span&gt; &lt;span class="nt"&gt;--git&lt;/span&gt; https://github.com/johnohhh1/librelink-mcp-rs &lt;span class="nt"&gt;--tag&lt;/span&gt; v1.0.0
librelink-mcp-rs setup    &lt;span class="c"&gt;# local prompt; the password never touches the MCP client&lt;/span&gt;
librelink-mcp-rs check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's validated on one US follower account with a FreeStyle Libre 3. If you're in another region or on another sensor, the &lt;a href="https://github.com/johnohhh1/librelink-mcp-rs/blob/main/docs/compatibility.md" rel="noopener noreferrer"&gt;compatibility guide&lt;/a&gt; shows how to test it and report the result without sharing a single reading. That's the most useful contribution right now.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Unofficial, and not affiliated with or endorsed by Abbott. FreeStyle Libre, LibreLinkUp, and LibreView are Abbott trademarks. This software describes data; it doesn't diagnose or give treatment advice. Thanks to Amandeep Khurana, whose &lt;a href="https://github.com/amansk/librelink-mcp-server" rel="noopener noreferrer"&gt;original LibreLink MCP server&lt;/a&gt; defined the eight tools this one implements.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rust</category>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I spent 6 hours debugging a VM that didn't need to exist</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Thu, 16 Apr 2026 17:07:13 +0000</pubDate>
      <link>https://dev.to/johnohhh1/i-spent-6-hours-debugging-a-vm-that-didnt-need-to-exist-549m</link>
      <guid>https://dev.to/johnohhh1/i-spent-6-hours-debugging-a-vm-that-didnt-need-to-exist-549m</guid>
      <description>&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm trying to get Anthropic's Cowork feature running on Linux. Cowork is officially Mac-only. It works by booting a sandboxed Ubuntu VM inside Claude Desktop, then running Claude Code inside that VM so it can safely read and write your project files.&lt;/p&gt;

&lt;p&gt;Claude Desktop for Linux exists as a community build — I maintain &lt;a href="https://github.com/johnohhh1/claude-desktop-ubuntu" rel="noopener noreferrer"&gt;a deb package&lt;/a&gt; that repackages the official Windows MSIX for Ubuntu. The desktop app works fine. But Cowork doesn't start. The VM service socket never appears.&lt;/p&gt;

&lt;p&gt;So I open Claude Code on my terminal and say: fix this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rabbit hole
&lt;/h2&gt;

&lt;p&gt;What follows is roughly 6 hours of two problem-solvers — me and Claude — locked in a feedback loop where we're both hyperfixated on the same wrong question: &lt;strong&gt;how do we make this VM boot?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's an abbreviated tour of the damage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hour 1:&lt;/strong&gt; We extract the Electron app's &lt;code&gt;app.asar&lt;/code&gt;, find the minified JavaScript, locate the platform check. The Windows version uses a named pipe &lt;code&gt;\\.\pipe\cowork-vm-service&lt;/code&gt;. We patch it to use a Unix socket. We write a custom Node.js service that listens on that socket and spawns QEMU. Claude Desktop connects. It sends &lt;code&gt;startVM&lt;/code&gt;. QEMU boots. The guest agent connects. Progress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hour 2:&lt;/strong&gt; The &lt;code&gt;installSdk&lt;/code&gt; call loops forever. We make it return success locally. Startup completes in 2.5 seconds. Then &lt;code&gt;isProcessRunning&lt;/code&gt; keepalives timeout because the guest agent isn't responding. We discover the guest's &lt;code&gt;sdk-daemon&lt;/code&gt; binary uses vsock, not the virtio serial port we set up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hour 3:&lt;/strong&gt; The &lt;code&gt;sdk-daemon&lt;/code&gt; inside the VM can't find the "smol-bin" disk. It scans for NVMe devices. QEMU's NVMe emulation produces "bogus Namespace Identifiers" that the Ubuntu 22.04 kernel rejects. &lt;code&gt;nvme1n1&lt;/code&gt; never appears as a block device. We try &lt;code&gt;uuid=auto&lt;/code&gt;, &lt;code&gt;nguid=auto&lt;/code&gt;, &lt;code&gt;eui64-default=on&lt;/code&gt;. None of it works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hour 4:&lt;/strong&gt; We try SCSI instead of NVMe. The device appears as &lt;code&gt;/dev/sda&lt;/code&gt; but the sdk-daemon only scans NVMe paths. We try virtio-blk. Same. We discover the "smol-bin" disk is actually just an empty ext4 sessions disk, not the binary distribution we thought. We patch the rootfs with a fake tmpfs mount. The sdk-daemon gets past the smol-bin check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hour 5:&lt;/strong&gt; New error: VirtioFS mount fails. We add &lt;code&gt;virtiofsd&lt;/code&gt; and &lt;code&gt;memory-backend-memfd&lt;/code&gt; to QEMU. The sdk-daemon gets further. It mounts the sessions disk. It starts a MITM proxy. Then: &lt;code&gt;[rpc] connecting to host CID=2 port=51234&lt;/code&gt; — connection refused. We write a Python vsock bridge. It connects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hour 6:&lt;/strong&gt; The bridge works but the sdk-daemon crashes on restart because the fake mounts get torn down. We patch the rootfs systemd units. We patch the wrapper script. We fight with rootfs journal persistence. We're deep in the weeds of a QEMU VM running Ubuntu 22.04 inside Ubuntu 26.04, bridging vsock through Python to Node.js through Unix sockets to Electron, trying to make a Go binary that was compiled for Hyper-V happy inside KVM.&lt;/p&gt;

&lt;p&gt;Then I find &lt;a href="https://github.com/johnzfitch/claude-cowork-linux" rel="noopener noreferrer"&gt;this repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was 3 lines of thinking
&lt;/h2&gt;

&lt;p&gt;The entire README of &lt;code&gt;claude-cowork-linux&lt;/code&gt; by &lt;a href="https://github.com/johnzfitch" rel="noopener noreferrer"&gt;@johnzfitch&lt;/a&gt; can be summarized as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why does Cowork use a VM?&lt;/strong&gt; Because it needs a Linux environment to run Claude Code. On macOS, that means spinning up an Ubuntu VM. On Windows, same thing — Hyper-V boots a Linux guest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But you're already on Linux.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So just... don't boot the VM. Stub out the macOS native module. Run Claude Code directly on the host. Translate the VM paths to real paths. Done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The install took 30 seconds. Cowork started immediately. The spawned Claude Code process ran for 23ms to confirm, then the full session worked.&lt;/p&gt;

&lt;p&gt;Every single thing I spent 6 hours debugging — NVMe namespace identifiers, vsock port discovery, VirtioFS permissions, rootfs systemd patches, Python-to-Node socket bridges — was a consequence of not asking &lt;strong&gt;why&lt;/strong&gt; the VM existed in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;This is a pattern I've seen before but never this clearly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hyperfixation on the error, not the cause.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The error said "VM service not running." So we made the VM service run. Then the error said "guest agent not connected." So we connected the guest agent. Then "smol-bin device not found." So we tried to make the device appear. Every error led to the next error, and we solved each one, and we were making "progress" the entire time.&lt;/p&gt;

&lt;p&gt;But the actual cause was upstream of all of it: &lt;strong&gt;the VM shouldn't exist on Linux in the first place.&lt;/strong&gt; We never questioned the premise. We just tried to make the wrong thing work really, really well.&lt;/p&gt;

&lt;p&gt;And here's the uncomfortable part: Claude Code was the perfect partner for this kind of mistake. It's incredible at "make X work" tasks. Give it an error, it'll fix it. Give it the next error, it'll fix that too. It will reverse-engineer a binary's vsock port by reading Go strings out of an ELF executable at 2 AM and not complain. It's a relentless debugger.&lt;/p&gt;

&lt;p&gt;But relentless debugging is exactly what you don't need when the whole approach is wrong. What you need is someone to say: &lt;em&gt;wait, why are we doing this?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I should have done
&lt;/h2&gt;

&lt;p&gt;Before touching any code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ask what the VM is for.&lt;/strong&gt; It runs Linux so Claude Code has a sandbox. We're on Linux. We don't need the sandbox VM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search for prior art.&lt;/strong&gt; Someone already solved this. 30 seconds of GitHub search would have found it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Question the frame.&lt;/strong&gt; The logs said "VM service not running" and I read that as "I need to make the VM service run." A better read: "this component assumes a VM is needed — is it?"&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;AI coding assistants are force multipliers. If you're pointed in the right direction, they're unbelievable. If you're pointed in the wrong direction, they'll help you dig the most elaborate, well-engineered hole you've ever seen.&lt;/p&gt;

&lt;p&gt;The fix for Cowork on Linux was a JavaScript stub and a path translation layer. We built a QEMU VM orchestrator with NVMe disk emulation, vsock bridging, VirtioFS shared filesystems, and rootfs systemd patching.&lt;/p&gt;

&lt;p&gt;Next time the debugging gets complicated, I'm going to stop and ask: &lt;strong&gt;am I solving the right problem, or am I just solving the next error?&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Credit to &lt;a href="https://github.com/johnzfitch" rel="noopener noreferrer"&gt;@johnzfitch&lt;/a&gt; for &lt;a href="https://github.com/johnzfitch/claude-cowork-linux" rel="noopener noreferrer"&gt;claude-cowork-linux&lt;/a&gt;, which solved in an afternoon what I couldn't solve in a night. The deb package with Cowork support is at &lt;a href="https://github.com/johnohhh1/claude-desktop-ubuntu" rel="noopener noreferrer"&gt;johnohhh1/claude-desktop-ubuntu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>debugging</category>
      <category>ai</category>
      <category>claude</category>
    </item>
    <item>
      <title>I Ported ComfyUI Desktop to Ubuntu 26.04</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Wed, 15 Apr 2026 03:06:51 +0000</pubDate>
      <link>https://dev.to/johnohhh1/i-ported-comfyui-desktop-to-ubuntu-2604-2cb8</link>
      <guid>https://dev.to/johnohhh1/i-ported-comfyui-desktop-to-ubuntu-2604-2cb8</guid>
      <description>&lt;h2&gt;
  
  
  Another Desktop App ported to Linux That was not available. I will Keep going until release day or I run out of apps to port. Let me Know what one you would like to see next!
&lt;/h2&gt;

&lt;p&gt;The official ComfyUI Desktop app ships for Windows and macOS.&lt;/p&gt;

&lt;p&gt;I wanted it on Ubuntu 26.04.&lt;/p&gt;

&lt;p&gt;So I ported it.&lt;/p&gt;

&lt;p&gt;Not a web wrapper. Not a fake launcher. Not a shell script around a browser tab.&lt;/p&gt;

&lt;p&gt;The actual desktop app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;I had two requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;build the real app on Linux&lt;/li&gt;
&lt;li&gt;make it usable on a machine that already runs ComfyUI normally&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second one mattered more than people think.&lt;/p&gt;

&lt;p&gt;If a Linux user already has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a real &lt;code&gt;~/ComfyUI&lt;/code&gt; install&lt;/li&gt;
&lt;li&gt;a running server on &lt;code&gt;127.0.0.1:8188&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then the desktop app should not pretend the machine is blank and try to push a fresh install into &lt;code&gt;~/Documents/ComfyUI&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is bad product behavior, even if the package technically launches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Starting Point
&lt;/h2&gt;

&lt;p&gt;The upstream repo is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Comfy-Org/desktop" rel="noopener noreferrer"&gt;https://github.com/Comfy-Org/desktop&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At first glance it looks cross-platform enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Electron app&lt;/li&gt;
&lt;li&gt;bundled frontend assets&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;uv&lt;/code&gt; bootstrap&lt;/li&gt;
&lt;li&gt;asset build pipeline&lt;/li&gt;
&lt;li&gt;electron-builder config with Linux bits already half-present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But “half-present” is the dangerous state.&lt;/p&gt;

&lt;p&gt;That usually means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;some paths exist&lt;/li&gt;
&lt;li&gt;some assets exist&lt;/li&gt;
&lt;li&gt;some packaging config exists&lt;/li&gt;
&lt;li&gt;and the app still does not actually ship on Linux&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is exactly what was going on here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problems
&lt;/h2&gt;

&lt;p&gt;There were a few distinct failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Linux was gated out of asset/bootstrap flow
&lt;/h3&gt;

&lt;p&gt;The app would build fine for supported platforms, but Linux was not being carried all the way through asset setup and build verification.&lt;/p&gt;

&lt;p&gt;That meant some Linux artifacts were never properly prepared even though the source tree looked close.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Packaged builds were missing &lt;code&gt;desktop-ui&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This was the first hard packaging bug.&lt;/p&gt;

&lt;p&gt;The app launched, then died with a file-not-found error because the packaged build did not contain the desktop onboarding UI bundle.&lt;/p&gt;

&lt;p&gt;That is the kind of bug that makes a port look dead before you even get to runtime behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. AppImage hit Electron sandbox issues
&lt;/h3&gt;

&lt;p&gt;This is the standard Linux Electron tax.&lt;/p&gt;

&lt;p&gt;The AppImage mounted fine, but Chromium tripped over the sandbox helper and aborted before the app could become useful.&lt;/p&gt;

&lt;p&gt;I did not waste time pretending AppImage was the right hill to die on.&lt;/p&gt;

&lt;p&gt;For Ubuntu, &lt;code&gt;.deb&lt;/code&gt; was the better target.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Linux runtime assumptions were wrong
&lt;/h3&gt;

&lt;p&gt;The app still had platform checks and install behavior that were effectively “Windows/macOS first, Linux later.”&lt;/p&gt;

&lt;p&gt;A Linux port cannot just compile. It has to stop acting like Linux is unsupported at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Existing Comfy installs were ignored
&lt;/h3&gt;

&lt;p&gt;This was the most important UX issue.&lt;/p&gt;

&lt;p&gt;On my machine, ComfyUI was already installed and already running in the browser.&lt;/p&gt;

&lt;p&gt;The desktop app still tried to start from scratch and push a managed install path into &lt;code&gt;Documents&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is technically launchable, but functionally wrong.&lt;/p&gt;

&lt;p&gt;So I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Changed
&lt;/h2&gt;

&lt;p&gt;I split the work into build fixes, runtime fixes, and “make it behave like a sane Linux app” fixes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build and packaging fixes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;enabled Linux asset bootstrap&lt;/li&gt;
&lt;li&gt;enabled Linux build verification&lt;/li&gt;
&lt;li&gt;packaged the missing &lt;code&gt;desktop-ui&lt;/code&gt; bundle&lt;/li&gt;
&lt;li&gt;switched the Linux target to &lt;code&gt;.deb&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Runtime fixes
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;added Linux config/reset path handling&lt;/li&gt;
&lt;li&gt;made Linux hardware validation non-fatal&lt;/li&gt;
&lt;li&gt;added fallback behavior for missing compiled requirements&lt;/li&gt;
&lt;li&gt;added Linux-safe Electron startup defaults&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Existing-install behavior
&lt;/h3&gt;

&lt;p&gt;This was the good part.&lt;/p&gt;

&lt;p&gt;I changed the app so it now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prefers an existing &lt;code&gt;~/ComfyUI&lt;/code&gt; directory as the default base path&lt;/li&gt;
&lt;li&gt;detects a live local Comfy server on &lt;code&gt;127.0.0.1:8188&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;persists external-server mode into desktop config&lt;/li&gt;
&lt;li&gt;skips the desktop-managed venv validation path when attaching to that external server&lt;/li&gt;
&lt;li&gt;opens the frontend against the already-running instance instead of forcing first-run install flow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last bit is what makes the port feel real instead of merely “bootable.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;.deb&lt;/code&gt; Won
&lt;/h2&gt;

&lt;p&gt;I know someone will ask why I did not just ship AppImage.&lt;/p&gt;

&lt;p&gt;Because Ubuntu users need something that works, not something ideologically pure.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.deb&lt;/code&gt; route gave me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stable installation under &lt;code&gt;/opt/ComfyUI&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;normal desktop integration&lt;/li&gt;
&lt;li&gt;no AppImage sandbox headache&lt;/li&gt;
&lt;li&gt;a clean launcher path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Linux, packaging format is not theology. It is operational reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the App Does Now
&lt;/h2&gt;

&lt;p&gt;On an Ubuntu 26.04 machine with an existing Comfy setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;launch the desktop app&lt;/li&gt;
&lt;li&gt;it detects &lt;code&gt;~/ComfyUI&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;it sees the live server on &lt;code&gt;127.0.0.1:8188&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;it stores that as the desktop install state&lt;/li&gt;
&lt;li&gt;it attaches instead of onboarding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the behavior I wanted from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commands I Actually Used
&lt;/h2&gt;

&lt;p&gt;Install dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;corepack &lt;span class="nb"&gt;enable
&lt;/span&gt;corepack yarn &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bootstrap app assets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;corepack yarn make:assets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;corepack yarn typecheck
corepack yarn vitest run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;corepack yarn make
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; ./dist/ComfyUI-0.8.30-amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Nice Surprise
&lt;/h2&gt;

&lt;p&gt;The upstream repo was not hopeless.&lt;/p&gt;

&lt;p&gt;This was not one of those situations where “Linux support” secretly meant rewriting the app from scratch.&lt;/p&gt;

&lt;p&gt;Most of the work was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;finishing incomplete Linux paths&lt;/li&gt;
&lt;li&gt;fixing packaging omissions&lt;/li&gt;
&lt;li&gt;picking the right distribution target&lt;/li&gt;
&lt;li&gt;making startup logic respect an existing local Comfy workflow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is still real work, but it is not fantasy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Useful Lesson
&lt;/h2&gt;

&lt;p&gt;A port is not finished when the window appears.&lt;/p&gt;

&lt;p&gt;A port is finished when the app behaves correctly on the target machine.&lt;/p&gt;

&lt;p&gt;For this one, the difference was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;not just “ComfyUI Desktop launches on Ubuntu”&lt;/li&gt;
&lt;li&gt;but “ComfyUI Desktop understands an Ubuntu user who already runs ComfyUI”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between a demo and a port.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upstream and Source
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Upstream desktop app: &lt;a href="https://github.com/Comfy-Org/desktop" rel="noopener noreferrer"&gt;https://github.com/Comfy-Org/desktop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ComfyUI core: &lt;a href="https://github.com/comfyanonymous/ComfyUI" rel="noopener noreferrer"&gt;https://github.com/comfyanonymous/ComfyUI&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  My Repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/johnohhh1/comfyui-desktop-port-linux.git" rel="noopener noreferrer"&gt;https://github.com/johnohhh1/comfyui-desktop-port-linux.git&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/johnohhh1/comfyui-desktop-port-linux.git

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>electron</category>
      <category>ai</category>
    </item>
    <item>
      <title>Kimi Desktop on Ubuntu 26.04: Fixing the Broken .deb with Tauri v2</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Tue, 14 Apr 2026 02:54:43 +0000</pubDate>
      <link>https://dev.to/johnohhh1/kimi-desktop-on-ubuntu-2604-fixing-the-broken-deb-with-tauri-v2-1e25</link>
      <guid>https://dev.to/johnohhh1/kimi-desktop-on-ubuntu-2604-fixing-the-broken-deb-with-tauri-v2-1e25</guid>
      <description>&lt;p&gt;You install the official Kimi desktop &lt;code&gt;.deb&lt;/code&gt;, fire &lt;code&gt;sudo dpkg -i&lt;/code&gt;, and boom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dpkg: dependency problems prevent configuration of kimi:
 kimi depends on libwebkit2gtk-4.0-37; however:
  Package libwebkit2gtk-4.0-37 is not installed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That library doesn't exist on Ubuntu 24.04, let alone 26.04. It was removed from the repos over a year ago. The official &lt;a href="https://github.com/kimi-moonshot/kimi-moonshot" rel="noopener noreferrer"&gt;Kimi desktop package&lt;/a&gt; is built on Tauri v1, which hard-depends on &lt;code&gt;libwebkit2gtk-4.0.so.37&lt;/code&gt; — a library that shipped with webkit2gtk 4.0, superseded by 4.1 and then dropped entirely.&lt;/p&gt;

&lt;p&gt;So the app is just... broken on any modern Ubuntu. Here's how I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem in one sentence
&lt;/h2&gt;

&lt;p&gt;Tauri v1 → &lt;code&gt;libwebkit2gtk-4.0&lt;/code&gt; → removed from Ubuntu 24.04+ → &lt;code&gt;dpkg&lt;/code&gt; fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: rebuild with Tauri v2
&lt;/h2&gt;

&lt;p&gt;Tauri v2 links against &lt;code&gt;libwebkit2gtk-4.1&lt;/code&gt;, which &lt;em&gt;is&lt;/em&gt; the version shipped in Ubuntu 24.04 and 26.04. So the fix is straightforward: rebuild the app with Tauri v2 instead of v1.&lt;/p&gt;

&lt;p&gt;I used &lt;a href="https://github.com/tw93/Pake" rel="noopener noreferrer"&gt;Pake&lt;/a&gt; v3, which wraps any web app into a native desktop app using Tauri under the hood. One build script, one config file, and you get a &lt;code&gt;.deb&lt;/code&gt; that actually installs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tauri v2 runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Links against &lt;code&gt;libwebkit2gtk-4.1&lt;/code&gt; — the one Ubuntu actually ships&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OAuth / SSO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--new-window&lt;/code&gt; flag means Google sign-in works in-app instead of being blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;System tray&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Desktop integration that works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1200x780 window&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Matches the original Kimi desktop dimensions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Rebuild it yourself
&lt;/h2&gt;

&lt;p&gt;Prerequisites — Rust, Node, and the usual GTK/webkit dev packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Rust &amp;gt;= 1.85&lt;/span&gt;
curl &lt;span class="nt"&gt;--proto&lt;/span&gt; &lt;span class="s1"&gt;'=https'&lt;/span&gt; &lt;span class="nt"&gt;--tlsv1&lt;/span&gt;.2 &lt;span class="nt"&gt;-sSf&lt;/span&gt; https://sh.rustup.rs | sh

&lt;span class="c"&gt;# Node.js &amp;gt;= 22 — use nvm, brew, whatever you prefer&lt;/span&gt;

&lt;span class="c"&gt;# Build deps&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;libwebkit2gtk-4.1-dev libgtk-3-dev &lt;span class="se"&gt;\&lt;/span&gt;
  libayatana-appindicator3-dev librsvg2-dev

&lt;span class="c"&gt;# Pake CLI&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pake-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it's one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.deb&lt;/code&gt; lands in &lt;code&gt;dist/&lt;/code&gt;. Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg &lt;span class="nt"&gt;-i&lt;/span&gt; dist/kimi_1.0.0_amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. Kimi runs natively on Ubuntu 26.04 with no missing libraries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config that makes it work
&lt;/h2&gt;

&lt;p&gt;Everything lives in &lt;code&gt;config/pake.json&lt;/code&gt;. The important bits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"windows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://kimi.moonshot.cn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"new_window"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;780&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user_agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"linux"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two things that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;new_window: true&lt;/code&gt;&lt;/strong&gt; — Without this, OAuth popups (Google sign-in, etc.) get blocked by the webview's navigation policy. This flag tells Pake/Tauri to open them in a new window instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;user_agent.linux&lt;/code&gt;&lt;/strong&gt; — Spoofing a Chrome UA because some OAuth providers reject webview user agents.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why not just use the web app in a browser?
&lt;/h2&gt;

&lt;p&gt;Fair question. A native desktop app gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alt-Tab separation&lt;/strong&gt; — Kimi isn't buried among 40 browser tabs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System tray&lt;/strong&gt; — Quick access, stays running in the background&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Own window chrome&lt;/strong&gt; — Feels like an app, not a tab&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller memory footprint&lt;/strong&gt; — Tauri uses the system webview, not a bundled Electron instance&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/johnohhh1/kimi-app" rel="noopener noreferrer"&gt;github.com/johnohhh1/kimi-app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clone it, build it, install it. If you're on Ubuntu 24.04+ and want Kimi as a desktop app, this is currently the only way that works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uninstall
&lt;/h2&gt;

&lt;p&gt;If you need to remove it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg &lt;span class="nt"&gt;-r&lt;/span&gt; kimi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Kimi is a product of &lt;a href="https://moonshot.cn" rel="noopener noreferrer"&gt;Moonshot AI&lt;/a&gt;. This project uses the open-source &lt;a href="https://github.com/tw93/Pake" rel="noopener noreferrer"&gt;Pake&lt;/a&gt; tool (MIT license) to wrap the Kimi web interface as a native desktop application.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>tauri</category>
      <category>desktop</category>
    </item>
    <item>
      <title>I Ported the Ollama Desktop App to Linux Just in Time for Ubuntu 26.04 LTS</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Sat, 11 Apr 2026 22:46:54 +0000</pubDate>
      <link>https://dev.to/johnohhh1/i-ported-the-ollama-desktop-app-to-linux-just-in-time-for-ubuntu-2604-lts-4bnp</link>
      <guid>https://dev.to/johnohhh1/i-ported-the-ollama-desktop-app-to-linux-just-in-time-for-ubuntu-2604-lts-4bnp</guid>
      <description>&lt;p&gt;Ubuntu 26.04 LTS drops this week. I've been running the RC for months. And I think it's going to be the release that finally pulls a meaningful chunk of people off Windows — especially AI-curious developers who are tired of the telemetry, the forced updates, and the general feeling that Microsoft is treating them like a product.&lt;/p&gt;

&lt;p&gt;So I've been on a mission: take the cool AI tooling that still assumes Windows or macOS, and make it work natively on Linux.&lt;/p&gt;

&lt;p&gt;This week's project: &lt;strong&gt;the official Ollama desktop app&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; is incredible. Local LLMs, dead simple, fast. But their desktop app — the polished GTK-style chat UI with model management, file attachments, settings, chat history — &lt;strong&gt;officially ships for Windows and macOS only&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every single Go file in the &lt;code&gt;app/&lt;/code&gt; directory of &lt;a href="https://github.com/ollama/ollama" rel="noopener noreferrer"&gt;ollama/ollama&lt;/a&gt; starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;//go:build windows || darwin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Linux users are left running &lt;code&gt;ollama serve&lt;/code&gt; and pointing a browser at &lt;code&gt;localhost:11434&lt;/code&gt;. It works, but it's not the experience.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Discovery
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I did what any good engineer does: I read the source.&lt;/p&gt;

&lt;p&gt;The Ollama desktop app is built on &lt;a href="https://github.com/webview/webview" rel="noopener noreferrer"&gt;webview&lt;/a&gt; — a lightweight C++ library that wraps the platform's native browser engine. On Windows it uses WebView2 (Edge). On macOS it uses WebKit. And on Linux?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// From webview.h, line ~400:&lt;/span&gt;
&lt;span class="cp"&gt;#if defined(WEBVIEW_GTK)
&lt;/span&gt;&lt;span class="c1"&gt;// Full GTK3 + WebKit2GTK implementation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Linux support was already there.&lt;/strong&gt; Fully implemented. Sitting in the header file, waiting. The Go build tags were the only thing blocking it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Port
&lt;/h2&gt;

&lt;p&gt;Six new files. A patch. About 350 lines of Go.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Unlock the build tags
&lt;/h3&gt;

&lt;p&gt;~40 files needed one line changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// before&lt;/span&gt;
&lt;span class="c"&gt;//go:build windows || darwin&lt;/span&gt;

&lt;span class="c"&gt;// after  &lt;/span&gt;
&lt;span class="c"&gt;//go:build windows || darwin || linux&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Wire up the CGo flags
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;app/webview/webview.go&lt;/code&gt; needed Linux-specific build flags to find GTK and WebKit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="n"&gt;cgo&lt;/span&gt; &lt;span class="n"&gt;linux&lt;/span&gt; &lt;span class="n"&gt;CXXFLAGS&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;DWEBVIEW_GTK&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="m"&gt;14&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="n"&gt;cgo&lt;/span&gt; &lt;span class="n"&gt;linux&lt;/span&gt; &lt;span class="n"&gt;LDFLAGS&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ldl&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="n"&gt;cgo&lt;/span&gt; &lt;span class="n"&gt;linux&lt;/span&gt; &lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gtk&lt;/span&gt;&lt;span class="o"&gt;+-&lt;/span&gt;&lt;span class="m"&gt;3.0&lt;/span&gt; &lt;span class="n"&gt;webkit2gtk&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;4.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: Ubuntu 26.04 ships &lt;code&gt;webkit2gtk-4.1&lt;/code&gt; — not &lt;code&gt;4.0&lt;/code&gt;. If you're targeting older distros, check your version.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Platform files
&lt;/h3&gt;

&lt;p&gt;The app architecture expects platform-specific implementations of a handful of interfaces. I wrote six &lt;code&gt;_linux.go&lt;/code&gt; files:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;app_linux.go&lt;/code&gt;&lt;/strong&gt; — GTK window management via CGo, lockfile-based single-instance enforcement, signal cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;handleExistingInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startHidden&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lockPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_CREATE&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_EXCL&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_WRONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="n"&gt;o644&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;slog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"another ollama-app instance detected, exiting"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGHUP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;
        &lt;span class="n"&gt;removeLock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;server_linux.go&lt;/code&gt;&lt;/strong&gt; — The app normally spawns the Ollama daemon itself. On Linux, systemd handles that. So this file is just a health-checker that pings &lt;code&gt;localhost:11434&lt;/code&gt; every 30 seconds and logs if it's unreachable. Never spawns a process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;updater_linux.go&lt;/code&gt;&lt;/strong&gt; — All no-ops. systemd and apt manage updates on Linux.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;filepicker_linux.go&lt;/code&gt;&lt;/strong&gt; — File/folder dialogs via &lt;code&gt;zenity&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;selectDirectory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"zenity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--file-selection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--directory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--title="&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Output&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TrimSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;dialog/dlgs_linux.go&lt;/code&gt;&lt;/strong&gt; — zenity implementations of the app's dialog interface (yes/no, info, error, file pickers).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;webview_linux.go&lt;/code&gt;&lt;/strong&gt; — The Webview struct without the Windows &lt;code&gt;menu.h&lt;/code&gt; CGo baggage, plus all the JS bindings: &lt;code&gt;ready&lt;/code&gt;, &lt;code&gt;close&lt;/code&gt;, &lt;code&gt;drag&lt;/code&gt;, &lt;code&gt;doubleClick&lt;/code&gt;, &lt;code&gt;zoomIn&lt;/code&gt;, &lt;code&gt;zoomOut&lt;/code&gt;, &lt;code&gt;selectFiles&lt;/code&gt;, &lt;code&gt;selectWorkingDirectory&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The one frontend bug
&lt;/h3&gt;

&lt;p&gt;The React sidebar had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isWindows&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/settings"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Settings&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Linux, &lt;code&gt;navigator.platform&lt;/code&gt; returns &lt;code&gt;"Linux x86_64"&lt;/code&gt; — so &lt;code&gt;isWindows&lt;/code&gt; is false and Settings just... doesn't appear. One-line fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isMac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mac&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isMac&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/settings"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Settings&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/johnohhh1/ollama-webchat-ubuntu.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ollama-webchat-ubuntu
bash build.sh
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;build/ollama-app /usr/local/bin/ollama-app
ollama-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A native GTK3 window. WebKit2GTK rendering the full React SPA. Chat history in SQLite. File attachments. Model management. Settings. Dark mode. Wayland-native — no XWayland needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The NVIDIA gotcha
&lt;/h2&gt;

&lt;p&gt;Running an RTX 5070 (Blackwell) with the proprietary driver? You'll see a wall of EGL warnings on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;libEGL warning: egl: failed to create dri2 screen
libEGL warning: pci id for fd 17: 10de:2f04, driver (null)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Ignore them.&lt;/strong&gt; There's no open-source Mesa driver for Blackwell yet — WebKit probes Mesa EGL, fails, and falls back to Cairo software rendering for the compositor. For a chat UI it's completely imperceptible. Page layout and text were always CPU-rendered anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Ubuntu 26.04 changes things
&lt;/h2&gt;

&lt;p&gt;26.04 LTS is the first Ubuntu release with X11 removed entirely — Wayland only. GNOME 50. The desktop finally feels like a first-class target, not an afterthought.&lt;/p&gt;

&lt;p&gt;At the same time, the local AI tooling ecosystem has exploded. Ollama, LM Studio, Open WebUI, llama.cpp — these tools are genuinely good, and they mostly work on Linux. The desktop app experience is just lagging behind.&lt;/p&gt;

&lt;p&gt;That's the gap I'm trying to close. If you're a developer making the jump from Windows to Ubuntu 26.04 this week, you shouldn't have to give up your tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/johnohhh1/ollama-webchat-ubuntu" rel="noopener noreferrer"&gt;github.com/johnohhh1/ollama-webchat-ubuntu&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PRs welcome — especially if you test on Fedora, Arch, or Ubuntu 24.04. And if you're building other AI tools that are stuck behind Windows/macOS build tags, the pattern here is reusable.&lt;/p&gt;

&lt;p&gt;The C library usually already supports Linux. The build tags are almost always the only thing in the way.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>ubuntu</category>
      <category>linux</category>
      <category>go</category>
    </item>
    <item>
      <title>Anthropic Limited Claude Subscription Usage for Third-Party Harnesses. So We Ported NanoClaw to Codex</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Thu, 09 Apr 2026 19:01:29 +0000</pubDate>
      <link>https://dev.to/johnohhh1/anthropic-limited-claude-subscription-usage-for-third-party-harnesses-so-we-ported-nanoclaw-to-47ic</link>
      <guid>https://dev.to/johnohhh1/anthropic-limited-claude-subscription-usage-for-third-party-harnesses-so-we-ported-nanoclaw-to-47ic</guid>
      <description>&lt;p&gt;&lt;em&gt;NanoClaw started as an app effectively built by running Claude inside its own repo. Porting it to Codex meant rewriting not just the runtime, but the markdown instruction layer Claude had been using to build and operate the system.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most "AI app ports" are not actually ports.&lt;/p&gt;

&lt;p&gt;They are usually one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;swap the SDK&lt;/li&gt;
&lt;li&gt;change the model name&lt;/li&gt;
&lt;li&gt;keep the old runtime assumptions&lt;/li&gt;
&lt;li&gt;call it done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was not that.&lt;/p&gt;

&lt;p&gt;On April 4, Anthropic began limiting Claude subscription usage for third-party harnesses, including tools in the OpenClaw/NanoClaw category, pushing that usage toward extra paid usage instead of normal subscription limits. That policy shift did not magically make NanoClaw unusable overnight, but it did make one thing obvious:&lt;/p&gt;

&lt;p&gt;Building a serious agent system on top of a first-party subscription runtime is not a stable foundation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NanoClaw&lt;/code&gt; started life as something much stranger and much more interesting: an agent harness that was not just &lt;strong&gt;running on Claude-native assumptions&lt;/strong&gt;, but was also largely &lt;strong&gt;shaped by Claude while working inside the repo&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The original workflow was basically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;point Claude Code at the repo&lt;/li&gt;
&lt;li&gt;give it a stack of markdown instructions&lt;/li&gt;
&lt;li&gt;run commands like &lt;code&gt;/setup&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;let it scaffold, extend, and evolve the app from inside its own preferred operating model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means the old codebase was not merely "compatible with Claude."&lt;/p&gt;

&lt;p&gt;It was, in a very real sense, &lt;strong&gt;built by running Claude inside a repo that was designed to teach Claude how to keep building it&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That distinction is the whole story.&lt;/p&gt;

&lt;p&gt;Because once we decided to make NanoClaw truly Codex-native, the work was not "replace Anthropic with OpenAI."&lt;/p&gt;

&lt;p&gt;The work was simple to describe and hard to do:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;separate the app from the agent that originally co-authored its architecture.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Weird Part: The Markdown Was Part of the Runtime
&lt;/h2&gt;

&lt;p&gt;A lot of projects have docs.&lt;/p&gt;

&lt;p&gt;NanoClaw had something closer to an instruction substrate.&lt;/p&gt;

&lt;p&gt;The Claude-native version depended on a pile of markdown-driven behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.claude/skills/...&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;setup flows like &lt;code&gt;/setup&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;branch/workflow docs&lt;/li&gt;
&lt;li&gt;installer docs&lt;/li&gt;
&lt;li&gt;capability docs&lt;/li&gt;
&lt;li&gt;hidden conventions for how the agent should extend the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These files were not just "developer notes."&lt;/p&gt;

&lt;p&gt;They were part of the product's behavioral layer.&lt;/p&gt;

&lt;p&gt;They told the agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how to initialize the app&lt;/li&gt;
&lt;li&gt;how to install integrations&lt;/li&gt;
&lt;li&gt;how to shape features&lt;/li&gt;
&lt;li&gt;how to think about memory&lt;/li&gt;
&lt;li&gt;how to extend channels&lt;/li&gt;
&lt;li&gt;how to apply skills&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So when we started the port, one thing became obvious fast:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;some of the &lt;code&gt;.md&lt;/code&gt; files were effectively code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not executable code in the TypeScript sense, but runtime-significant architecture in the agent sense.&lt;/p&gt;

&lt;p&gt;That meant the port had two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Port the actual Node.js/container runtime&lt;/li&gt;
&lt;li&gt;Audit and rewrite the instruction layer that Claude had been using to build and operate the app&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If we didn't do both, we wouldn't have a Codex-native system.&lt;/p&gt;

&lt;p&gt;We would just have Claude-shaped behavior hiding under a new logo.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NanoClaw Actually Is
&lt;/h2&gt;

&lt;p&gt;At its core, NanoClaw is a small Node.js orchestrator for running isolated assistants in containers.&lt;/p&gt;

&lt;p&gt;The host process handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;channels&lt;/li&gt;
&lt;li&gt;routing&lt;/li&gt;
&lt;li&gt;SQLite state&lt;/li&gt;
&lt;li&gt;scheduling&lt;/li&gt;
&lt;li&gt;group registration&lt;/li&gt;
&lt;li&gt;IPC&lt;/li&gt;
&lt;li&gt;container lifecycle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The container handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the actual agent runtime&lt;/li&gt;
&lt;li&gt;group-local context&lt;/li&gt;
&lt;li&gt;active skills&lt;/li&gt;
&lt;li&gt;delegated subagents&lt;/li&gt;
&lt;li&gt;tool access via MCP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That architecture survived the port.&lt;/p&gt;

&lt;p&gt;What changed was the substrate it was built on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Claude-Specific
&lt;/h2&gt;

&lt;p&gt;Before the port, the Claude-specific assumptions were everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude SDK query loop&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; instruction contract&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/&lt;/code&gt; state and session layout&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/skills/&lt;/code&gt; install model&lt;/li&gt;
&lt;li&gt;Claude slash-command workflows like &lt;code&gt;/setup&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Claude-era delegation semantics&lt;/li&gt;
&lt;li&gt;remote control built around Claude tooling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And because Claude had been used to keep extending the app from inside the repo, those assumptions were reinforced both in code and in markdown.&lt;/p&gt;

&lt;p&gt;So the first real task was not coding.&lt;/p&gt;

&lt;p&gt;It was indexing and classifying the repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which parts were host application behavior&lt;/li&gt;
&lt;li&gt;which parts were Claude-specific runtime behavior&lt;/li&gt;
&lt;li&gt;which parts were portable concepts wearing Claude-shaped names&lt;/li&gt;
&lt;li&gt;which markdown files were still product-significant&lt;/li&gt;
&lt;li&gt;which markdown files were now just historical scaffolding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That review step mattered more than any individual code patch.&lt;/p&gt;

&lt;p&gt;Because if you misclassify those layers, you end up deleting real features or preserving the wrong abstractions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Port Was Not "Remove Claude, Add Codex"
&lt;/h2&gt;

&lt;p&gt;A real port required four kinds of changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Replace the runtime contract
&lt;/h3&gt;

&lt;p&gt;The old container runner was built around Claude-native execution.&lt;/p&gt;

&lt;p&gt;The new one is built around Codex CLI running non-interactively in the container.&lt;/p&gt;

&lt;p&gt;That meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;building prompt context from host state plus group instructions&lt;/li&gt;
&lt;li&gt;invoking Codex cleanly for fresh turns and resumed turns&lt;/li&gt;
&lt;li&gt;streaming results back through a stable host/container protocol&lt;/li&gt;
&lt;li&gt;preserving session continuity where possible&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This sounds like a simple runner swap.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;Because CLI contracts differ in annoying, subtle ways.&lt;/p&gt;

&lt;p&gt;We hit real bugs around things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;flags that work on &lt;code&gt;codex&lt;/code&gt; but not &lt;code&gt;codex exec&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;flags accepted on fresh turns but rejected on &lt;code&gt;codex exec resume&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are not theoretical portability problems. They are the kinds of issues that make a working-looking system fail in production after the host queue has already done its job.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Rewrite the instruction layer
&lt;/h3&gt;

&lt;p&gt;This was the part that most clearly separated a fake port from a real one.&lt;/p&gt;

&lt;p&gt;The Claude-native repo had accumulated behavior through markdown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;skills&lt;/li&gt;
&lt;li&gt;setup flow&lt;/li&gt;
&lt;li&gt;integration guidance&lt;/li&gt;
&lt;li&gt;memory conventions&lt;/li&gt;
&lt;li&gt;extension/install patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We had to go through those files and make hard calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;convert&lt;/li&gt;
&lt;li&gt;prune&lt;/li&gt;
&lt;li&gt;delete&lt;/li&gt;
&lt;li&gt;replace&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That meant moving from Claude-specific conventions to Codex-native ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; became &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;repo/group skills became &lt;code&gt;SKILL.md&lt;/code&gt;-based&lt;/li&gt;
&lt;li&gt;old &lt;code&gt;.claude/skills/&lt;/code&gt; assumptions were removed&lt;/li&gt;
&lt;li&gt;the setup story was rewritten around the current runtime rather than old slash-command flows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This was not glamorous work, but it was absolutely core to the port.&lt;/p&gt;

&lt;p&gt;If the old markdown continues to teach the wrong mental model to the next agent working in the repo, the architecture drifts backwards immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keep the real product features
&lt;/h3&gt;

&lt;p&gt;One of the easiest mistakes in a port is to treat every old feature as vendor-specific and quietly delete it.&lt;/p&gt;

&lt;p&gt;That would have been wrong here.&lt;/p&gt;

&lt;p&gt;Some things were truly Claude-specific and should die:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude SDK integration&lt;/li&gt;
&lt;li&gt;Claude remote control&lt;/li&gt;
&lt;li&gt;old Claude-only skill marketplace conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But other things were just app features that happened to be implemented in a Claude-native system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Telegram&lt;/li&gt;
&lt;li&gt;WhatsApp&lt;/li&gt;
&lt;li&gt;web UI channel&lt;/li&gt;
&lt;li&gt;subagent delegation&lt;/li&gt;
&lt;li&gt;host skills&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those needed to be ported, not removed.&lt;/p&gt;

&lt;p&gt;That was an important correction during this work.&lt;/p&gt;

&lt;p&gt;The right question was not:&lt;/p&gt;

&lt;p&gt;"Does this mention Claude?"&lt;/p&gt;

&lt;p&gt;The right question was:&lt;/p&gt;

&lt;p&gt;"Is this a real app capability, and does Codex have a legitimate equivalent or integration path?"&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Make the host own the architecture
&lt;/h3&gt;

&lt;p&gt;This is the core lesson of the whole effort.&lt;/p&gt;

&lt;p&gt;The clean architecture is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NanoClaw owns orchestration&lt;/li&gt;
&lt;li&gt;NanoClaw owns state&lt;/li&gt;
&lt;li&gt;NanoClaw owns channels&lt;/li&gt;
&lt;li&gt;NanoClaw owns skills&lt;/li&gt;
&lt;li&gt;NanoClaw owns setup&lt;/li&gt;
&lt;li&gt;Codex is the agent backend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the host owns those things, the backend is replaceable.&lt;/p&gt;

&lt;p&gt;If the backend owns those things, the host is just branding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bugs That Proved the Point
&lt;/h2&gt;

&lt;p&gt;The best part of a port like this is that the bugs are revealing.&lt;/p&gt;

&lt;p&gt;They tell you where your assumptions still belong to the old system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 1: The bot looked alive but was brain-dead
&lt;/h3&gt;

&lt;p&gt;Telegram connected.&lt;br&gt;
Messages were being stored.&lt;br&gt;
But nothing was being processed.&lt;/p&gt;

&lt;p&gt;Why?&lt;/p&gt;

&lt;p&gt;Because WhatsApp was loading even when it was not configured, entering a reconnect loop, and blocking the async startup path before the message loop came online.&lt;/p&gt;

&lt;p&gt;That produced a nasty half-working state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inbound messages reached the DB&lt;/li&gt;
&lt;li&gt;logs looked active&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startMessageLoop()&lt;/code&gt; never really came alive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"NanoClaw running"&lt;/code&gt; never appeared&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix was to stop treating channel startup as monolithic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;load channels conditionally from real config/auth state&lt;/li&gt;
&lt;li&gt;allow channel connect failure without killing startup&lt;/li&gt;
&lt;li&gt;skip missing channels cleanly instead of blocking the process&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That sounds basic, but it was exactly the kind of bug you get when a system has grown around agent-era assumptions rather than explicit host orchestration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 2: The auth existed, but Codex still 401'd
&lt;/h3&gt;

&lt;p&gt;This was my favorite bug in the whole port because it was pure systems work.&lt;/p&gt;

&lt;p&gt;The host was logged into Codex.&lt;br&gt;
The container was supposed to inherit auth.&lt;br&gt;
But every in-container turn failed with:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;401 Unauthorized: Missing bearer or basic authentication in header&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The credentials were not absent.&lt;br&gt;
They were misplaced.&lt;/p&gt;

&lt;p&gt;The chain was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;auth was copied into a nested &lt;code&gt;.codex/.codex/auth.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;that directory was mounted into the container&lt;/li&gt;
&lt;li&gt;Codex expected auth relative to &lt;code&gt;$HOME/.codex/auth.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the file existed on disk, just not where the CLI actually looked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the kind of bug that disappears if you talk only in architecture diagrams.&lt;/p&gt;

&lt;p&gt;It only becomes obvious when you trace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;host path&lt;/li&gt;
&lt;li&gt;mounted path&lt;/li&gt;
&lt;li&gt;container &lt;code&gt;$HOME&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;actual CLI lookup convention&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;flatten the per-group Codex state layout&lt;/li&gt;
&lt;li&gt;mount it directly to &lt;code&gt;/home/node/.codex&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;migrate old nested state forward&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After that, the 401 vanished and the live message path finally completed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bug 3: Resume was not the same as fresh execution
&lt;/h3&gt;

&lt;p&gt;Another seam bug.&lt;/p&gt;

&lt;p&gt;We got fresh turns working, then resume failed because &lt;code&gt;codex exec resume&lt;/code&gt; did not accept the same options as the fresh path.&lt;/p&gt;

&lt;p&gt;That is a perfect example of why "we already integrated the CLI" is not enough.&lt;/p&gt;

&lt;p&gt;You have to test:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fresh turn path&lt;/li&gt;
&lt;li&gt;resumed turn path&lt;/li&gt;
&lt;li&gt;session restoration&lt;/li&gt;
&lt;li&gt;output handling after resume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are separate runtime contracts, even when they share most of the binary name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the System Looks Like Now
&lt;/h2&gt;

&lt;p&gt;The current branch is not "Claude code with Codex paint."&lt;/p&gt;

&lt;p&gt;It is a different runtime model.&lt;/p&gt;

&lt;p&gt;Today it has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Codex-native container runner&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; for project/group instructions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SKILL.md&lt;/code&gt;-based host skills&lt;/li&gt;
&lt;li&gt;per-group Codex state&lt;/li&gt;
&lt;li&gt;Telegram, WhatsApp, and Web UI channels&lt;/li&gt;
&lt;li&gt;Codex-backed delegation via MCP&lt;/li&gt;
&lt;li&gt;host-owned setup and orchestration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And critically, it is no longer relying on the repo being interpreted through Claude-native markdown conventions to keep itself evolving correctly.&lt;/p&gt;

&lt;p&gt;That was the actual finish line.&lt;/p&gt;

&lt;p&gt;Not "build passes."&lt;/p&gt;

&lt;p&gt;Not "logs look nice."&lt;/p&gt;

&lt;p&gt;But:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the app can now be operated, extended, and reasoned about as a Codex-native system.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Lesson
&lt;/h2&gt;

&lt;p&gt;The strongest takeaway from this work is that agent-built systems can end up with architecture embedded in places most teams don't normally treat as architecture.&lt;/p&gt;

&lt;p&gt;In this case, that meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;markdown instruction files&lt;/li&gt;
&lt;li&gt;slash-command setup flows&lt;/li&gt;
&lt;li&gt;skill directories&lt;/li&gt;
&lt;li&gt;implied agent workflows&lt;/li&gt;
&lt;li&gt;hidden filesystem conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to port a system like that, you have to audit all of it.&lt;/p&gt;

&lt;p&gt;Not just the code.&lt;/p&gt;

&lt;p&gt;Not just the API client.&lt;/p&gt;

&lt;p&gt;All of it.&lt;/p&gt;

&lt;p&gt;Because once an agent has spent enough time building inside a repo, the repo starts to encode assumptions about &lt;em&gt;that agent's worldview&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That is fascinating when it works. It is dangerous when you need to switch runtimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  If You're Thinking About Doing This Yourself
&lt;/h2&gt;

&lt;p&gt;My advice is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inventory the instruction layer before you touch the runtime&lt;/li&gt;
&lt;li&gt;treat markdown conventions as potentially executable architecture&lt;/li&gt;
&lt;li&gt;separate host ownership from backend ownership early&lt;/li&gt;
&lt;li&gt;verify real CLI behavior instead of trusting docs from memory&lt;/li&gt;
&lt;li&gt;test startup, auth, fresh turns, resumed turns, and recovery independently&lt;/li&gt;
&lt;li&gt;do not call it a port if you quietly deleted capabilities that had real equivalents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hard part is not replacing the model.&lt;/p&gt;

&lt;p&gt;The hard part is identifying all the places where the old model had already shaped the app.&lt;/p&gt;

&lt;p&gt;And once you do that, the job becomes much clearer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;you are not porting an SDK. You are reclaiming the architecture.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>nanoclaw</category>
      <category>codex</category>
      <category>port</category>
    </item>
    <item>
      <title>How I repackaged the official Windows Codex MSIX into a working Linux .deb</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Thu, 09 Apr 2026 18:13:34 +0000</pubDate>
      <link>https://dev.to/johnohhh1/how-i-repackaged-the-official-windows-codex-msix-into-a-working-linux-deb-48ch</link>
      <guid>https://dev.to/johnohhh1/how-i-repackaged-the-official-windows-codex-msix-into-a-working-linux-deb-48ch</guid>
      <description>&lt;p&gt;I wanted a real Linux desktop app for Codex.&lt;/p&gt;

&lt;p&gt;At first, this looked like it should be straightforward: take the official Windows package, unpack it, swap in Linux Electron, rebuild whatever was platform-specific, and turn it into a &lt;code&gt;.deb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is not what happened.&lt;/p&gt;

&lt;p&gt;What actually worked was more pragmatic: preserve the official Codex payload, branding, and resources from the Windows MSIX, but replace the runtime shell with a small Linux Electron wrapper that opens Codex in its own desktop window.&lt;/p&gt;

&lt;p&gt;This post is the write-up I wish I had before I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;The target was simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;install Codex on Ubuntu as a &lt;code&gt;.deb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;get a desktop launcher and icon&lt;/li&gt;
&lt;li&gt;make it feel like a native app&lt;/li&gt;
&lt;li&gt;avoid relying on a browser tab&lt;/li&gt;
&lt;li&gt;keep the build repeatable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I already had something similar working for ChatGPT, so I assumed Codex would follow the same path.&lt;/p&gt;

&lt;p&gt;That assumption was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I started with
&lt;/h2&gt;

&lt;p&gt;The source payload was the official Windows MSIX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenAI.Codex_26.313.5234.0_x64__2p2nqsd0c76g0.Msix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo I ended up with is here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git@github.com:johnohhh1/codex-ubuntu.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main build script is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./build-codex-native-deb.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The obvious approach
&lt;/h2&gt;

&lt;p&gt;The first plan was the normal "repackage the Electron app" playbook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extract the MSIX.&lt;/li&gt;
&lt;li&gt;Grab the bundled &lt;code&gt;app.asar&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Swap the Windows Electron runtime for Linux Electron.&lt;/li&gt;
&lt;li&gt;Rebuild or replace native modules.&lt;/li&gt;
&lt;li&gt;Repackage everything into a &lt;code&gt;.deb&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In theory, that should have produced a Linux &lt;code&gt;.deb&lt;/code&gt; built directly from the original app bundle.&lt;/p&gt;

&lt;p&gt;In practice, it failed in multiple different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the Windows bundle was not actually Linux-ready
&lt;/h2&gt;

&lt;p&gt;The Codex payload included resources and binaries that looked promising, but the packaged app still carried Windows assumptions.&lt;/p&gt;

&lt;p&gt;The biggest issue was native modules.&lt;/p&gt;

&lt;p&gt;At one point I confirmed the app was dying because a native dependency was still Windows-built:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid ELF header
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the classic sign that Linux is trying to load a binary module compiled for the wrong platform.&lt;/p&gt;

&lt;p&gt;So the next move was to rebuild native modules for Linux and wire them back into the extracted app.&lt;/p&gt;

&lt;p&gt;That still did not get me to a usable app window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: getting the app to "launch" was not the same as getting it to work
&lt;/h2&gt;

&lt;p&gt;I got as far as installing the package cleanly and starting Electron on Linux.&lt;/p&gt;

&lt;p&gt;That was progress, but not enough.&lt;/p&gt;

&lt;p&gt;The app process would launch and then exit without showing a window, or fail during startup after getting past the first set of issues.&lt;/p&gt;

&lt;p&gt;This is one of the most annoying parts of Electron packaging work: "the binary started" sounds good, but it does not mean the app is actually viable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Electron GPU startup was fatal on Linux
&lt;/h2&gt;

&lt;p&gt;Once I simplified the runtime enough to observe the real failure clearly, the next blocker showed up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: GPU process isn't usable. Goodbye.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That changed the direction of the fix immediately.&lt;/p&gt;

&lt;p&gt;Instead of trying to preserve every part of the original desktop behavior, I needed a Linux-friendly launcher path that was stable in the actual Ubuntu session I was using.&lt;/p&gt;

&lt;p&gt;The fix was to disable GPU at both levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in the Electron app itself with command-line switches&lt;/li&gt;
&lt;li&gt;in the launcher wrapper used by the packaged &lt;code&gt;.deb&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The final launcher executes Electron like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;exec&lt;/span&gt; /opt/codex-desktop-native/electron/Codex &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-sandbox&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-setuid-sandbox&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-gpu&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disable-gpu-compositing&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was not elegant, but it was effective.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: stop trying to preserve the original runtime shell
&lt;/h2&gt;

&lt;p&gt;This was the real turning point.&lt;/p&gt;

&lt;p&gt;Instead of continuing to patch the original Windows desktop runtime deeper and deeper, I switched to a simpler architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;use Linux Electron as the runtime&lt;/li&gt;
&lt;li&gt;keep the official Codex assets from the MSIX&lt;/li&gt;
&lt;li&gt;build a tiny Linux wrapper app&lt;/li&gt;
&lt;li&gt;load &lt;code&gt;https://chatgpt.com/codex&lt;/code&gt; inside a dedicated desktop window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That approach still let me build from the official Windows MSIX, but it stopped me from getting trapped in the least portable parts of the original runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the final wrapper does
&lt;/h2&gt;

&lt;p&gt;The generated wrapper app is very small.&lt;/p&gt;

&lt;p&gt;It:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creates a &lt;code&gt;BrowserWindow&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;hides the menu bar&lt;/li&gt;
&lt;li&gt;uses a Codex title and icon&lt;/li&gt;
&lt;li&gt;opens external popups in the default browser&lt;/li&gt;
&lt;li&gt;allows normal &lt;code&gt;http&lt;/code&gt; and &lt;code&gt;https&lt;/code&gt; navigation&lt;/li&gt;
&lt;li&gt;forces the window to show even if page paint is slow&lt;/li&gt;
&lt;li&gt;points at &lt;code&gt;https://chatgpt.com/codex&lt;/code&gt; by default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wrapper also supports overriding the URL with an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CODEX_WEB_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://chatgpt.com/codex codex-desktop-native
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is useful if the endpoint ever changes or if you want a staging/test flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging details that mattered
&lt;/h2&gt;

&lt;p&gt;There were a few packaging details that turned out to be more important than I expected.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The app had to present itself as Codex
&lt;/h3&gt;

&lt;p&gt;I renamed the Linux Electron binary from &lt;code&gt;electron&lt;/code&gt; to &lt;code&gt;Codex&lt;/code&gt; so the app identity looked correct on Linux.&lt;/p&gt;

&lt;p&gt;That helped with launcher behavior and window class handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Desktop entry details mattered
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;.desktop&lt;/code&gt; file sets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Name=Codex&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Exec=codex-desktop-native&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Icon=codex-desktop-native&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;StartupWMClass=Codex&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-GNOME-WMClass=Codex&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that, you can end up with weird launcher grouping or desktop integration issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Old install leftovers had to be cleaned up explicitly
&lt;/h3&gt;

&lt;p&gt;Earlier experiments left behind &lt;code&gt;app.asar.unpacked&lt;/code&gt; content from previous versions.&lt;/p&gt;

&lt;p&gt;Even after the packaging approach changed, those leftovers could survive upgrades and create confusing behavior.&lt;/p&gt;

&lt;p&gt;So I added a &lt;code&gt;preinst&lt;/code&gt; cleanup step to the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"/opt/codex-desktop-native/electron/resources/app.asar.unpacked"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That made upgrades deterministic again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the build self-contained
&lt;/h2&gt;

&lt;p&gt;At first I was borrowing tools from another workspace, which worked but was brittle.&lt;/p&gt;

&lt;p&gt;I cleaned that up by making the project self-contained with local dev dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"devDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"asar"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^3.2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"electron"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^40.0.0"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That way the repo can be built with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
./rebuild-install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of depending on some other directory on disk having the right binaries in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build flow now
&lt;/h2&gt;

&lt;p&gt;The working build pipeline looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Validate the MSIX input.&lt;/li&gt;
&lt;li&gt;Extract the package and detect its version from &lt;code&gt;AppxManifest.xml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Copy the official assets into a staging directory.&lt;/li&gt;
&lt;li&gt;Copy in Linux Electron.&lt;/li&gt;
&lt;li&gt;Replace the original runtime shell with a generated Linux wrapper &lt;code&gt;app.asar&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Build a &lt;code&gt;.deb&lt;/code&gt; with the desktop entry, icon, launcher script, and package hooks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The helper script for rebuild and reinstall is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./rebuild-install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The final output is a package like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/codex-desktop-native_26.313.5234.0_amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the user-facing launch command is simply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;codex-desktop-native
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most importantly: it actually opens, stays up, and works as a desktop app on Ubuntu.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;The biggest lesson from this project is that "porting" is sometimes the wrong framing.&lt;/p&gt;

&lt;p&gt;I went in thinking I needed to preserve the original Windows desktop runtime as faithfully as possible.&lt;/p&gt;

&lt;p&gt;What I really needed was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a reliable Linux package&lt;/li&gt;
&lt;li&gt;a clean desktop launcher&lt;/li&gt;
&lt;li&gt;Codex in its own app window&lt;/li&gt;
&lt;li&gt;a build process I could rerun later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once I optimized for that instead of purity, the solution got much simpler.&lt;/p&gt;

&lt;p&gt;That tradeoff is worth stating directly: the final app is absolutely built from the official Windows MSIX, but it is not a perfect binary carryover of the original runtime. It is a Linux-native wrapper around the official Codex experience, using the original payload where it helps and replacing the parts that did not survive the platform jump well.&lt;/p&gt;

&lt;p&gt;That was the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;The repo is here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git@github.com:johnohhh1/codex-ubuntu.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:johnohhh1/codex-ubuntu.git
&lt;span class="nb"&gt;cd &lt;/span&gt;codex-ubuntu
npm &lt;span class="nb"&gt;install&lt;/span&gt;
./rebuild-install.sh
codex-desktop-native
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;A lot of cross-platform packaging work looks like a debugging problem, but the real work is architectural judgment.&lt;/p&gt;

&lt;p&gt;You have to decide when to keep forcing the original design, and when to admit that a thinner, more native solution is the thing that will actually ship.&lt;/p&gt;

&lt;p&gt;This project only started working once I made that call. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a build process I could rerun later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once I optimized for that instead of purity, the solution got much simpler.&lt;/p&gt;

&lt;p&gt;That tradeoff is worth stating directly: the final app is absolutely built from the official Windows MSIX, but it is not a perfect binary carryover of the original runtime. It is a Linux-native wrapper around the official Codex experience, using the original payload where it helps and replacing the parts that did not survive the platform jump well.&lt;/p&gt;

&lt;p&gt;That was the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;The repo is here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git@github.com:johnohhh1/codex-ubuntu.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:johnohhh1/codex-ubuntu.git
&lt;span class="nb"&gt;cd &lt;/span&gt;codex-ubuntu
npm &lt;span class="nb"&gt;install&lt;/span&gt;
./rebuild-install.sh
codex-desktop-native
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;A lot of cross-platform packaging work looks like a debugging problem, but the real work is architectural judgment.&lt;/p&gt;

&lt;p&gt;You have to decide when to keep forcing the original design, and when to admit that a thinner, more native solution is the thing that will actually ship.&lt;/p&gt;

&lt;p&gt;This project only started working once I made that call.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>codex</category>
      <category>electron</category>
      <category>ported</category>
    </item>
    <item>
      <title>Run the Real ChatGPT Desktop App on Ubuntu Linux (Not a Wrapper)</title>
      <dc:creator>johnohhh1</dc:creator>
      <pubDate>Tue, 07 Apr 2026 13:51:17 +0000</pubDate>
      <link>https://dev.to/johnohhh1/run-the-real-chatgpt-desktop-app-on-ubuntu-linux-not-a-wrapper-54g7</link>
      <guid>https://dev.to/johnohhh1/run-the-real-chatgpt-desktop-app-on-ubuntu-linux-not-a-wrapper-54g7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; OpenAI ships a Windows MSIX binary. You can unpack it, patch three platform assumptions, and run the actual official app natively on Ubuntu — same binary Windows users get. No Electron wrapper, no web view in a box.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem With Every Other Guide
&lt;/h2&gt;

&lt;p&gt;Search "ChatGPT desktop Ubuntu" and you'll find two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Articles pointing you at &lt;code&gt;lencx/ChatGPT&lt;/code&gt; — a third-party Electron wrapper that loads chatgpt.com in a window. It's not the real app.&lt;/li&gt;
&lt;li&gt;Articles pointing you at other wrappers doing the same thing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These work, but you're getting a community-built shell around a website — not the app itself. The real ChatGPT Desktop (the one in the Windows Store) has features, update hooks, and auth flows that wrapper apps can't replicate cleanly.&lt;/p&gt;

&lt;p&gt;This guide unpacks the official binary and runs it on Ubuntu 26.04. Tested on kernel 7.0.0, RTX 5070, NVIDIA 580/CUDA 13.0, both X11 and Wayland via XWayland.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Script Actually Does
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Extracts the &lt;code&gt;x64&lt;/code&gt; MSIX from the official &lt;code&gt;.msixbundle&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pulls out the official &lt;code&gt;app.asar&lt;/code&gt; (the real app logic)&lt;/li&gt;
&lt;li&gt;Patches three platform assumptions so it boots on Linux:

&lt;ul&gt;
&lt;li&gt;Routes the platform chooser through the macOS-style implementation (Linux is close enough)&lt;/li&gt;
&lt;li&gt;Disables macOS-only &lt;code&gt;setVibrancy()&lt;/code&gt; calls&lt;/li&gt;
&lt;li&gt;Skips the macOS &lt;code&gt;ioreg&lt;/code&gt; device ID path&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Stages Linux Electron around the official app resources&lt;/li&gt;
&lt;li&gt;Packages everything as a proper &lt;code&gt;.deb&lt;/code&gt; — &lt;code&gt;chatgpt-desktop-native&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You end up with a system package you can install, update, and uninstall like anything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;h3&gt;
  
  
  System packages
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; dpkg-dev nodejs python3 file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Local Electron tooling
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; ~/chatgpt-windows-deb &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/chatgpt-windows-deb
npm &lt;span class="nb"&gt;install &lt;/span&gt;electron @electron/asar &lt;span class="nt"&gt;--no-save&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Get the Official MSIX Bundle
&lt;/h2&gt;

&lt;p&gt;You need the official Windows package from OpenAI. Download &lt;code&gt;OpenAI.ChatGPT-Desktop_&amp;lt;version&amp;gt;.Msixbundle&lt;/code&gt; from the Microsoft Store or OpenAI's distribution endpoint and drop it in your working directory.&lt;/p&gt;

&lt;p&gt;The repo includes the version current at time of writing as an example payload.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/chatgpt-windows-deb
git clone https://github.com/johnohhh1/chatgpt_desktop_ubuntu &lt;span class="nb"&gt;.&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;electron @electron/asar &lt;span class="nt"&gt;--no-save&lt;/span&gt;
./build-chatgpt-native-deb.sh &lt;span class="nt"&gt;--exe&lt;/span&gt; ./OpenAI.ChatGPT-Desktop_2026.212.2039.0.Msixbundle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output lands in &lt;code&gt;dist/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/chatgpt-desktop-native_2026.212.2039.0_amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; ./dist/chatgpt-desktop-native_2026.212.2039.0_amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebuilding without a version bump? Force refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; ./dist/chatgpt-desktop-native_2026.212.2039.0_amd64.deb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Register Auth Callback Handlers
&lt;/h2&gt;

&lt;p&gt;The package installs a helper that registers your desktop session as the handler for the &lt;code&gt;chatgpt:&lt;/code&gt; and &lt;code&gt;chatgpt-alt:&lt;/code&gt; URL schemes (used for the login flow):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chatgpt-desktop-native-register
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it took:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xdg-mime query default x-scheme-handler/chatgpt
xdg-mime query default x-scheme-handler/chatgpt-alt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both should return &lt;code&gt;chatgpt-desktop-native.desktop&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Launch
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;chatgpt-desktop-native
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The desktop entry sets the WM class to &lt;code&gt;electron&lt;/code&gt; so GNOME binds the running window to the ChatGPT icon instead of the generic gear icon.&lt;/p&gt;




&lt;h2&gt;
  
  
  Known Quirks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The terminal will print Electron/NVIDIA/VA-API noise. This is normal — ignore it.&lt;/li&gt;
&lt;li&gt;The success signal is functional login and working chat, not a clean terminal.&lt;/li&gt;
&lt;li&gt;If GNOME still shows the generic icon after first launch: close the app fully and relaunch once. Stubborn shell? Log out and back in.&lt;/li&gt;
&lt;li&gt;If OpenAI updates the Windows app significantly, the patch targets in &lt;code&gt;build-chatgpt-native-deb.sh&lt;/code&gt; may need updating. PR welcome.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Reproducing on Another Machine
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Clone the repo to the target machine&lt;/li&gt;
&lt;li&gt;Drop a real ChatGPT &lt;code&gt;.msix&lt;/code&gt;, &lt;code&gt;.msixbundle&lt;/code&gt;, &lt;code&gt;.appx&lt;/code&gt;, or &lt;code&gt;.appxbundle&lt;/code&gt; in the directory&lt;/li&gt;
&lt;li&gt;&lt;code&gt;npm install electron @electron/asar --no-save&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;./build-chatgpt-native-deb.sh --exe &amp;lt;your-payload&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Install the generated &lt;code&gt;.deb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;chatgpt-desktop-native-register&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Launch &lt;code&gt;chatgpt-desktop-native&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/johnohhh1/chatgpt_desktop_ubuntu" rel="noopener noreferrer"&gt;github.com/johnohhh1/chatgpt_desktop_ubuntu&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Issues and PRs open. If a new MSIX version breaks the patches, open an issue with the version string and I'll update the patch targets.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tested on Ubuntu 26.04 "Noble" with kernel 7.0.0-10, RTX 5070, NVIDIA driver 580, CUDA 13.0.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>chatgpt</category>
      <category>electron</category>
    </item>
  </channel>
</rss>
