{"slug": "building-a-desktop-client-for-an-ai-coding-agent", "title": "Building a desktop client for an AI coding agent", "summary": "A developer built a native desktop client for xAI's open-source Rust coding agent grok-build, using Tauri 2 for an ~8 MB binary with a React frontend and Rust runtime that communicates with the CLI over JSON-RPC 2.0. The project, available on GitHub under MIT license, aims to provide a real desktop UX while preserving the CLI's capabilities.", "body_md": "*Lessons from wrapping grok-build — the architecture, the traps, and why we picked Tauri over Electron.*\n\n`grok-build`\n\nis xAI's open-source Rust coding agent. It ships as a TUI. We\n\nwrote a native desktop client for it — Tauri 2 (~8 MB binary), React\n\nfrontend, Rust runtime that spawns the CLI as a child process and talks\n\nto it over ACP/JSON-RPC 2.0. This post is the architecture deep-dive:\n\nhow the pieces fit together, what surprised us, and the parts we'd\n\nbuild differently next time.\n\nThe full source is at [github.com/timexingxin/grok-gui](https://github.com/timexingxin/grok-gui).\n\nMIT-licensed. Demo GIF in the README.\n\n`grok-build`\n\nis genuinely good at code work — comparable to Claude Code\n\nfor my workflow. But it ships as a Rust TUI. After six months of\n\n`cmd+tab`\n\nbetween the terminal and my browser tabs, I wanted a real\n\ndesktop UX without losing what makes the CLI good.\n\nThe naive options all had problems:\n\nThe right answer was staring at me: `grok-build`\n\nalready has a\n\n**JSON-RPC 2.0 over stdio** interface called the Agent Client Protocol\n\n(ACP). That's the protocol I should be a client of. My job is just to\n\nwrite the client.\n\n[ACP](https://agentclientprotocol.com/) is a JSON-RPC 2.0 protocol that\n\ncoding-agent CLIs expose over their stdin/stdout. The agent emits\n\nnotifications (text deltas, tool calls, plan updates, permission\n\nrequests, session lifecycle); the client sends requests (user prompts,\n\npermission responses, model switches, session loads).\n\nIf your agent speaks ACP, you can write a client without re-implementing\n\nthe agent loop. You just connect to the stdio, parse the JSON frames,\n\nand render.\n\n```\n┌──────────────────────────────────────────┐\n│  Desktop Shell (Tauri 2, ~8 MB)          │   React + Vite + Tailwind\n└──────────────┬───────────────────────────┘\n               │ Tauri commands + events (typed)\n               ▼\n┌──────────────────────────────────────────┐\n│  Rust bridge (apps/desktop/src-tauri/src/grok_runtime.rs)\n│  - spawns `grok agent stdio` as a child │\n│  - JSON-RPC 2.0 over its stdin/stdout   │\n│  - LANG/LC_ALL forwarded for locale     │\n│  - manages a pool of live runtimes      │\n└──────────────┬───────────────────────────┘\n               │ subprocess stdin/stdout\n               ▼\n┌──────────────────────────────────────────┐\n│  Grok Build runtime (xai-org/grok-build) │   upstream, Apache-2.0\n│  Agent loop, tools, context, MCP, skills │\n└──────────────────────────────────────────┘\n```\n\nThe Rust bridge is the interesting part. It manages the lifecycle:\n\nspawn the child, do the initialize handshake (where the agent tells\n\nus its version and supported features), do a session/new or\n\nsession/load, stream every event into the frontend. The frontend just\n\nlistens for typed events and renders.\n\nI considered both. The decision matrix:\n\n| Tauri 2 | Electron | |\n|---|---|---|\nBinary size |\n~8 MB | ~150 MB |\nMemory (idle) |\n~80 MB | ~300 MB |\nWebview |\nOS-native WebKit / WebView2 | Bundled Chromium |\nNative feel |\nCloser (real OS widgets) | Web app |\nBackend language |\nRust | Node.js |\nIPC overhead |\nFunction call (typed structs) | JSON-over-IPC |\n\nTauri won for three reasons:\n\n**The backend was already going to be Rust.** `grok-build`\n\nis\n\nRust, the ACP client had to be Rust to spawn and talk to it\n\nproperly, and Rust is a good fit for the long-running\n\nprocess-pool pattern. Electron would have meant Rust + Node.js in\n\nthe same project.\n\n**8 MB vs 150 MB matters for distribution.** A coding tool\n\ninstaller shouldn't be larger than the Electron runtime itself\n\nis on disk. The 150 MB Electron app feels heavy on a MacBook\n\nwhere the rest of the system is small native binaries.\n\n**OS-native webview feels right.** WebKit on macOS renders\n\nidentically to Safari, which means the React app looks like a\n\nMac app, not a Windows-95 webpage. The title-bar-style=\"hiddenInset\"\n\nThe tradeoff: Tauri requires the MSVC build tools on Windows. But\n\n`windows-latest`\n\nGitHub Actions runners have those preinstalled, so\n\nthe CI cost is the same.\n\nThe killer feature for me is having multiple conversations going at\n\nonce. If I'm asking Grok to refactor one file while waiting on a\n\ndifferent task, I don't want to lose the first turn's stream when I\n\nswitch.\n\nNaive implementation: spawn N CLI processes on app startup. Wasteful\n\nfor users who only have one session going.\n\nWhat I built: a **pool of live runtimes**, LRU-evicted on idle.\n\n```\n// pseudocode\nstruct RuntimePool {\n    runtimes: HashMap<SessionId, GrokRuntime>,\n    capacity: usize,\n}\n\nfn acquire(pool: &mut RuntimePool, session_id: SessionId) -> GrokRuntime {\n    if let Some(rt) = pool.runtimes.remove(&session_id) {\n        // LRU hit: re-use the existing process. No spawn cost.\n        return rt;\n    }\n    if pool.runtimes.len() >= pool.capacity {\n        // Evict the least-recently-used idle runtime.\n        let (victim_id, victim_rt) = pool.runtimes.pop_lru();\n        victim_rt.shutdown();\n    }\n    // Spawn a new CLI subprocess.\n    GrokRuntime::spawn(...)\n}\n```\n\nWhen a user starts a new session, the pool looks up an existing live\n\nruntime for that session id. If found, it reuses it — no spawn cost,\n\nno handshake re-init. If the pool is at capacity, it shuts down the\n\nleast-recently-used idle runtime (a graceful kill, waiting up to 5s for\n\nthe agent to settle). Otherwise it spawns a new CLI.\n\nThe LRU eviction is critical because `grok agent stdio`\n\ncan hold\n\nhundreds of MB of context in memory. Without eviction, opening a few\n\nsessions would have eaten all available RAM. With eviction, idle\n\nsessions are cleaned up and the user's working set stays bounded.\n\nThe \"background turn keeps streaming\" behavior — the bit I'm most\n\nproud of — comes for free from this design. A session that the user\n\nisn't currently looking at still has its `GrokRuntime`\n\nalive in the\n\npool, which means the child process is still running, which means the\n\nagent is still streaming events. When the user switches back, they\n\nsee the latest state, not a blank panel that has to catch up.\n\n`grok-build`\n\ndefaults to xAI. We didn't want to be a single-vendor\n\nclient — that's a strategic dead-end.\n\nThe protocol makes this easy. The agent's `initialize`\n\nhandshake\n\nreturns its `availableModels`\n\nlist, and the `session/set_model`\n\nrequest switches between them. The CLI handles the provider-specific\n\nplumbing (API keys, request shapes, streaming formats); we just tell\n\nit which model to use.\n\nFrontend side: a `useActiveModel()`\n\nZustand selector reads from the\n\nhandshake's `availableModels`\n\n. Switching models is a single IPC call.\n\n``` js\n// store layer\nconst resp = await tauri.invoke(\"start_session\", {\n  workspacePath: workspace,\n  provider: get().activeModel?.providerId ?? \"xai\",\n  model: get().activeModel?.id ?? \"grok-4.5\",\n  // ...\n  locale: language ?? get().settings.language,\n});\njs\n// rust bridge — the model is passed verbatim to the CLI\nlet mut cmd = Command::new(&grok_bin);\ncmd.env(\"XAI_API_KEY\", api_key);\n// ...\ncmd.args([\"agent\", \"stdio\"]);\ncmd.args([\"--model\", model]);  // or via `session/set_model` post-init\n```\n\nThe upshot: the same UI works against xAI, OpenAI, Anthropic, Google,\n\nDeepSeek, OpenRouter, Ollama, and any OpenAI-compatible endpoint. As\n\nlong as the agent runtime supports a model, the picker shows it.\n\nThe most embarrassing bug I shipped was this: the UI language\n\npicker stored the user's choice (\"English\" / \"简体中文\"), but I never\n\nthreaded it through to the spawned `grok agent stdio`\n\nprocess. The\n\nprocess inherited the system locale — zh_CN on my Mac — and replied in\n\nChinese no matter what the UI said.\n\nThe fix is one-liner, but the principle is bigger:\n\n``` js\nif let Some(lang_value) = locale_env_value(options.locale.as_deref()) {\n    cmd.env(\"LANG\", &lang_value);\n    cmd.env(\"LC_ALL\", &lang_value);\n}\n```\n\n`LANG`\n\nis the de-facto standard locale env var. `LC_ALL`\n\nis the\n\noverride (some CLIs check it before `LANG`\n\n). Setting both is belt-and-\n\nsuspenders. The mapping handles the two picker values explicitly:\n\n``` php\nfn locale_env_value(locale: Option<&str>) -> Option<String> {\n    let tag = locale?;\n    let posix = match tag {\n        \"en-US\" | \"en\" => \"en_US.UTF-8\".to_string(),\n        \"zh-CN\" | \"zh\" => \"zh_CN.UTF-8\".to_string(),\n        other if other.contains('-') => other.replacen('-', \"_\", 1),\n        other => other.to_string(),\n    };\n    Some(format!(\"{}.UTF-8\", posix))\n}\n```\n\nThe generic `'-' -> '_'`\n\nfallthrough means a future `fr-FR`\n\npicker\n\njust works without code changes.\n\nThe principle: **if your spawned subprocess has any user-facing\noutput that touches language, the system locale isn't good enough**.\n\n`grok-build`\n\nhas three sandbox modes — read-only, read-only + no shell,\n\nfull access. I mapped them to Codex-style Ask / Plan / Build pickers\n\nin the UI:\n\n| Picker | Sandbox | Shell | File writes | Network |\n|---|---|---|---|---|\nAsk |\nstrict | ❌ | ❌ | ✅ |\nPlan |\nread-only | ❌ | ❌ | ✅ |\nBuild |\noff | ✅ | ✅ | ✅ |\n\nThe non-obvious bit: when the user switches modes, **the agent has to\nbe restarted**. The sandbox is set at process-creation time (it's an\n\n`--sandbox`\n\n), so an in-flight runtime can't have its\n\n```\n// On mode change in the UI:\nfn switch_mode(session_id: SessionId, new_mode: String) {\n    let old_rt = pool.runtimes.remove(&session_id);\n    old_rt.shutdown();          // graceful kill\n    let new_rt = GrokRuntime::spawn(/* ...with new_mode... */);\n    pool.insert(session_id, new_rt);\n}\n```\n\nThis is annoying in practice — a 2-3 second stall when switching —\n\nbut the alternative is the UI lying about what's safe. We chose the\n\nhonest version.\n\nThings nobody tells you until you're shipping:\n\n**macOS ad-hoc signing is not \"real\" signing.** A signed-with-`-`\n\n.app bundle is unsigned from Gatekeeper's perspective — first install\n\ntriggers the \"unidentified developer\" prompt. The workaround is\n\nright-click → Open, or our `First-Run-Open-Me.command`\n\nscript that\n\nclears the `com.apple.quarantine`\n\nxattr. To get out of this you need\n\na real Apple Developer ID ($99/year) and a notarized build through\n\n`xcrun notarytool`\n\n. We don't have either.\n\n**The Electron placeholder signature is broken.** Out of the box,\n\nelectron-builder produces a Windows .exe whose Authenticode signature\n\nis broken in a specific way: the linker signs the binary as\n\n`Identifier=Electron`\n\n, then electron-builder renames the executable\n\nto your product name, and the CodeDirectory says \"no sealed resources\"\n\nbut the bundle has resources. Windows code-signing tools will reject\n\nthis. macOS Sequoia Gatekeeper also rejects it with \"damaged, can't be\n\nopened.\"\n\nThe fix: a post-build `codesign --force --deep --sign -`\n\nhook that\n\nre-signs the .app bundle with an ad-hoc identity after electron-builder\n\nfinishes. We wire this via `electron-builder.yml`\n\n's `afterSign`\n\nhook,\n\nwhich calls `scripts/afterSign.js`\n\n(with `process.platform`\n\ncheck to\n\nskip on non-darwin).\n\n**Code-signing on Windows CI is a 5-minute job.** Tauri Windows uses\n\nWiX 3.14's `light.exe`\n\n, which crashes on github-actions windows-latest\n\nwith \"failed to run ...\\WixTools314\\light.exe\" (a known .NET Framework\n\ndependency issue). We work around it by passing `--bundles nsis`\n\nto\n\n`tauri build`\n\n, which skips the MSI target and only builds the NSIS\n\ninstaller (which uses `makensis`\n\n, bundled with the Tauri CLI, no\n\ndependency hell).\n\n**The nested packages/core/ duplication is a maintenance hazard.**\n\n`packages/core/src/stores/`\n\n`electron-version/packages/core/src/`\n\nso it can build`locale`\n\n**Stream the agent's plan/permission requests into a separate\nnotification surface.** Right now they're inline tool-call cards\n\n**Cache grok agent stdio's initialization.** Every session spawn\n\n**Open the multi-runtime pool to other backends.** Right now the\n\npool is hard-coded to `grok agent stdio`\n\n. Other ACP servers\n\n(OpenCode, Claude Code, Continue) could share the same pool\n\ninfrastructure.\n\n**Make AGENTS.md aware.** Workspace-level rules override the\n\n```\nbrew install --cask timexingxin/grok-gui/grok-gui-lite\n```\n\nOr grab the macOS DMG / Windows .exe from the\n\n[releases page](https://github.com/timexingxin/grok-gui/releases/latest).\n\nSource at\n\n[github.com/timexingxin/grok-gui](https://github.com/timexingxin/grok-gui).\n\nMIT-licensed. The wrapped `grok-build`\n\nruntime is Apache-2.0 from\n\n[xai-org/grok-build](https://github.com/xai-org/grok-build).\n\nIf you write a desktop client for an AI agent and hit any of these\n\npatterns, I'd love to compare notes — open an issue or hit me up on\n\nTwitter [@timexingxin](https://twitter.com/timexingxin).", "url": "https://wpnews.pro/news/building-a-desktop-client-for-an-ai-coding-agent", "canonical_source": "https://dev.to/timexingxin/building-a-desktop-client-for-an-ai-coding-agent-147n", "published_at": "2026-07-25 21:15:28+00:00", "updated_at": "2026-07-25 22:01:00.831015+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-agents", "large-language-models", "ai-products"], "entities": ["xAI", "grok-build", "Tauri 2", "React", "Rust", "Agent Client Protocol", "Electron", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/building-a-desktop-client-for-an-ai-coding-agent", "markdown": "https://wpnews.pro/news/building-a-desktop-client-for-an-ai-coding-agent.md", "text": "https://wpnews.pro/news/building-a-desktop-client-for-an-ai-coding-agent.txt", "jsonld": "https://wpnews.pro/news/building-a-desktop-client-for-an-ai-coding-agent.jsonld"}}