<?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: King Wan</title>
    <description>The latest articles on DEV Community by King Wan (@timexingxin).</description>
    <link>https://dev.to/timexingxin</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%2F4047293%2F3fceb6c4-5de7-47d5-8e69-628c253e41ab.jpg</url>
      <title>DEV Community: King Wan</title>
      <link>https://dev.to/timexingxin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/timexingxin"/>
    <language>en</language>
    <item>
      <title>Building a desktop client for an AI coding agent</title>
      <dc:creator>King Wan</dc:creator>
      <pubDate>Sat, 25 Jul 2026 21:15:28 +0000</pubDate>
      <link>https://dev.to/timexingxin/building-a-desktop-client-for-an-ai-coding-agent-147n</link>
      <guid>https://dev.to/timexingxin/building-a-desktop-client-for-an-ai-coding-agent-147n</guid>
      <description>&lt;p&gt;&lt;em&gt;Lessons from wrapping grok-build — the architecture, the traps, and why we picked Tauri over Electron.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grok-build&lt;/code&gt; is xAI's open-source Rust coding agent. It ships as a TUI. We&lt;br&gt;
wrote a native desktop client for it — Tauri 2 (~8 MB binary), React&lt;br&gt;
frontend, Rust runtime that spawns the CLI as a child process and talks&lt;br&gt;
to it over ACP/JSON-RPC 2.0. This post is the architecture deep-dive:&lt;br&gt;
how the pieces fit together, what surprised us, and the parts we'd&lt;br&gt;
build differently next time.&lt;/p&gt;

&lt;p&gt;The full source is at &lt;a href="https://github.com/timexingxin/grok-gui" rel="noopener noreferrer"&gt;github.com/timexingxin/grok-gui&lt;/a&gt;.&lt;br&gt;
MIT-licensed. Demo GIF in the README.&lt;/p&gt;


&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grok-build&lt;/code&gt; is genuinely good at code work — comparable to Claude Code&lt;br&gt;
for my workflow. But it ships as a Rust TUI. After six months of&lt;br&gt;
&lt;code&gt;cmd+tab&lt;/code&gt; between the terminal and my browser tabs, I wanted a real&lt;br&gt;
desktop UX without losing what makes the CLI good.&lt;/p&gt;

&lt;p&gt;The naive options all had problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrap it as a tmux session in a webview.&lt;/strong&gt; Doesn't help — you're
still reading scrollback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a community-built web wrapper.&lt;/strong&gt; They all wrap the OpenAI
Chat Completions API directly. They don't talk to the actual
agent runtime, so they miss tool calls, plan updates, permission
requests, and the streaming event surface that makes coding agents
feel responsive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a desktop GUI from scratch.&lt;/strong&gt; Means re-implementing the
agent loop, the model integration, the tool calling. Six months of
work, plus the resulting client would always lag the upstream.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right answer was staring at me: &lt;code&gt;grok-build&lt;/code&gt; already has a&lt;br&gt;
&lt;strong&gt;JSON-RPC 2.0 over stdio&lt;/strong&gt; interface called the Agent Client Protocol&lt;br&gt;
(ACP). That's the protocol I should be a client of. My job is just to&lt;br&gt;
write the client.&lt;/p&gt;


&lt;h2&gt;
  
  
  What is ACP?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://agentclientprotocol.com/" rel="noopener noreferrer"&gt;ACP&lt;/a&gt; is a JSON-RPC 2.0 protocol that&lt;br&gt;
coding-agent CLIs expose over their stdin/stdout. The agent emits&lt;br&gt;
notifications (text deltas, tool calls, plan updates, permission&lt;br&gt;
requests, session lifecycle); the client sends requests (user prompts,&lt;br&gt;
permission responses, model switches, session loads).&lt;/p&gt;

&lt;p&gt;If your agent speaks ACP, you can write a client without re-implementing&lt;br&gt;
the agent loop. You just connect to the stdio, parse the JSON frames,&lt;br&gt;
and render.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────┐
│  Desktop Shell (Tauri 2, ~8 MB)          │   React + Vite + Tailwind
└──────────────┬───────────────────────────┘
               │ Tauri commands + events (typed)
               ▼
┌──────────────────────────────────────────┐
│  Rust bridge (apps/desktop/src-tauri/src/grok_runtime.rs)
│  - spawns `grok agent stdio` as a child │
│  - JSON-RPC 2.0 over its stdin/stdout   │
│  - LANG/LC_ALL forwarded for locale     │
│  - manages a pool of live runtimes      │
└──────────────┬───────────────────────────┘
               │ subprocess stdin/stdout
               ▼
┌──────────────────────────────────────────┐
│  Grok Build runtime (xai-org/grok-build) │   upstream, Apache-2.0
│  Agent loop, tools, context, MCP, skills │
└──────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Rust bridge is the interesting part. It manages the lifecycle:&lt;br&gt;
spawn the child, do the initialize handshake (where the agent tells&lt;br&gt;
us its version and supported features), do a session/new or&lt;br&gt;
session/load, stream every event into the frontend. The frontend just&lt;br&gt;
listens for typed events and renders.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why Tauri, not Electron
&lt;/h2&gt;

&lt;p&gt;I considered both. The decision matrix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Tauri 2&lt;/th&gt;
&lt;th&gt;Electron&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Binary size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~8 MB&lt;/td&gt;
&lt;td&gt;~150 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory (idle)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~80 MB&lt;/td&gt;
&lt;td&gt;~300 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Webview&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OS-native WebKit / WebView2&lt;/td&gt;
&lt;td&gt;Bundled Chromium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Native feel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Closer (real OS widgets)&lt;/td&gt;
&lt;td&gt;Web app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IPC overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Function call (typed structs)&lt;/td&gt;
&lt;td&gt;JSON-over-IPC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tauri won for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The backend was already going to be Rust.&lt;/strong&gt; &lt;code&gt;grok-build&lt;/code&gt; is&lt;br&gt;
Rust, the ACP client had to be Rust to spawn and talk to it&lt;br&gt;
properly, and Rust is a good fit for the long-running&lt;br&gt;
process-pool pattern. Electron would have meant Rust + Node.js in&lt;br&gt;
the same project.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;8 MB vs 150 MB matters for distribution.&lt;/strong&gt; A coding tool&lt;br&gt;
installer shouldn't be larger than the Electron runtime itself&lt;br&gt;
is on disk. The 150 MB Electron app feels heavy on a MacBook&lt;br&gt;
where the rest of the system is small native binaries.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;OS-native webview feels right.&lt;/strong&gt; WebKit on macOS renders&lt;br&gt;
identically to Safari, which means the React app looks like a&lt;br&gt;
Mac app, not a Windows-95 webpage. The title-bar-style="hiddenInset"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trafficLightPosition settings give us native window controls
without the visible title bar.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff: Tauri requires the MSVC build tools on Windows. But&lt;br&gt;
&lt;code&gt;windows-latest&lt;/code&gt; GitHub Actions runners have those preinstalled, so&lt;br&gt;
the CI cost is the same.&lt;/p&gt;


&lt;h2&gt;
  
  
  Multi-session parallel
&lt;/h2&gt;

&lt;p&gt;The killer feature for me is having multiple conversations going at&lt;br&gt;
once. If I'm asking Grok to refactor one file while waiting on a&lt;br&gt;
different task, I don't want to lose the first turn's stream when I&lt;br&gt;
switch.&lt;/p&gt;

&lt;p&gt;Naive implementation: spawn N CLI processes on app startup. Wasteful&lt;br&gt;
for users who only have one session going.&lt;/p&gt;

&lt;p&gt;What I built: a &lt;strong&gt;pool of live runtimes&lt;/strong&gt;, LRU-evicted on idle.&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="c1"&gt;// pseudocode&lt;/span&gt;
&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;RuntimePool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;runtimes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HashMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GrokRuntime&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&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;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;RuntimePool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;GrokRuntime&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="py"&gt;.runtimes&lt;/span&gt;&lt;span class="nf"&gt;.remove&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;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// LRU hit: re-use the existing process. No spawn cost.&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;;&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;pool&lt;/span&gt;&lt;span class="py"&gt;.runtimes&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="py"&gt;.capacity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Evict the least-recently-used idle runtime.&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;victim_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;victim_rt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="py"&gt;.runtimes&lt;/span&gt;&lt;span class="nf"&gt;.pop_lru&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;victim_rt&lt;/span&gt;&lt;span class="nf"&gt;.shutdown&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Spawn a new CLI subprocess.&lt;/span&gt;
    &lt;span class="nn"&gt;GrokRuntime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a user starts a new session, the pool looks up an existing live&lt;br&gt;
runtime for that session id. If found, it reuses it — no spawn cost,&lt;br&gt;
no handshake re-init. If the pool is at capacity, it shuts down the&lt;br&gt;
least-recently-used idle runtime (a graceful kill, waiting up to 5s for&lt;br&gt;
the agent to settle). Otherwise it spawns a new CLI.&lt;/p&gt;

&lt;p&gt;The LRU eviction is critical because &lt;code&gt;grok agent stdio&lt;/code&gt; can hold&lt;br&gt;
hundreds of MB of context in memory. Without eviction, opening a few&lt;br&gt;
sessions would have eaten all available RAM. With eviction, idle&lt;br&gt;
sessions are cleaned up and the user's working set stays bounded.&lt;/p&gt;

&lt;p&gt;The "background turn keeps streaming" behavior — the bit I'm most&lt;br&gt;
proud of — comes for free from this design. A session that the user&lt;br&gt;
isn't currently looking at still has its &lt;code&gt;GrokRuntime&lt;/code&gt; alive in the&lt;br&gt;
pool, which means the child process is still running, which means the&lt;br&gt;
agent is still streaming events. When the user switches back, they&lt;br&gt;
see the latest state, not a blank panel that has to catch up.&lt;/p&gt;


&lt;h2&gt;
  
  
  Multi-provider abstraction
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grok-build&lt;/code&gt; defaults to xAI. We didn't want to be a single-vendor&lt;br&gt;
client — that's a strategic dead-end.&lt;/p&gt;

&lt;p&gt;The protocol makes this easy. The agent's &lt;code&gt;initialize&lt;/code&gt; handshake&lt;br&gt;
returns its &lt;code&gt;availableModels&lt;/code&gt; list, and the &lt;code&gt;session/set_model&lt;/code&gt;&lt;br&gt;
request switches between them. The CLI handles the provider-specific&lt;br&gt;
plumbing (API keys, request shapes, streaming formats); we just tell&lt;br&gt;
it which model to use.&lt;/p&gt;

&lt;p&gt;Frontend side: a &lt;code&gt;useActiveModel()&lt;/code&gt; Zustand selector reads from the&lt;br&gt;
handshake's &lt;code&gt;availableModels&lt;/code&gt;. Switching models is a single IPC call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// store layer&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tauri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;start_session&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="na"&gt;workspacePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;workspace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeModel&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;providerId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;xai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;activeModel&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grok-4.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// rust bridge — the model is passed verbatim to the CLI&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Command&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;grok_bin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="nf"&gt;.env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"XAI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="nf"&gt;.args&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="nf"&gt;.args&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"--model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;  &lt;span class="c1"&gt;// or via `session/set_model` post-init&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upshot: the same UI works against xAI, OpenAI, Anthropic, Google,&lt;br&gt;
DeepSeek, OpenRouter, Ollama, and any OpenAI-compatible endpoint. As&lt;br&gt;
long as the agent runtime supports a model, the picker shows it.&lt;/p&gt;


&lt;h2&gt;
  
  
  The LANG/LC_ALL bug (and why it matters more than you'd think)
&lt;/h2&gt;

&lt;p&gt;The most embarrassing bug I shipped was this: the UI language&lt;br&gt;
picker stored the user's choice ("English" / "简体中文"), but I never&lt;br&gt;
threaded it through to the spawned &lt;code&gt;grok agent stdio&lt;/code&gt; process. The&lt;br&gt;
process inherited the system locale — zh_CN on my Mac — and replied in&lt;br&gt;
Chinese no matter what the UI said.&lt;/p&gt;

&lt;p&gt;The fix is one-liner, but the principle is bigger:&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;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lang_value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;locale_env_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="py"&gt;.locale&lt;/span&gt;&lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="nf"&gt;.env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LANG"&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;lang_value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="nf"&gt;.env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"LC_ALL"&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;lang_value&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;code&gt;LANG&lt;/code&gt; is the de-facto standard locale env var. &lt;code&gt;LC_ALL&lt;/code&gt; is the&lt;br&gt;
override (some CLIs check it before &lt;code&gt;LANG&lt;/code&gt;). Setting both is belt-and-&lt;br&gt;
suspenders. The mapping handles the two picker values explicitly:&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;locale_env_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="o"&gt;&amp;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="nb"&gt;String&lt;/span&gt;&lt;span class="o"&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;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="o"&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;posix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s"&gt;"en-US"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"en_US.UTF-8"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s"&gt;"zh-CN"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"zh"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"zh_CN.UTF-8"&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&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="n"&gt;other&lt;/span&gt;&lt;span class="nf"&gt;.replacen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"_"&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="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;other&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}.UTF-8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;posix&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 generic &lt;code&gt;'-' -&amp;gt; '_'&lt;/code&gt; fallthrough means a future &lt;code&gt;fr-FR&lt;/code&gt; picker&lt;br&gt;
just works without code changes.&lt;/p&gt;

&lt;p&gt;The principle: &lt;strong&gt;if your spawned subprocess has any user-facing&lt;br&gt;
output that touches language, the system locale isn't good enough&lt;/strong&gt;.&lt;br&gt;
The user's UI choice is the only thing that's actually under their&lt;br&gt;
control. Thread it through explicitly.&lt;/p&gt;


&lt;h2&gt;
  
  
  Permission modes: Ask / Plan / Build
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;grok-build&lt;/code&gt; has three sandbox modes — read-only, read-only + no shell,&lt;br&gt;
full access. I mapped them to Codex-style Ask / Plan / Build pickers&lt;br&gt;
in the UI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Picker&lt;/th&gt;
&lt;th&gt;Sandbox&lt;/th&gt;
&lt;th&gt;Shell&lt;/th&gt;
&lt;th&gt;File writes&lt;/th&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ask&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;strict&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;read-only&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Build&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The non-obvious bit: when the user switches modes, &lt;strong&gt;the agent has to&lt;br&gt;
be restarted&lt;/strong&gt;. The sandbox is set at process-creation time (it's an&lt;br&gt;
argv flag &lt;code&gt;--sandbox&lt;/code&gt;), so an in-flight runtime can't have its&lt;br&gt;
permissions downgraded.&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="c1"&gt;// On mode change in the UI:&lt;/span&gt;
&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;switch_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&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;old_rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="py"&gt;.runtimes&lt;/span&gt;&lt;span class="nf"&gt;.remove&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;session_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;old_rt&lt;/span&gt;&lt;span class="nf"&gt;.shutdown&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// graceful kill&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;new_rt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;GrokRuntime&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ...with new_mode... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_rt&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;This is annoying in practice — a 2-3 second stall when switching —&lt;br&gt;
but the alternative is the UI lying about what's safe. We chose the&lt;br&gt;
honest version.&lt;/p&gt;




&lt;h2&gt;
  
  
  Signing &amp;amp; distribution: the parts that aren't in the docs
&lt;/h2&gt;

&lt;p&gt;Things nobody tells you until you're shipping:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS ad-hoc signing is not "real" signing.&lt;/strong&gt; A signed-with-&lt;code&gt;-&lt;/code&gt;&lt;br&gt;
.app bundle is unsigned from Gatekeeper's perspective — first install&lt;br&gt;
triggers the "unidentified developer" prompt. The workaround is&lt;br&gt;
right-click → Open, or our &lt;code&gt;First-Run-Open-Me.command&lt;/code&gt; script that&lt;br&gt;
clears the &lt;code&gt;com.apple.quarantine&lt;/code&gt; xattr. To get out of this you need&lt;br&gt;
a real Apple Developer ID ($99/year) and a notarized build through&lt;br&gt;
&lt;code&gt;xcrun notarytool&lt;/code&gt;. We don't have either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Electron placeholder signature is broken.&lt;/strong&gt; Out of the box,&lt;br&gt;
electron-builder produces a Windows .exe whose Authenticode signature&lt;br&gt;
is broken in a specific way: the linker signs the binary as&lt;br&gt;
&lt;code&gt;Identifier=Electron&lt;/code&gt;, then electron-builder renames the executable&lt;br&gt;
to your product name, and the CodeDirectory says "no sealed resources"&lt;br&gt;
but the bundle has resources. Windows code-signing tools will reject&lt;br&gt;
this. macOS Sequoia Gatekeeper also rejects it with "damaged, can't be&lt;br&gt;
opened."&lt;/p&gt;

&lt;p&gt;The fix: a post-build &lt;code&gt;codesign --force --deep --sign -&lt;/code&gt; hook that&lt;br&gt;
re-signs the .app bundle with an ad-hoc identity after electron-builder&lt;br&gt;
finishes. We wire this via &lt;code&gt;electron-builder.yml&lt;/code&gt;'s &lt;code&gt;afterSign&lt;/code&gt; hook,&lt;br&gt;
which calls &lt;code&gt;scripts/afterSign.js&lt;/code&gt; (with &lt;code&gt;process.platform&lt;/code&gt; check to&lt;br&gt;
skip on non-darwin).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code-signing on Windows CI is a 5-minute job.&lt;/strong&gt; Tauri Windows uses&lt;br&gt;
WiX 3.14's &lt;code&gt;light.exe&lt;/code&gt;, which crashes on github-actions windows-latest&lt;br&gt;
with "failed to run ...\WixTools314\light.exe" (a known .NET Framework&lt;br&gt;
dependency issue). We work around it by passing &lt;code&gt;--bundles nsis&lt;/code&gt; to&lt;br&gt;
&lt;code&gt;tauri build&lt;/code&gt;, which skips the MSI target and only builds the NSIS&lt;br&gt;
installer (which uses &lt;code&gt;makensis&lt;/code&gt;, bundled with the Tauri CLI, no&lt;br&gt;
dependency hell).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The nested &lt;code&gt;packages/core/&lt;/code&gt; duplication is a maintenance hazard.&lt;/strong&gt;&lt;br&gt;
The Tauri and Electron runtimes are separate projects in the same&lt;br&gt;
repo. The Electron one vendors a copy of &lt;code&gt;packages/core/src/stores/&lt;/code&gt;&lt;br&gt;
into &lt;code&gt;electron-version/packages/core/src/&lt;/code&gt; so it can build&lt;br&gt;
independently. Every time the store changes (e.g., the new &lt;code&gt;locale&lt;/code&gt;&lt;br&gt;
parameter), you have to sync the nested copy. We don't yet have a&lt;br&gt;
pre-commit hook for this.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we'd build differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Stream the agent's plan/permission requests into a separate&lt;br&gt;
notification surface.&lt;/strong&gt; Right now they're inline tool-call cards&lt;br&gt;
in the conversation. They should be push notifications so the user&lt;br&gt;
can act on them while looking at another tab.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cache &lt;code&gt;grok agent stdio&lt;/code&gt;'s initialization.&lt;/strong&gt; Every session spawn&lt;br&gt;
re-does the JSON-RPC initialize handshake, which costs ~300ms.&lt;br&gt;
With enough workspace-state fingerprinting, we could skip the&lt;br&gt;
handshake for "this is the same workspace I just talked to" cases.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Open the multi-runtime pool to other backends.&lt;/strong&gt; Right now the&lt;br&gt;
pool is hard-coded to &lt;code&gt;grok agent stdio&lt;/code&gt;. Other ACP servers&lt;br&gt;
(OpenCode, Claude Code, Continue) could share the same pool&lt;br&gt;
infrastructure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Make &lt;code&gt;AGENTS.md&lt;/code&gt; aware.&lt;/strong&gt; Workspace-level rules override the&lt;br&gt;
UI language picker today (correctly — they're more specific). But&lt;br&gt;
the user has to discover this. A small badge in the chat header&lt;br&gt;
when an AGENTS.md is in play would help.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; timexingxin/grok-gui/grok-gui-lite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or grab the macOS DMG / Windows .exe from the&lt;br&gt;
&lt;a href="https://github.com/timexingxin/grok-gui/releases/latest" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;br&gt;
Source at&lt;br&gt;
&lt;a href="https://github.com/timexingxin/grok-gui" rel="noopener noreferrer"&gt;github.com/timexingxin/grok-gui&lt;/a&gt;.&lt;br&gt;
MIT-licensed. The wrapped &lt;code&gt;grok-build&lt;/code&gt; runtime is Apache-2.0 from&lt;br&gt;
&lt;a href="https://github.com/xai-org/grok-build" rel="noopener noreferrer"&gt;xai-org/grok-build&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you write a desktop client for an AI agent and hit any of these&lt;br&gt;
patterns, I'd love to compare notes — open an issue or hit me up on&lt;br&gt;
Twitter &lt;a href="https://twitter.com/timexingxin" rel="noopener noreferrer"&gt;@timexingxin&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>tauri</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
