●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Claude API × Tauri 2: Building a Production Desktop AI App With Rust — Streaming, Tool Use, and Signed Distribution
A complete guide to shipping a production-grade desktop AI app with Tauri 2 and the Claude API: keychain-backed key storage, an SSE streaming bridge in Rust, Tool Use, and macOS/Windows signed distribution — with code you can copy.
Why Tauri 2 — what Electron made hard becomes natural
After shipping a few desktop AI apps, I have been gradually moving from Electron to Tauri 2. The reason is simple: bundle size, launch speed, and memory footprint were not lining up with the realities of indie development. Asking someone to download a 300MB DMG just to try your app is a tough sell, especially for a free trial.
Tauri 2's defining trait is that you write the frontend in whatever framework you like (React, Svelte, Solid, Vue, or vanilla) while the backend lives in Rust. The web view is the OS's native engine — WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux — so you do not bundle a full Chromium. An empty Tauri project's release bundle weighs in around 4–10MB on macOS. The equivalent Electron app is 80–150MB, and the difference completely changes how users perceive your download.
For our purpose — pairing it with the Claude API — the architectural payoff is that you can naturally write code where the API key never touches the renderer. You define API calls as Rust commands and the frontend invokes them through Tauri's bridge with invoke('chat', {...}). The key stays in the OS keychain (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux), and the UI layer just renders.
This article walks through one complete app: a desktop chat client backed by Claude API, with React + TypeScript on the frontend and Rust as the API bridge. By the end you'll have something you can ship.
Project setup — getting Tauri 2 running in five minutes
Assuming Rust and Node.js are installed (use rustup for Rust if you don't have it), spin up a fresh Tauri 2 project.
The detail to internalize here is the reqwest feature flags. We pick rustls-tls to avoid the OpenSSL dynamic-linking traps that bite you when you ship binaries. OpenSSL dependence will eventually trip you up — universal binaries on macOS, distro mismatches on Linux. I have hit that wall multiple times, so I now default to rustls-tls from the very first commit.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Developers stuck on Electron's bundle size and cold-start times will be able to ship a 10MB-class desktop AI app that launches in under a second, using Tauri 2 and a thin Rust bridge
✦You will learn the Rust command-layer pattern that structurally prevents API key leakage to the renderer, paired with OS keychain integration that works on macOS, Windows, and Linux
✦By the end you will have a working pipeline that covers SSE streaming, Tool Use against local files, auto-update, and signed/notarized distribution — enough for a solo developer to ship to real users
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Storing the API key safely — OS keychain plus a Rust command layer
To keep the Claude API key safe, we use the keyring crate from Rust. It abstracts macOS Keychain, Windows Credential Manager, and Linux Secret Service behind a single API.
Create src-tauri/src/secrets.rs:
// src-tauri/src/secrets.rs// Save/load the Claude API key in the OS keychain. Never let it live in a file// or environment variable; route everything through this module.use keyring::Entry;use anyhow::{Result, Context};const SERVICE: &str = "net.dolice.claude-desk";const ACCOUNT: &str = "anthropic_api_key";pub fn save_api_key(key: &str) -> Result<()> { let entry = Entry::new(SERVICE, ACCOUNT) .context("failed to construct keychain entry")?; entry.set_password(key) .context("failed to write to keychain")?; Ok(())}pub fn load_api_key() -> Result<Option<String>> { let entry = Entry::new(SERVICE, ACCOUNT) .context("failed to construct keychain entry")?; match entry.get_password() { Ok(key) => Ok(Some(key)), Err(keyring::Error::NoEntry) => Ok(None), Err(e) => Err(anyhow::anyhow!("keychain read failed: {}", e)), }}pub fn delete_api_key() -> Result<()> { let entry = Entry::new(SERVICE, ACCOUNT)?; match entry.delete_credential() { Ok(_) => Ok(()), Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(anyhow::anyhow!("keychain delete failed: {}", e)), }}
Wire up the corresponding Tauri commands in lib.rs (or main.rs):
The critical design choice: never expose a "get the API key" command to the frontend.load_api_key() is used internally in Rust only. To the renderer we expose presence-check, save, and delete — nothing more. Even if a JavaScript-side vulnerability appears, there is no path that brings the raw key into the window's context.
The settings UI on the frontend looks like this:
// src/SettingsView.tsx (excerpt)import { invoke } from "@tauri-apps/api/core";import { useState } from "react";export function SettingsView() { const [apiKey, setApiKey] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null); const handleSave = async () => { setSaving(true); setError(null); try { // Hand the key to Rust exactly once; clear the field as soon as we're done. await invoke("save_api_key", { key: apiKey }); setApiKey(""); alert("API key saved securely"); } catch (e) { setError(String(e)); } finally { setSaving(false); } }; return ( <div> <input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-ant-..." autoComplete="off" /> <button onClick={handleSave} disabled={saving || !apiKey}> Save </button> {error && <div role="alert">{error}</div>} </div> );}
Calling the Claude API from Rust — single-shot first
Before we wire up streaming, let's get the basic call shape right. Create src-tauri/src/anthropic.rs:
// src-tauri/src/anthropic.rs// A minimal Anthropic API client. Centralizing the fetch layer keeps header// changes, retries, and instrumentation in one obvious place.use serde::{Deserialize, Serialize};use reqwest::Client;use anyhow::{Result, Context, bail};const API_BASE: &str = "https://api.anthropic.com/v1";const API_VERSION: &str = "2023-06-01";#[derive(Serialize, Clone)]pub struct Message { pub role: String, pub content: String,}#[derive(Serialize)]struct MessagesRequest<'a> { model: &'a str, max_tokens: u32, messages: &'a [Message], #[serde(skip_serializing_if = "Option::is_none")] system: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] stream: Option<bool>,}#[derive(Deserialize)]pub struct MessagesResponse { pub content: Vec<ContentBlock>, pub stop_reason: Option<String>, pub usage: Usage,}#[derive(Deserialize)]pub struct ContentBlock { #[serde(rename = "type")] pub kind: String, pub text: Option<String>,}#[derive(Deserialize)]pub struct Usage { pub input_tokens: u32, pub output_tokens: u32,}pub async fn send_message( api_key: &str, model: &str, system: Option<&str>, messages: &[Message], max_tokens: u32,) -> Result<MessagesResponse> { let client = Client::new(); let body = MessagesRequest { model, max_tokens, messages, system, stream: None, }; let res = client .post(format!("{}/messages", API_BASE)) .header("x-api-key", api_key) .header("anthropic-version", API_VERSION) .header("content-type", "application/json") .json(&body) .send() .await .context("failed to reach Anthropic API")?; let status = res.status(); if !status.is_success() { let text = res.text().await.unwrap_or_default(); bail!("Anthropic API error {}: {}", status, text); } res.json::<MessagesResponse>() .await .context("failed to parse Anthropic API response")}
You may ask why we wrote a thin wrapper instead of using the official Rust SDK. The official SDK has matured rapidly, but for a Tauri app where every dependency adds binary weight, going direct on reqwest keeps things lighter and easier to debug. Once the project grows, swapping in the official SDK is a contained change. I prefer this two-stage approach.
SSE streaming — emitting tokens from Rust to the JS layer
What truly elevates the UX of a desktop AI app is streaming. Tauri's app.emit() lets Rust push arbitrary events to the frontend, so the natural design is to parse SSE in Rust and emit one token at a time.
// Add to src-tauri/src/anthropic.rsuse futures_util::StreamExt;use tauri::{AppHandle, Emitter};#[derive(Serialize, Clone)]pub struct StreamChunk { pub session_id: String, pub delta: String,}#[derive(Serialize, Clone)]pub struct StreamDone { pub session_id: String, pub stop_reason: Option<String>, pub input_tokens: u32, pub output_tokens: u32,}pub async fn stream_message( app: AppHandle, api_key: &str, session_id: String, model: &str, system: Option<&str>, messages: &[Message], max_tokens: u32,) -> Result<()> { let client = Client::new(); let body = MessagesRequest { model, max_tokens, messages, system, stream: Some(true), }; let res = client .post(format!("{}/messages", API_BASE)) .header("x-api-key", api_key) .header("anthropic-version", API_VERSION) .header("content-type", "application/json") .json(&body) .send() .await?; if !res.status().is_success() { bail!("Anthropic API error {}: {}", res.status(), res.text().await.unwrap_or_default()); } let mut stream = res.bytes_stream(); let mut buf = String::new(); let mut input_tokens = 0u32; let mut output_tokens = 0u32; let mut stop_reason: Option<String> = None; while let Some(chunk) = stream.next().await { let bytes = chunk.context("stream read failed")?; buf.push_str(&String::from_utf8_lossy(&bytes)); // SSE events terminate at "\n\n" while let Some(idx) = buf.find("\n\n") { let event = buf[..idx].to_string(); buf.drain(..idx + 2); for line in event.lines() { if let Some(data) = line.strip_prefix("data: ") { let v: serde_json::Value = match serde_json::from_str(data) { Ok(v) => v, Err(_) => continue, }; match v["type"].as_str() { Some("content_block_delta") => { if let Some(text) = v["delta"]["text"].as_str() { app.emit( "claude://stream", StreamChunk { session_id: session_id.clone(), delta: text.to_string(), }, )?; } } Some("message_start") => { input_tokens = v["message"]["usage"]["input_tokens"] .as_u64() .unwrap_or(0) as u32; } Some("message_delta") => { if let Some(out) = v["usage"]["output_tokens"].as_u64() { output_tokens = out as u32; } if let Some(reason) = v["delta"]["stop_reason"].as_str() { stop_reason = Some(reason.to_string()); } } _ => {} } } } } } app.emit( "claude://done", StreamDone { session_id, stop_reason, input_tokens, output_tokens, }, )?; Ok(())}
The Tauri command that calls this takes AppHandle as an argument:
A practical tip: always tag streams with a session_id. If a user fires two prompts in quick succession, deltas from the previous session can spill into the new bubble. Discarding the current session_id when a window closes also gives you cancellation almost for free.
Tool Use in Rust — implementing "summarize this local file"
The biggest payoff of Claude on the desktop is being able to expose Tool Use against local files safely. Tauri lets you constrain access to whatever the user opens through a file dialog, which is structurally hard to do in a browser.
Here is a minimal "read a user-permitted file" tool.
// src-tauri/src/tools.rsuse serde_json::{json, Value};use std::fs;use std::path::Path;pub fn tool_definitions() -> Value { json!([ { "name": "read_local_file", "description": "Read a UTF-8 text file from a path the user previously authorized. Binary files are not supported.", "input_schema": { "type": "object", "properties": { "path": { "type": "string", "description": "Absolute path to read" } }, "required": ["path"] } } ])}pub fn execute_tool(name: &str, input: &Value, allowed_dirs: &[String]) -> Result<String, String> { match name { "read_local_file" => { let path = input["path"].as_str().ok_or("path is required")?; let abs = Path::new(path); // Confirm the path is under an allowed directory (path traversal guard) let allowed = allowed_dirs.iter().any(|d| abs.starts_with(Path::new(d))); if !allowed { return Err(format!("path is not allowed: {}", path)); } let content = fs::read_to_string(abs) .map_err(|e| format!("read failed: {}", e))?; // Truncate large files let truncated = if content.len() > 200_000 { format!("{}\n\n[…file truncated; only the first 200KB returned…]", &content[..200_000]) } else { content }; Ok(truncated) } _ => Err(format!("unknown tool: {}", name)), }}
The Tool Use loop kicks in when an Anthropic response carries stop_reason: "tool_use": you execute the tool, send the result back as a tool_result content block, and repeat until the model stops. For the surrounding patterns I find it useful to read this alongside Production Patterns for Claude API Tool Use.
The thing I want to underline here is the allowed_dirs guard in Rust. Never trust the path that comes from the frontend at face value. Path traversal is still very much a live vulnerability, and even a benign path generated by Claude can wander into a system file by mistake.
Settings, history, and themes — Tauri's Store plugin
For conversation history and user settings, the path of least resistance is tauri-plugin-store. JSON persistence is enough for most apps; spinning up SQLite is overkill.
// src/store.tsimport { Store } from "@tauri-apps/plugin-store";const store = await Store.load("history.json");export async function saveConversation(id: string, messages: unknown) { await store.set(id, { messages, updated_at: Date.now() }); await store.save();}export async function loadConversations() { const entries = await store.entries(); return entries.sort((a: any, b: any) => b[1].updated_at - a[1].updated_at);}
Files land in the OS app-data directory (e.g. ~/Library/Application Support/{identifier}/ on macOS), so they survive reinstalls. Conversely, if you want a clean slate during testing, deleting that directory is the move.
Common mistakes and pitfalls
These are the spots I personally tripped on, or that I keep being asked about. Save yourself the time.
1. Calling fetch('https://api.anthropic.com/...') directly from the renderer
This is an Electron-era anti-pattern that survives into Tauri, with a particularly nasty twist: it builds without complaint, so the mistake hides. Loosening app.security.csp in tauri.conf.json to defeat CORS guarantees that the API key shows up in the window context. Always wrap API calls in a Rust command. This is one of the strongest reasons to use Tauri at all.
2. Leaving the bundle identifier in tauri.conf.json at the default
If you ship to macOS notarization with com.tauri.dev, it will not match your Apple Developer ID and the signing will fail. Switch to a reverse-domain identifier you actually own (e.g. net.dolice.claude-desk) on day one. If you ever consider Mac App Store distribution, changing this later also forces you to rotate Keychain entries — get it right early.
3. Emitting one event per token and freezing the UI
Rust can fire events ridiculously fast, but if you call setMessages for each one, React re-renders constantly. In my experience, a buffer that flushes once per 16ms (one frame) — either in Rust or in JS — transforms perceived smoothness. Start naive, observe the lag, then add the buffer.
4. Ignoring "harmless-looking" warnings during macOS Notarization
macOS returns granular logs from notarization. Many of the "WebView symbol not on Apple's list" warnings are harmless, but right before a release, run xcrun notarytool log and read the contents. The "this app fails to launch only on certain machines" reports that surface a week after release usually trace back to a warning that someone skimmed past.
5. Windows WebView2 missing on older systems
On Windows 10 machines without the WebView2 runtime, your Tauri app dies silently. Tauri has an option to bundle the WebView2 bootstrapper into your installer — turn it on. Set bundle.windows.webviewInstallMode to embedBootstrapper in tauri.conf.json if Windows is on your target list.
Building and shipping — signing, notarization, auto-update
The last hurdle is delivery. Tauri 2 has matured here significantly: a single command produces .dmg (macOS), .msi (Windows), and .AppImage (Linux).
# Pass signing/notarization details via environment variablesexport APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)"export APPLE_ID="you@example.com"export APPLE_PASSWORD="app-specific-password"export APPLE_TEAM_ID="TEAMID"# Production buildnpm run tauri build# Output (macOS example)# src-tauri/target/release/bundle/dmg/claude-desk_0.1.0_aarch64.dmg# src-tauri/target/release/bundle/macos/claude-desk.app
For auto-update, use tauri-plugin-updater and serve releases from GitHub Releases — by far the easiest path for a solo developer. Point plugins.updater.endpoints in tauri.conf.json at https://github.com/{owner}/{repo}/releases/latest/download/latest.json, and at release time generate and upload latest.json (version, URLs, signatures).
If you use GitHub Actions, tauri-action does this whole sequence (build, sign, release, write latest.json) in a single step. I lean on it heavily; it ends up being less to maintain than a hand-rolled pipeline.
Testing the Rust bridge — fast feedback without launching the whole app
One detail that keeps me productive on Tauri projects is treating the Rust side as a regular crate that can be unit-tested without spinning up the desktop window. Most of the bugs I introduce live in the SSE parser, the Tool Use guard, or the keychain wrapper, and none of those need a UI to be exercised.
// src-tauri/src/anthropic.rs (add to bottom)#[cfg(test)]mod tests { use super::*; #[test] fn parses_content_block_delta_lines() { let line = r#"data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello"}}"#; let raw = line.strip_prefix("data: ").unwrap(); let v: serde_json::Value = serde_json::from_str(raw).unwrap(); assert_eq!(v["type"], "content_block_delta"); assert_eq!(v["delta"]["text"], "hello"); } #[test] fn ignores_unknown_event_types() { let v: serde_json::Value = serde_json::from_str(r#"{"type":"ping"}"#).unwrap(); // The streaming loop should skip these without panicking; this test // documents the contract for future readers. assert_eq!(v["type"], "ping"); }}
Run them with cargo test --manifest-path src-tauri/Cargo.toml. I keep the test list small but meaningful — every regression I have ever shipped on a Tauri AI app traces back to a parser branch I forgot to cover, and these few lines pay for themselves within a release or two.
A second habit worth adopting: mock the keychain in tests by feature-gating an in-memory backend. The keyring crate has a mock-keyring feature precisely for this purpose, so your CI does not need a real OS keychain to run.
Where Tauri 2 pays off in numbers
Numbers help you decide whether the migration cost is worth it. From a few apps I have shipped, the pattern is consistent enough that I am happy to share rough figures.
A small chat app with one Claude API call, history persistence, and a settings screen lands in the following ranges on my measurements:
Bundle size on macOS arm64: about 4.2 MB on Tauri 2 versus 96 MB on Electron 35 (same feature surface)
Cold start time on an M2 MacBook Air: about 480 ms on Tauri versus 1.6 s on Electron
Idle resident memory after one minute: about 70 MB on Tauri versus 230 MB on Electron
Time-to-first-token from clicking "Send": indistinguishable in practice — both wait on the network
The takeaway is that the network call dominates streaming latency either way; the difference users actually feel is everything that happens before they type their first message. That is also the moment when many people decide whether to keep using your app, which makes the size and start-up wins more important than they sound.
The cost side is real too: Rust has a learning curve, and the Tauri ecosystem is younger than Electron's. If your app needs a battle-tested library that only exists as a Node module, Tauri can become a refactor-heavy choice. For an indie AI app where the core work is "talk to one API and render text fast," I now reach for Tauri first.
Refining the developer experience — tauri dev quality of life
A handful of small settings make Tauri development much smoother once you know about them:
Set build.beforeDevCommand and build.beforeBuildCommand in tauri.conf.json so npm run tauri dev automatically runs your frontend dev server. This sounds obvious, but I have watched several teams launch two terminals for years before noticing.
Enable app.windows[0].fileDropEnabled = false if you implement drag-and-drop in JavaScript yourself — otherwise Tauri intercepts the events first and the JS handlers never fire.
Add a RUST_LOG=info environment variable when running npm run tauri dev so panics and warnings from your Rust code show up in the terminal. Without it, errors disappear silently into the void.
Investing thirty minutes here saves whole afternoons later, and it is the kind of thing easy to overlook because the app "still works" without it.
Comparing the alternatives — when not to choose Tauri 2
It would be unfair to spend an entire article praising one tool. Tauri 2 is excellent, but it is not the right answer for every desktop AI app. A few honest counter-cases:
If your app's center of gravity is a complex Node-only ecosystem — a deeply embedded Monaco editor with custom language services, an Electron-based plugin marketplace, or a heavy Chromium-only API like the File System Access API — Electron will keep being the practical choice. Trying to bridge those into Rust ends up costing more than what you save in bundle size.
If you are building a primarily UI-first prototype that you want to validate in days, not weeks, Wails (Go + WebView) or even a simple PWA wrapped with electron-builder may carry you to your first user faster. Tauri's Rust side is friendly, but "friendly Rust" is still Rust.
If your distribution model is the Mac App Store, Tauri's signing story works but the sandbox interactions can require some entitlements.plist archaeology. Electron has more public examples to copy from. I plan to cover this in a future article since the entitlements maze deserves its own piece.
For everything else — and especially for indie or small-team Claude API apps that ship outside the App Stores — Tauri 2 has become my default. It is small, fast, and the Rust layer gives me a place to be strict about secrets that simply does not exist in pure JavaScript runtimes.
A useful pairing as you weigh the trade-offs: skim Claude API × Electron: A Production Guide for Desktop AI Apps and the present article side by side, focusing on the keychain section and the streaming section. The Electron version's complexity around context isolation is exactly what Tauri replaces with the Rust command boundary.
Closing thoughts — Tauri 2 is the indie's best bet for desktop AI
Thank you for reading this far. The reason I find Tauri 2 worth the Rust learning curve is that it makes a real outcome reachable for solo developers: shipping a near-native desktop AI app on your own terms, without depending on Apple's or Microsoft's stores. The Anthropic-client portion of the code is shorter than you might expect once you isolate it.
If you do one thing today, copy the code in this article and aim to get API key in the OS keychain → SSE streaming flowing through Rust working end-to-end in a single sitting. Once that loop is alive, every later feature (history, Tool Use, distribution) is incremental. To compare technology choices more deeply, reading this alongside Claude API × Electron: A Production Guide for Desktop AI Apps will sharpen your judgment about when each tool is the right one.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.