← All posts
·20 min read

Claude Code with Tauri: Build Rust-Backed Desktop Apps

Claude CodeTauriRustWorkflow
Claude Code with Tauri: Build Rust-Backed Desktop Apps

Why Tauri needs a two-language CLAUDE.md

Tauri is one of the fastest-growing desktop frameworks in the Rust ecosystem. The promise is compelling: ship a cross-platform desktop app with a web frontend you already know and a Rust backend that handles the heavy lifting. Binaries come in around 600KB compared to the 150-200MB that Electron produces. The security model is locked down by default. Mobile targets are shipping in v2.

Claude Code knows Tauri. It can scaffold a project, write a Rust command, wire up the invoke call in JavaScript, and configure tauri.conf.json. The problem is that Tauri's two-language architecture gives Claude twice the surface area to make incorrect assumptions. Rust and TypeScript have different conventions, different lifetimes, different error patterns, and different async models. The IPC bridge between them has specific rules about what types can cross it and what permissions the allowlist must grant before anything works. The security model closes every capability by default and requires explicit allowlist entries for shell access, filesystem access, HTTP requests, and clipboard operations.

Without a CLAUDE.md that describes your specific posture, Claude defaults to a combination of Tauri v1 and v2 patterns, forgets to register commands in the invoke_handler, generates async Rust commands that conflict with synchronous Rust state, and produces JavaScript that calls invoke with argument shapes the Rust side has never seen. It also has no way to know whether you are targeting Windows, macOS, Linux, or mobile, which changes which bundler targets and signing configurations are relevant.

This guide gives you the CLAUDE.md configuration and patterns that anchor Claude Code to one coherent Tauri v2 implementation. If you are new to Claude Code setup, the Claude Code setup guide covers installation and basic configuration. For the Rust-specific conventions that underpin the backend side of this guide, Claude Code with Rust is a direct companion.

The Tauri CLAUDE.md template

Your CLAUDE.md lives at the project root and is read at the start of every session. For a Tauri project it needs to declare the framework version, the project layout, which capabilities are open in the allowlist, how commands are structured, the IPC bridge conventions, and the hard rules that prevent the failure modes Claude produces without context.

Here is a working template covering the core of a Tauri v2 project:

# Tauri v2 project rules

## Stack
- Tauri 2.x (stable), tauri-cli 2.x
- Rust edition 2021, stable toolchain
- Frontend: React 19 + TypeScript 5.x strict, bundled by Vite 6.x
- @tauri-apps/api 2.x (JS API), @tauri-apps/plugin-* for optional plugins

## Project structure
- src-tauri/src/main.rs: app entry, .invoke_handler() registration
- src-tauri/src/lib.rs: tauri::Builder setup, exported to main.rs
- src-tauri/src/commands/: one file per command group (files.rs, db.rs, shell.rs)
- src-tauri/tauri.conf.json: all capability and bundler config lives here
- src-tauri/Cargo.toml: dependencies (serde, serde_json, tokio, sqlx as needed)
- src/: frontend (React, Vite), no Rust knowledge required in src/
- src/lib/tauri.ts: all invoke wrappers, typed, one source of truth

## IPC: invoke / emit / listen

### Rust command definition (always in src-tauri/src/commands/)
#[tauri::command]
async fn greet(name: String, state: tauri::State<'_, AppState>) -> Result<String, String> {
    Ok(format!("Hello, {}! Build count: {}", name, state.build_count.load(Ordering::Relaxed)))
}

### Registration in lib.rs (MANDATORY, Claude must do this every time)
tauri::Builder::default()
    .manage(AppState::default())
    .invoke_handler(tauri::generate_handler![
        greet,
        commands::files::read_file,
        commands::files::write_file,
        commands::db::query,
    ])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");

### JS invoke wrapper (always in src/lib/tauri.ts)
import { invoke } from "@tauri-apps/api/core";

export async function greet(name: string): Promise<string> {
    return invoke<string>("greet", { name });
}

## Argument serialisation rules
- Rust snake_case params map to JS camelCase via serde: #[serde(rename_all = "camelCase")]
- Structs must derive Serialize + Deserialize to cross the IPC boundary
- Return types must be Result<T, String> or Result<T, E> where E: Serialize
- NEVER return bare (), frontend gets undefined and error handling breaks

## Events: emit / listen
- Backend emit: app_handle.emit("event-name", payload)?;
- Frontend listen: import { listen } from "@tauri-apps/api/event"; const unlisten = await listen<Payload>("event-name", (e) => ...);
- Always call unlisten() on component unmount or you will accumulate dead listeners
- Events cross the IPC bridge; payloads must be Serialize on the Rust side

## State management (Rust)
- Use tauri::State for app-scoped shared state (Arc<Mutex<T>> or AtomicUsize for simple counters)
- State is injected into commands via the state parameter, NEVER use global statics
- Async commands must not hold a MutexGuard across an await point, deadlock risk

## tauri.conf.json allowlist (security model)
- ALL capabilities are CLOSED by default in Tauri v2
- Open only what the app needs; each capability is a named JSON file in src-tauri/capabilities/
- Current open capabilities: fs:read-app-dir, shell:open-external (for opening URLs), dialog:all
- Any new capability must be added as a named .json file in src-tauri/capabilities/ AND referenced in tauri.conf.json

## CSP (Content Security Policy)
- Tauri v2 enforces CSP on the webview
- Current policy in tauri.conf.json:
  "security": { "csp": "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'" }
- NEVER use 'unsafe-eval', it disables JIT and is a security regression
- NEVER use script-src *, allows arbitrary JS injection into the webview
- If a JS library requires 'unsafe-eval', find an alternative or vendor and hash it

## Bundler targets
- Primary: dmg (macOS), msi + nsis (Windows), deb + appimage (Linux)
- Build command: cargo tauri build --target <triple>
- Universal macOS (arm64 + x86_64): cargo tauri build --target universal-apple-darwin

## Code signing
- macOS: Developer ID Application cert in Keychain, notarisation via notarytool
- Windows: EV certificate via Windows SDK signtool
- Variables in CI: APPLE_SIGNING_IDENTITY, APPLE_ID, APPLE_TEAM_ID, APPLE_PASSWORD
- NEVER hardcode signing credentials, all from environment variables

## Hard rules
- ALWAYS register new commands in .invoke_handler() in lib.rs, unregistered commands silently return "command not found" at runtime
- NEVER call serde_json::to_string inside a command return, return the typed value, Tauri serialises it
- NEVER use process::exit(), call app.handle().exit(0) to let Tauri clean up
- NEVER open network connections from the webview directly, proxy through a Rust command or use allowed HTTP plugin
- ALWAYS derive Clone on state structs if they need to be passed across threads

This template does a lot of work across a small number of rules. Let us walk through the parts that matter most.

The invoke_handler registration rule is the single most important entry. Claude will generate a perfectly correct #[tauri::command] function, wire up the JavaScript invoke call, and then leave the command unregistered in lib.rs. The runtime error is command not found, which is cryptic if you do not know to look in the handler list. With the registration pattern in CLAUDE.md, Claude adds the command to tauri::generate_handler![] as part of the same generation step.

The Result return type rule prevents a subtle bug. When Claude generates a command that returns () on the Rust side, the JavaScript side receives undefined. That is fine until you check the return value in the frontend, or until an error occurs and you discover that bare () gives you no error information at all. Result<T, String> is the safe default: success gives the frontend a typed value, failure gives a string error message the frontend can surface.

The CSP rule matters because Tauri's embedded webview enforces the content security policy strictly, and several popular JavaScript libraries require 'unsafe-eval'. Adding it disables JIT compilation in the webview and opens an injection surface. The rule pushes Claude to find alternatives before reaching for the escape hatch.

The state management rule prevents a class of deadlock that Claude generates when it does not know your Rust async model. Holding a MutexGuard across an .await point compiles on stable Rust but can deadlock at runtime when another async task tries to acquire the same lock. The rule makes Claude use Arc<Mutex<T>> with a scoped lock pattern: acquire, read/write, drop before the next .await.

IPC patterns: invoke, events, and sidecars

The IPC bridge is where the two-language split creates the most surface area for mistakes. There are three communication patterns in Tauri v2 and Claude needs to know which to use for which scenario.

invoke is request-response. The JavaScript frontend calls a named Rust function, waits for the result, and handles the response. Use this for everything that has a defined input and output: reading a file, querying a database, performing a computation. It is the right default for 90% of frontend-to-backend communication.

Add the canonical invoke pattern to your src/lib/tauri.ts:

import { invoke } from "@tauri-apps/api/core";

// All invoke calls live here, typed, one per command
export async function readFile(path: string): Promise<string> {
    return invoke<string>("read_file", { path });
}

export async function writeFile(path: string, contents: string): Promise<void> {
    return invoke<void>("write_file", { path, contents });
}

export async function runQuery(sql: string, params: unknown[]): Promise<unknown[]> {
    return invoke<unknown[]>("run_query", { sql, params });
}

Centralising all invoke calls in one file is not required by Tauri, but it makes Claude's job much clearer. When CLAUDE.md says "all invoke wrappers live in src/lib/tauri.ts", Claude adds new wrappers there rather than scattering bare invoke calls across component files. Bare calls are a maintenance problem: the command name is a string, the argument shape is implicit, and a rename on the Rust side does not produce a TypeScript error.

Events are one-way broadcasts. The Rust backend emits an event, the frontend listens for it. Use this for streaming updates: progress bars, background job status, file system watchers, log streaming. The pattern on the Rust side requires an AppHandle:

use tauri::Manager;

#[tauri::command]
async fn start_long_job(app: tauri::AppHandle) -> Result<(), String> {
    tokio::spawn(async move {
        for i in 0..100 {
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            app.emit("job-progress", i).unwrap();
        }
        app.emit("job-complete", ()).unwrap();
    });
    Ok(())
}

On the frontend:

import { listen } from "@tauri-apps/api/event";
import { useEffect } from "react";

function ProgressBar() {
    useEffect(() => {
        let unlisten: (() => void) | undefined;

        listen<number>("job-progress", (event) => {
            setProgress(event.payload);
        }).then((fn) => {
            unlisten = fn;
        });

        return () => {
            unlisten?.();
        };
    }, []);
}

The unlisten cleanup is the part Claude forgets most often without the explicit rule. Every listen call registers a handler in the Tauri event bus. If the component unmounts and the handler stays registered, events keep firing into a component that no longer exists. In a React app with frequent navigation this accumulates into a memory and CPU leak. The rule in CLAUDE.md makes Claude generate the cleanup function every time, and the useEffect return pattern makes it idiomatic React.

Sidecars are external processes that Tauri bundles alongside the app binary and manages for you. Use them when you need to run a binary that cannot be a Rust library: a Python script, a compiled Go binary, an existing CLI tool. The sidecar must be listed in tauri.conf.json under bundle.externalBin and the shell capability must be open. Add a sidecar rule to CLAUDE.md:

## Sidecars (external bundled binaries)
- Sidecar binaries live in src-tauri/binaries/
- Naming convention: {name}-{target-triple} (e.g. my-cli-x86_64-unknown-linux-gnu)
- Listed in tauri.conf.json under bundle.externalBin
- Spawned via Command from @tauri-apps/plugin-shell (not via a bare invoke)
- Shell plugin must be open in capabilities: shell:allow-execute for the specific sidecar
- NEVER use JavaScript child_process, it does not exist in the Tauri webview
- NEVER spawn arbitrary executables, only listed sidecars with specific permissions

The last rule is the security-relevant one. The Tauri security model exists precisely to prevent the webview from spawning arbitrary processes. A CLAUDE.md that explicitly prohibits arbitrary exec calls ensures Claude reaches for the sidecar mechanism (auditable, listed in config) rather than trying to call a Node.js API that does not exist in the webview context.

Common failure modes and how to prevent them

Tauri has a specific set of failure modes that Claude generates without project context. These are the ones worth calling out explicitly in CLAUDE.md.

Unregistered commands. Covered above, but worth repeating because it is the number one Tauri debugging question. The error message "command not found" at runtime, with no compile-time signal, is the result of generating the function but skipping the tauri::generate_handler![] registration step. The fix is always the same: add the command to the handler list in lib.rs. Prevention: CLAUDE.md rule that makes registration mandatory.

Wrong serde rename convention. Rust uses snake_case by default. TypeScript uses camelCase by convention. Without #[serde(rename_all = "camelCase")] on your command parameter structs, the JavaScript side sends { fileName: "foo" } and Rust looks for file_name and finds nothing. The invoke call returns a type error that looks like the command does not exist. Add the derive to your CLAUDE.md template:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileArgs {
    pub file_name: String,
    pub contents: String,
    pub append: bool,
}

#[tauri::command]
async fn write_file(args: WriteFileArgs) -> Result<(), String> {
    // ...
    Ok(())
}

Allowlist gaps. In Tauri v2, every capability is off by default. If your app reads files, you need fs:read-app-dir or a more specific capability open. If it opens URLs in the browser, you need shell:open-external. If it shows a native file dialog, you need dialog:all or the specific dialog operations you use. Claude does not always know which capabilities your app needs, so it generates the Rust command or the JavaScript call and then leaves the capability closed. The symptom is a runtime error in the webview console: Error: path not allowed on the configured scope. The fix is adding the capability. Prevention: CLAUDE.md lists which capabilities are open and why, so Claude generates commands that stay within those bounds or flags when a new capability would be needed.

Async/sync Rust command mismatch. Tauri commands can be sync or async. Sync commands block the main thread; async commands run on Tokio. Claude mixes them based on whether the example it is referencing used async fn. If your command does any I/O (disk, network, database), it should be async. If it is a pure computation with no I/O, sync is fine. The practical rule: default to async fn for all commands, use sync only when you have a specific reason. Mixed sync/async commands in the same app are fine, but a sync command that accidentally calls an async library function will not compile. Add the rule to CLAUDE.md:

## Command async conventions
- Default: async fn for all commands that touch I/O (files, network, DB, subprocesses)
- Sync fn only for pure computations with no I/O (parsing, calculation, formatting)
- DO NOT use std::thread::sleep in an async command, use tokio::time::sleep
- DO NOT block the async runtime, offload CPU-heavy work to tokio::task::spawn_blocking

CSP violations from bundled assets. When you include a local font, icon set, or CSS library, the default CSP may reject it. font-src 'self' is required for local fonts. img-src 'self' data: is required for inline SVGs and data URIs. Claude sometimes generates code that loads assets from an external CDN, which the CSP blocks in the Tauri webview. The fix is either allowing the specific source in the CSP or vendoring the asset locally. Prevention: the CSP rule in CLAUDE.md makes Claude check whether an external asset URL would violate the policy before generating the import.

Rust state management and the AppHandle pattern

Tauri gives you two ways to hold application state: tauri::State for data injected at startup, and AppHandle for accessing app-level operations from anywhere. Understanding which to use prevents the most common Rust architecture mistakes Claude makes.

tauri::State is the right choice for configuration, database connections, and any data that lives for the lifetime of the app:

use std::sync::{Arc, Mutex};
use tauri::State;

pub struct AppState {
    pub db: Arc<Mutex<rusqlite::Connection>>,
    pub config: Arc<Config>,
}

#[tauri::command]
async fn get_settings(state: State<'_, AppState>) -> Result<Config, String> {
    let config = state.config.clone();
    Ok((*config).clone())
}

#[tauri::command]
async fn save_note(
    content: String,
    state: State<'_, AppState>,
) -> Result<i64, String> {
    let db = state.db.lock().map_err(|e| e.to_string())?;
    db.execute("INSERT INTO notes (content) VALUES (?1)", [&content])
        .map_err(|e| e.to_string())?;
    Ok(db.last_insert_rowid())
}

The Arc<Mutex<T>> pattern wraps the database connection because tauri::State requires Send + Sync, and rusqlite::Connection is neither by default. The Arc handles shared ownership across threads; the Mutex handles mutual exclusion. The important pattern: lock() returns a MutexGuard, which you use and then drop before any .await point.

AppHandle is the right choice when you need to emit events, access the window manager, or exit the app from inside a command or a background task:

use tauri::Manager;

#[tauri::command]
async fn start_sync(app: tauri::AppHandle) -> Result<(), String> {
    let handle = app.clone();
    tokio::spawn(async move {
        // perform sync in background
        match perform_sync().await {
            Ok(items) => {
                handle.emit("sync-complete", items).unwrap();
            }
            Err(e) => {
                handle.emit("sync-error", e.to_string()).unwrap();
            }
        }
    });
    Ok(())
}

The command returns immediately with Ok(()) and the sync runs in a Tokio task. The frontend does not block waiting for the sync to finish; instead it listens for the sync-complete or sync-error event. This is the correct pattern for any long-running operation in Tauri. Claude will generate a blocking implementation, where the command awaits the sync directly, when it does not know your intent. The CLAUDE.md comment on this pattern is one line: "Long-running operations return Ok(()) immediately and emit an event on completion."

The broader Rust patterns for error propagation, async runtimes, and state are covered in Claude Code with Rust. For the database-specific patterns when using SQLite via SQLx or rusqlite in your Tauri backend, Claude Code with databases covers the connection pool and query patterns that translate directly.

Bundling, signing, and the updater

Tauri's build output is a native binary for each target platform. The bundler targets, signing setup, and updater configuration are all in tauri.conf.json. Claude does not know which platforms you are targeting or whether you have signing certificates set up, so without CLAUDE.md context it generates placeholder values or skips signing configuration entirely.

Here is the relevant section of tauri.conf.json for a multi-platform app:

{
  "bundle": {
    "active": true,
    "targets": ["dmg", "msi", "nsis", "deb", "appimage"],
    "identifier": "com.yourcompany.yourapp",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ],
    "macOS": {
      "signingIdentity": null,
      "entitlements": "src-tauri/Entitlements.plist",
      "notarization": {
        "teamId": "${APPLE_TEAM_ID}"
      }
    },
    "windows": {
      "certificateThumbprint": null,
      "digestAlgorithm": "sha256",
      "timestampUrl": "http://timestamp.sectigo.com"
    }
  },
  "plugins": {
    "updater": {
      "active": true,
      "endpoints": [
        "https://releases.yourapp.com/{{target}}/{{arch}}/{{current_version}}"
      ],
      "dialog": true,
      "pubkey": "${TAURI_UPDATER_KEY}"
    }
  }
}

Add a bundling section to CLAUDE.md that makes Claude aware of your targets and signing posture:

## Bundling and signing

### Active targets
- macOS: dmg (primary), app bundle for direct distribution
- Windows: nsis installer (primary), msi for enterprise
- Linux: deb (Ubuntu/Debian), appimage (universal)
- Mobile: NOT active in this project (Tauri v2 mobile is available but not configured)

### Signing (CI only, never local)
- macOS: APPLE_SIGNING_IDENTITY, APPLE_ID, APPLE_TEAM_ID, APPLE_PASSWORD from CI secrets
- Windows: certificate loaded from PFX_BASE64 + PFX_PASSWORD in CI
- NEVER commit signing credentials, all from environment variables
- signingIdentity in tauri.conf.json must remain null, signing identity injected by CI

### Updater
- Active: true
- Endpoint: https://releases.yourapp.com/{{target}}/{{arch}}/{{current_version}}
- Public key: TAURI_UPDATER_KEY env var (private key never leaves CI)
- Do not change the endpoint URL without updating the release server

### Build commands
- Dev: cargo tauri dev
- Build (current platform): cargo tauri build
- Build universal macOS: cargo tauri build --target universal-apple-darwin
- Build with verbose output: cargo tauri build --verbose

The signingIdentity: null entry is intentional. Tauri will use the ambient signing identity from the Keychain when null, so CI provides the identity via the APPLE_SIGNING_IDENTITY environment variable without the config file needing to change between local and CI builds. Claude occasionally fills this with a hardcoded identity string when it sees the null value and assumes it is a placeholder. The comment in CLAUDE.md makes the intent clear.

The updater public key is the most frequently misunderstood part of Tauri's update system. You generate a key pair with cargo tauri signer generate. The public key goes in tauri.conf.json (it is safe to commit). The private key signs each release artifact and never leaves your CI environment. Without the CLAUDE.md rule, Claude sometimes puts both keys in the config file or hardcodes the private key in a CI script. The rule is explicit: TAURI_UPDATER_KEY is an environment variable; the private key never appears in config files or source code.

For the CI/CD patterns that connect your Tauri build to GitHub Actions, Claude Code with GitHub Actions covers the cross-platform matrix build setup that Tauri requires: compiling native binaries for three operating systems in a single workflow.

Permissions, hooks, and the security model

Tauri v2's security model is stricter than v1's. The allowlist from v1 has been replaced by a capability system where every permission is a named, scoped grant. This is more expressive but also more explicit: there is no way to accidentally grant broad filesystem access because the capability name forces you to specify which operations and which paths are allowed.

Add a capabilities reference to CLAUDE.md so Claude knows exactly what is open:

## Open capabilities (src-tauri/capabilities/)

### default.json (applies to all windows)
{
  "identifier": "default",
  "description": "Default capabilities for all windows",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "dialog:allow-open",
    "dialog:allow-save",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    "fs:allow-app-config-dir",
    "shell:allow-open"
  ]
}

### admin.json (applies to admin window only, if it exists)
- NOT currently active, add when admin window is implemented

## What is NOT permitted
- Network requests from webview (use HTTP plugin with specific URLs, not open wildcard)
- Arbitrary shell command execution (sidecars only, listed in bundle.externalBin)
- Clipboard read without explicit user gesture (clipboard plugin not enabled)
- Notification access (not configured)

## Adding a new capability
1. Create src-tauri/capabilities/{name}.json with identifier, windows, permissions
2. Test in dev with cargo tauri dev
3. Document in this CLAUDE.md section under "Open capabilities"
4. Never grant a permission broader than the minimum required for the feature

The "what is NOT permitted" section is as valuable as the capability list. When Claude generates a feature that needs network access, it needs to know whether to route through the HTTP plugin (which enforces an allowlist of permitted URLs) or whether that capability is simply not available. Without this section, Claude might generate a direct fetch from the frontend (which the CSP blocks) or a reqwest call in Rust without the HTTP plugin configured.

The minimum-permission principle at the bottom of the capabilities section maps directly to the permission hook pattern from the broader Claude Code workflow. The same principle that governs which bash commands Claude can run without confirmation applies here: scope permissions to the minimum, require explicit justification for anything broader. The permission hook patterns for Claude Code itself are documented in Claude Code permissions, and the same mental model transfers to the Tauri capability system.

For development, the .claude/settings.local.json permission hook config for a Tauri project looks like this:

{
  "permissions": {
    "allow": [
      "Bash(cargo tauri dev*)",
      "Bash(cargo tauri build*)",
      "Bash(cargo test*)",
      "Bash(cargo clippy*)",
      "Bash(cargo fmt*)",
      "Bash(pnpm install*)",
      "Bash(pnpm build*)"
    ],
    "deny": [
      "Bash(cargo tauri signer*)",
      "Bash(rm -rf*)"
    ]
  }
}

The signer commands are in the deny list because signing key generation is a one-time operation with permanent consequences. A key generated accidentally during a dev session and then lost cannot be recovered, and all previously distributed apps signed with that key become unable to receive updates. The deny list makes Claude ask before running any signing operation. The custom instructions and workflow hook patterns are covered in Claude Code hooks and Claude Code custom instructions.

Shell sidecars versus Rust commands: a decision guide

When you need to run a system operation in Tauri, you have three options: a Rust command, a registered sidecar, or the shell plugin's open (for URLs and files only). Choosing the wrong one produces either a security violation, a runtime error, or an unnecessarily complex architecture. Here is the decision logic:

Use a Rust command when: The operation is pure computation, file I/O, database access, or any logic you can implement in Rust. This is the right choice for 80% of backend operations. Rust commands are the most secure option because they run in the Tauri process with no additional shell attack surface. They compile to native code, have type-safe argument passing via serde, and return typed results.

Use a registered sidecar when: You have an existing binary that would be expensive to rewrite in Rust, or you need a runtime that does not embed into a Rust library (Python with a large dependency tree, a compiled Go service, a legacy C++ tool). The sidecar must be compiled for each target platform and listed in tauri.conf.json. You spawn it via the shell plugin with a specific permission grant. Example:

import { Command } from "@tauri-apps/plugin-shell";

// CORRECT: named sidecar with specific args
const output = await Command.sidecar("binaries/my-python-tool", ["--input", inputPath]).execute();

// WRONG: arbitrary shell execution
// const output = await Command.create("python3", ["my-script.py"]).execute(); // Rejected by capabilities

Use shell:open when: You need to open a URL in the system browser or open a file with its default application. shell:allow-open permits exactly this and nothing else. It is the safe way to open documentation links, payment pages, or output files without granting arbitrary shell access.

Never use when: There is no fourth option. Tauri's webview does not have Node.js, so child_process, fs, or any Node built-in is unavailable. Attempting to use them produces an error that looks like a JavaScript module resolution failure. The pattern of "write a quick Node.js script for this" does not apply inside the Tauri webview; it belongs in a Rust command or a sidecar.

Add the decision matrix to your CLAUDE.md as a brief reference:

## Backend operation decision matrix
- Pure computation, file I/O, database: Rust command (#[tauri::command])
- Existing binary, Python/Go runtime: Sidecar (bundle.externalBin + shell capability)
- Open URL or file in system default app: shell:allow-open (no command needed)
- NEVER: Node.js APIs (child_process, fs, net), not available in Tauri webview
- NEVER: Arbitrary shell strings, sidecar names must be listed in bundle.externalBin

This section composes with the Claude Code best practices guide's principle of explicit constraints over implicit ones. Claude performs better when CLAUDE.md defines both what to use and what not to use, rather than only the positive case.

Building a Tauri project Claude Code can extend

The CLAUDE.md template in this guide produces a Tauri project where Claude Code can add commands, open capabilities, scaffold frontend components, wire up events, and configure bundler targets without introducing the failure modes that an unconstrained session produces.

The pattern is the same as every other multi-component framework in the Claude Code workflow: the complexity is not in Claude's knowledge of the framework, it is in the gap between what Claude knows about Tauri in general and what it cannot know about your specific project's security posture, capability grants, async conventions, and state architecture. A CLAUDE.md that closes that gap produces Claude sessions where new features land correctly the first time, commands are registered rather than orphaned, and the IPC bridge carries typed data in both directions without runtime serialisation surprises.

For the Rust-specific patterns that complement the backend half of this guide, Claude Code with Rust covers the compiler error interpretation, ownership patterns, and test conventions that apply directly to src-tauri. For the general principles of how CLAUDE.md is read and applied at session start, CLAUDE.md explained covers the mechanics. For the custom instructions that extend CLAUDE.md with project-specific context, Claude Code custom instructions is a direct follow-on. Claudify includes a Tauri-specific CLAUDE.md template, pre-configured with the IPC patterns, allowlist conventions, state management rules, and bundler guidance from this guide.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir