Lessons from wrapping grok-build โ the architecture, the traps, and why we picked Tauri over Electron.
grok-build
is xAI's open-source Rust coding agent. It ships as a TUI. We
wrote a native desktop client for it โ Tauri 2 (~8 MB binary), React
frontend, Rust runtime that spawns the CLI as a child process and talks
to it over ACP/JSON-RPC 2.0. This post is the architecture deep-dive:
how the pieces fit together, what surprised us, and the parts we'd
build differently next time.
The full source is at github.com/timexingxin/grok-gui.
MIT-licensed. Demo GIF in the README.
grok-build
is genuinely good at code work โ comparable to Claude Code
for my workflow. But it ships as a Rust TUI. After six months of
cmd+tab
between the terminal and my browser tabs, I wanted a real
desktop UX without losing what makes the CLI good.
The naive options all had problems:
The right answer was staring at me: grok-build
already has a
JSON-RPC 2.0 over stdio interface called the Agent Client Protocol
(ACP). That's the protocol I should be a client of. My job is just to
write the client.
ACP is a JSON-RPC 2.0 protocol that
coding-agent CLIs expose over their stdin/stdout. The agent emits
notifications (text deltas, tool calls, plan updates, permission
requests, session lifecycle); the client sends requests (user prompts,
permission responses, model switches, session loads).
If your agent speaks ACP, you can write a client without re-implementing
the agent loop. You just connect to the stdio, parse the JSON frames,
and render.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ 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 โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Rust bridge is the interesting part. It manages the lifecycle:
spawn the child, do the initialize handshake (where the agent tells
us its version and supported features), do a session/new or
session/load, stream every event into the frontend. The frontend just
listens for typed events and renders.
I considered both. The decision matrix:
| Tauri 2 | Electron | |
|---|---|---|
| Binary size | ||
| ~8 MB | ~150 MB | |
| Memory (idle) | ||
| ~80 MB | ~300 MB | |
| Webview | ||
| OS-native WebKit / WebView2 | Bundled Chromium | |
| Native feel | ||
| Closer (real OS widgets) | Web app | |
| Backend language | ||
| Rust | Node.js | |
| IPC overhead | ||
| Function call (typed structs) | JSON-over-IPC |
Tauri won for three reasons:
The backend was already going to be Rust. grok-build
is
Rust, the ACP client had to be Rust to spawn and talk to it
properly, and Rust is a good fit for the long-running
process-pool pattern. Electron would have meant Rust + Node.js in
the same project.
8 MB vs 150 MB matters for distribution. A coding tool
installer shouldn't be larger than the Electron runtime itself
is on disk. The 150 MB Electron app feels heavy on a MacBook
where the rest of the system is small native binaries.
OS-native webview feels right. WebKit on macOS renders
identically to Safari, which means the React app looks like a
Mac app, not a Windows-95 webpage. The title-bar-style="hiddenInset"
The tradeoff: Tauri requires the MSVC build tools on Windows. But
windows-latest
GitHub Actions runners have those preinstalled, so
the CI cost is the same.
The killer feature for me is having multiple conversations going at
once. If I'm asking Grok to refactor one file while waiting on a
different task, I don't want to lose the first turn's stream when I
switch.
Naive implementation: spawn N CLI processes on app startup. Wasteful
for users who only have one session going.
What I built: a pool of live runtimes, LRU-evicted on idle.
// pseudocode
struct RuntimePool {
runtimes: HashMap<SessionId, GrokRuntime>,
capacity: usize,
}
fn acquire(pool: &mut RuntimePool, session_id: SessionId) -> GrokRuntime {
if let Some(rt) = pool.runtimes.remove(&session_id) {
// LRU hit: re-use the existing process. No spawn cost.
return rt;
}
if pool.runtimes.len() >= pool.capacity {
// Evict the least-recently-used idle runtime.
let (victim_id, victim_rt) = pool.runtimes.pop_lru();
victim_rt.shutdown();
}
// Spawn a new CLI subprocess.
GrokRuntime::spawn(...)
}
When a user starts a new session, the pool looks up an existing live
runtime for that session id. If found, it reuses it โ no spawn cost,
no handshake re-init. If the pool is at capacity, it shuts down the
least-recently-used idle runtime (a graceful kill, waiting up to 5s for
the agent to settle). Otherwise it spawns a new CLI.
The LRU eviction is critical because grok agent stdio
can hold
hundreds of MB of context in memory. Without eviction, opening a few
sessions would have eaten all available RAM. With eviction, idle
sessions are cleaned up and the user's working set stays bounded.
The "background turn keeps streaming" behavior โ the bit I'm most
proud of โ comes for free from this design. A session that the user
isn't currently looking at still has its GrokRuntime
alive in the
pool, which means the child process is still running, which means the
agent is still streaming events. When the user switches back, they
see the latest state, not a blank panel that has to catch up.
grok-build
defaults to xAI. We didn't want to be a single-vendor
client โ that's a strategic dead-end.
The protocol makes this easy. The agent's initialize
handshake
returns its availableModels
list, and the session/set_model
request switches between them. The CLI handles the provider-specific
plumbing (API keys, request shapes, streaming formats); we just tell
it which model to use.
Frontend side: a useActiveModel()
Zustand selector reads from the
handshake's availableModels
. Switching models is a single IPC call.
// store layer
const resp = await tauri.invoke("start_session", {
workspacePath: workspace,
provider: get().activeModel?.providerId ?? "xai",
model: get().activeModel?.id ?? "grok-4.5",
// ...
locale: language ?? get().settings.language,
});
js
// rust bridge โ the model is passed verbatim to the CLI
let mut cmd = Command::new(&grok_bin);
cmd.env("XAI_API_KEY", api_key);
// ...
cmd.args(["agent", "stdio"]);
cmd.args(["--model", model]); // or via `session/set_model` post-init
The upshot: the same UI works against xAI, OpenAI, Anthropic, Google,
DeepSeek, OpenRouter, Ollama, and any OpenAI-compatible endpoint. As
long as the agent runtime supports a model, the picker shows it.
The most embarrassing bug I shipped was this: the UI language
picker stored the user's choice ("English" / "็ฎไฝไธญๆ"), but I never
threaded it through to the spawned grok agent stdio
process. The
process inherited the system locale โ zh_CN on my Mac โ and replied in
Chinese no matter what the UI said.
The fix is one-liner, but the principle is bigger:
if let Some(lang_value) = locale_env_value(options.locale.as_deref()) {
cmd.env("LANG", &lang_value);
cmd.env("LC_ALL", &lang_value);
}
LANG
is the de-facto standard locale env var. LC_ALL
is the
override (some CLIs check it before LANG
). Setting both is belt-and-
suspenders. The mapping handles the two picker values explicitly:
fn locale_env_value(locale: Option<&str>) -> Option<String> {
let tag = locale?;
let posix = match tag {
"en-US" | "en" => "en_US.UTF-8".to_string(),
"zh-CN" | "zh" => "zh_CN.UTF-8".to_string(),
other if other.contains('-') => other.replacen('-', "_", 1),
other => other.to_string(),
};
Some(format!("{}.UTF-8", posix))
}
The generic '-' -> '_'
fallthrough means a future fr-FR
picker
just works without code changes.
The principle: if your spawned subprocess has any user-facing output that touches language, the system locale isn't good enough.
grok-build
has three sandbox modes โ read-only, read-only + no shell,
full access. I mapped them to Codex-style Ask / Plan / Build pickers
in the UI:
| Picker | Sandbox | Shell | File writes | Network |
|---|---|---|---|---|
| Ask | ||||
| strict | โ | โ | โ | |
| Plan | ||||
| read-only | โ | โ | โ | |
| Build | ||||
| off | โ | โ | โ |
The non-obvious bit: when the user switches modes, the agent has to be restarted. The sandbox is set at process-creation time (it's an
--sandbox
), so an in-flight runtime can't have its
// On mode change in the UI:
fn switch_mode(session_id: SessionId, new_mode: String) {
let old_rt = pool.runtimes.remove(&session_id);
old_rt.shutdown(); // graceful kill
let new_rt = GrokRuntime::spawn(/* ...with new_mode... */);
pool.insert(session_id, new_rt);
}
This is annoying in practice โ a 2-3 second stall when switching โ
but the alternative is the UI lying about what's safe. We chose the
honest version.
Things nobody tells you until you're shipping:
macOS ad-hoc signing is not "real" signing. A signed-with--
.app bundle is unsigned from Gatekeeper's perspective โ first install
triggers the "unidentified developer" prompt. The workaround is
right-click โ Open, or our First-Run-Open-Me.command
script that
clears the com.apple.quarantine
xattr. To get out of this you need
a real Apple Developer ID ($99/year) and a notarized build through
xcrun notarytool
. We don't have either.
The Electron placeholder signature is broken. Out of the box,
electron-builder produces a Windows .exe whose Authenticode signature
is broken in a specific way: the linker signs the binary as
Identifier=Electron
, then electron-builder renames the executable
to your product name, and the CodeDirectory says "no sealed resources"
but the bundle has resources. Windows code-signing tools will reject
this. macOS Sequoia Gatekeeper also rejects it with "damaged, can't be
opened."
The fix: a post-build codesign --force --deep --sign -
hook that
re-signs the .app bundle with an ad-hoc identity after electron-builder
finishes. We wire this via electron-builder.yml
's afterSign
hook,
which calls scripts/afterSign.js
(with process.platform
check to
skip on non-darwin).
Code-signing on Windows CI is a 5-minute job. Tauri Windows uses
WiX 3.14's light.exe
, which crashes on github-actions windows-latest
with "failed to run ...\WixTools314\light.exe" (a known .NET Framework
dependency issue). We work around it by passing --bundles nsis
to
tauri build
, which skips the MSI target and only builds the NSIS
installer (which uses makensis
, bundled with the Tauri CLI, no
dependency hell).
The nested packages/core/ duplication is a maintenance hazard.
packages/core/src/stores/
electron-version/packages/core/src/
so it can buildlocale
Stream the agent's plan/permission requests into a separate notification surface. Right now they're inline tool-call cards
Cache grok agent stdio's initialization. Every session spawn
Open the multi-runtime pool to other backends. Right now the
pool is hard-coded to grok agent stdio
. Other ACP servers
(OpenCode, Claude Code, Continue) could share the same pool
infrastructure.
Make AGENTS.md aware. Workspace-level rules override the
brew install --cask timexingxin/grok-gui/grok-gui-lite
Or grab the macOS DMG / Windows .exe from the
Source at
github.com/timexingxin/grok-gui.
MIT-licensed. The wrapped grok-build
runtime is Apache-2.0 from
If you write a desktop client for an AI agent and hit any of these
patterns, I'd love to compare notes โ open an issue or hit me up on
Twitter @timexingxin.