Claude Tauri skill: 10 desktop-app recipes that compile
Ten things the Tauri skill helps Claude get right — a cross-platform path helper, the FS plugin, a Rust #[tauri::command], typed IPC with tauri-specta, capability scoping, a real CSP, a sidecar, window-state, a native save dialog, and a signed CI build — each as one prompt with the code it returns.
One thing up front, because the skill’s name undersells it: this is not a “build any desktop app” skill. It’s a focused set of patterns for the parts of Tauri that bite — the capabilities model, the security config, the Rust command boundary, and cross-platform paths. The recipes below stay inside that scope on purpose.
Already know what skills are? Skip to the cookbook. First time? Read the explainer then come back. Need the install? It’s on the /skills/tauri page.

On this page · 21 sections▾
- What this skill does
- The cookbook
- Install + README
- Watch it built
- 01 · Resolve a cross-platform app-data path
- 02 · Read and write a file through the FS plugin
- 03 · Define a Rust #[tauri::command] and invoke it
- 04 · Keep typed IPC in sync with tauri-specta
- 05 · Harden capabilities and permissions
- 06 · Set a real CSP (and the dev override)
- 07 · Bundle and call a sidecar binary
- 08 · Persist window state across launches
- 09 · Open a native save dialog, then write the file
- 10 · Build, sign, and ship for macOS and Windows
- Community signal
- The contrarian take
- Real apps shipped
- Gotchas
- Pairs well with
- FAQ
- Sources
What this skill actually does
Sixty seconds of context before the cookbook. The Tauri skill, authored by EpicenterHQ, is a set of patterns Claude loads when you’re writing Tauri code — mention a Tauri command, invoke, capabilities, a ResourceId, file paths, or platform differences and it activates.
Real Tauri code on both sides of the bridge: Rust #[tauri::command] functions registered with generate_handler! and returning Result<T, E>; least-privilege capabilities JSON scoped to specific windows; CSP and devCsp config; tauri-specta binding flows; and async path helpers from @tauri-apps/api/path combined with the @tauri-apps/plugin-fs filesystem plugin.
The TypeScript caller is not the trust boundary — Rust is. Validate command inputs in Rust, keep capabilities least-privilege, never ship csp: null, and let the frontend remember a user’s intent while Rust enforces the filesystem boundary.
It does not scaffold a Tauri project, install the Rust toolchain, or pick your frontend framework. It assumes a Tauri app exists and sharpens the parts that are easy to get subtly wrong.
The cookbook
Each entry is a task the skill helps Claude get right, in roughly the order you’d hit them building an app. The early ones are pure frontend TypeScript — paths and the FS plugin. The middle ones cross into Rust: commands, typed IPC, capabilities, CSP. The last few are packaging. Every entry pairs with one or two skills you can already find on mcp.directory.
Install + README
If the skill isn’t on your machine yet, here’s the one-line install. The full panel (Codex, Copilot, and Antigravity variants, plus the project/global toggle) is the same UI the skill page uses.
One-line install · by EpicenterHQ
Open skill pageInstall
mkdir -p .claude/skills/tauri && curl -L -o skill.zip "https://mcp.directory/api/skills/download/1784" && unzip -o skill.zip -d .claude/skills/tauri && rm skill.zipInstalls to .claude/skills/tauri
Watch it built
A full Rust-plus-Tauri build that exercises the command and invoke bridge end to end. Worth watching before the cookbook because it grounds the IPC contract — the seam every recipe below either crosses or guards.
Resolve a cross-platform app-data path
Stop concatenating slashes. Get the OS-correct data directory and join a subpath that survives Windows, macOS, and Linux — using the async path API the skill insists on.
ForAnyone persisting user data in a Tauri webview frontend.
The prompt
In `apps/desktop/src/lib/storage.ts`, write a `workspaceFile(id, name)` helper that returns the absolute path to `<appLocalDataDir>/workspaces/<id>/<name>`. Use `@tauri-apps/api/path` — never string concatenation. Await every call (they're IPC, so they're Promises). Add a sibling `parentOf(path)` that returns the directory via `dirname`, not manual slicing.What slides.md looks like
import { appLocalDataDir, join, dirname } from '@tauri-apps/api/path';
// All path fns are async — they round-trip to Rust over IPC.
export async function workspaceFile(id: string, name: string) {
const base = await appLocalDataDir();
// join() inserts the platform separator: \ on Windows, / elsewhere.
return join(base, 'workspaces', id, name);
}
export async function parentOf(path: string) {
return dirname(path); // not path.slice(0, path.lastIndexOf('/'))
}One-line tweak
Swap `appLocalDataDir` for `appConfigDir` and the same helper now targets the per-OS config folder instead of the data folder.
Read and write a file through the FS plugin
Combine the path APIs with `@tauri-apps/plugin-fs` to create the parent directory and write bytes — the exact pattern the skill ships, with the capability you must declare for it to work.
ForDevs saving exports, caches, or user documents from the frontend.
The prompt
Write `saveData(segments, bytes)` that resolves `<appLocalDataDir>/<...segments>`, ensures the parent directory exists with `mkdir({ recursive: true })`, then writes with `writeFile`. Use `@tauri-apps/plugin-fs`. Then show the matching capability entry — I'm getting a 'forbidden path' error and I think the scope is missing.What slides.md looks like
import { appLocalDataDir, join, dirname } from '@tauri-apps/api/path';
import { mkdir, writeFile } from '@tauri-apps/plugin-fs';
export async function saveData(segments: string[], bytes: Uint8Array) {
const path = await join(await appLocalDataDir(), ...segments);
await mkdir(await dirname(path), { recursive: true });
await writeFile(path, bytes);
}
// capabilities/default.json — scope fs to $APPLOCALDATA, not the whole disk:
// { "identifier": "fs:allow-write-file",
// "allow": [{ "path": "$APPLOCALDATA/**" }] }One-line tweak
Replace `writeFile`/`readFile` with `writeTextFile`/`readTextFile` when the payload is UTF-8 text instead of raw bytes.
Define a Rust #[tauri::command] and invoke it
Expose a focused Rust function as a command that returns `Result<T, E>`, register it with `generate_handler!`, and call it from the frontend with `invoke`. The trust boundary lives in Rust — not in the TypeScript caller.
ForDevs moving logic that touches the OS off the frontend into Rust.
The prompt
Add a `read_config` command in `src-tauri/src/lib.rs` that takes an app handle, reads a JSON config from the app config dir, and returns `Result<Config, String>`. Validate the parsed shape in Rust — assume the TypeScript caller is hostile. Register it in `generate_handler!`. Then give me the typed `invoke<Config>('read_config')` call for the frontend.What slides.md looks like
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Config { theme: String, retries: u8 }
#[tauri::command]
fn read_config(app: tauri::AppHandle) -> Result<Config, String> {
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
let raw = std::fs::read_to_string(dir.join("config.json"))
.map_err(|e| e.to_string())?;
serde_json::from_str(&raw).map_err(|e| e.to_string()) // validate here
}
// lib.rs builder:
// .invoke_handler(tauri::generate_handler![read_config])
// frontend: const cfg = await invoke<Config>('read_config');One-line tweak
Make the command `async fn` and add `tokio::fs` when the work is I/O-heavy, so it doesn't block the main thread.
Keep typed IPC in sync with tauri-specta
When a project uses tauri-specta, the Rust command registry, the generated TypeScript bindings, and the frontend wrapper must stay in lockstep. The skill treats `bindings.gen.ts` as derived output you regenerate, never hand-edit.
ForTeams on a tauri-specta v2 setup with generated bindings.
The prompt
I added a `delete_recording` command but the frontend can't see it. Register it in the `tauri_specta::collect_commands!` builder, confirm the event payload types are in `collect_events!`, then give me the two commands that regenerate the bindings and cargo-check the Rust side. Remind me not to commit unrelated binding churn.What slides.md looks like
// src-tauri/src/lib.rs
let builder = tauri_specta::Builder::<tauri::Wry>::new()
.commands(tauri_specta::collect_commands![read_config, delete_recording])
.events(tauri_specta::collect_events![RecordingDeleted]);
// Regenerate the derived bindings + type-check Rust:
// cargo check --manifest-path src-tauri/Cargo.toml
// bun run bindings:tauri
// bindings.gen.ts is generated output — commit it only when the
// public IPC contract actually changed, and inspect the diff first.One-line tweak
For a command returning raw `tauri::ipc::Response` (not a specta `Type`), route it through a separate `generate_handler!` and keep a small handwritten TS wrapper — specta can't generate it.
Harden capabilities and permissions
Lock down `app.security.capabilities` to least privilege, scoped to the windows that need them. No wildcard permissions, no whole-disk filesystem access. The skill's first rule: capabilities are an allowlist, not a convenience.
ForAnyone shipping a Tauri app to real users.
The prompt
Audit `src-tauri/capabilities/default.json`. I currently have `fs:allow-read-file` and `fs:allow-write-file` with no scope and `shell:allow-execute`. Tighten the FS permissions to only `$APPLOCALDATA` and `$APPCONFIG`, drop the unscoped shell permission, and bind the capability to the `main` window only. Explain why each change reduces blast radius.What slides.md looks like
// src-tauri/capabilities/default.json — least privilege:
{
"identifier": "default",
"windows": ["main"], // scoped to one window, not "*"
"permissions": [
"core:default",
{ "identifier": "fs:allow-write-file",
"allow": [{ "path": "$APPLOCALDATA/**" }, { "path": "$APPCONFIG/**" }] },
{ "identifier": "fs:allow-read-file",
"allow": [{ "path": "$APPLOCALDATA/**" }] }
// shell:allow-execute removed — nothing here needs a raw shell.
]
}One-line tweak
Split untrusted surfaces into their own capability file (e.g. `capabilities/remote.json`) bound to a second window, so a compromised webview can't reach the main window's permissions.
Set a real CSP (and the dev override)
Ship a Content-Security-Policy that blocks an injected same-origin script from exfiltrating in-memory tokens, while keeping `tauri dev` working. The skill is blunt here: never ship `csp: null`.
ForDevs whose webview holds API tokens or keys in memory.
The prompt
Set `app.security.csp` in `tauri.conf.json` for production and a matching `devCsp` for `tauri dev`. Lock `connect-src` to my API origin plus Tauri's IPC. My frontend is a SvelteKit SPA, so production script-src can be `'self'` (codegen hashes inline scripts) but dev needs `'unsafe-inline'`/`'unsafe-eval'` for Vite HMR. Always include the IPC origin or invoke breaks.What slides.md looks like
// tauri.conf.json -> "app": { "security": { ... } }
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost https://api.example.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
// dev loads from the unhashed Vite server, so it keeps 'unsafe-inline':
"devCsp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://localhost:5173 ws://localhost:5173 https://api.example.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; object-src 'none'"One-line tweak
Only add `asset: http://asset.localhost` to the policy if you actually call `convertFileSrc` — listing it otherwise just widens the surface for nothing.
Bundle and call a sidecar binary
Ship an external executable alongside the app and run it through a scoped shell permission — instead of opening a raw shell. The skill's ownership rule applies: Rust roots the command, the frontend only triggers it.
ForApps wrapping ffmpeg, a CLI, or a language runtime.
The prompt
I need to bundle `bin/transcribe` (a platform-suffixed sidecar) and call it from the frontend. Add the `externalBin` entry to `tauri.conf.json`, add a scoped `shell:allow-execute` permission that only allows that one sidecar, and show the `Command.sidecar('bin/transcribe', [file])` call. Do not give it a broad shell permission.What slides.md looks like
// tauri.conf.json -> "bundle": { "externalBin": ["bin/transcribe"] }
// capabilities: scope the shell to ONE sidecar, not the whole shell:
// { "identifier": "shell:allow-execute",
// "allow": [{ "name": "bin/transcribe", "sidecar": true,
// "args": [{ "validator": "\\S+" }] }] }
import { Command } from '@tauri-apps/plugin-shell';
const out = await Command.sidecar('bin/transcribe', [filePath]).execute();
if (out.code !== 0) throw new Error(out.stderr);One-line tweak
Tighten the `args` validator regex to the exact flags the sidecar accepts, so the frontend can't smuggle arbitrary arguments through.
Persist window state across launches
Remember window size and position between sessions with the official window-state plugin — a few lines in the builder, not a hand-rolled config file you'd have to path-resolve yourself.
ForAnyone who wants the app to reopen where the user left it.
The prompt
Add `tauri-plugin-window-state` so the main window restores its size and position on relaunch. Show the `Cargo.toml` dependency, the `.plugin(...)` line in the Rust builder, and the capability permission it needs. Then tell me the manual save/restore calls in case I want to trigger them myself.What slides.md looks like
// Cargo.toml: tauri-plugin-window-state = "2"
// src-tauri/src/lib.rs builder:
tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::default().build())
// ...
// capabilities: add "window-state:default"
// Manual control from the frontend, if you want it:
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state';
await saveWindowState(StateFlags.ALL);One-line tweak
Pass `StateFlags::SIZE` only to the builder when you want to remember dimensions but always center the window on launch.
Open a native save dialog, then write the file
Use the dialog plugin to get a user-chosen path, then hand it to the FS plugin. The frontend remembers the user's intent; Rust still owns the write boundary through the capability scope.
ForDevs adding 'Export…' / 'Save As…' to a desktop app.
The prompt
Wire an Export button: open a native save dialog with `@tauri-apps/plugin-dialog` defaulting to a `.md` filename, and if the user picks a path, write the markdown there with `writeTextFile`. Handle the cancel case (the dialog returns null). Remind me which dialog and fs permissions the capability needs.What slides.md looks like
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
export async function exportMarkdown(body: string) {
const path = await save({
defaultPath: 'export.md',
filters: [{ name: 'Markdown', extensions: ['md'] }],
});
if (!path) return; // user cancelled
await writeTextFile(path, body);
}
// capabilities: "dialog:allow-save" + a scoped "fs:allow-write-file".One-line tweak
Swap `save` for `open({ multiple: true })` to build an importer that returns an array of chosen paths instead.
Build, sign, and ship for macOS and Windows
Produce signed installers in CI with the official Tauri Action — the cross-platform packaging step the skill points you toward once the app itself is correct.
ForMaintainers cutting a real release, not just running dev.
The prompt
Write a GitHub Actions workflow that builds my Tauri app on macOS and Windows runners and uploads the installers as release assets using `tauri-apps/tauri-action`. Reference signing secrets from repository secrets — don't hardcode anything. Use a matrix so both platforms build from one job definition.What slides.md looks like
jobs:
release:
strategy:
matrix:
platform: [macos-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS signing pulled from secrets, never inlined:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}One-line tweak
Add `ubuntu-latest` to the matrix (with the GTK/WebKit apt deps step) to emit a Linux AppImage and .deb from the same workflow.
Community signal
Three voices from people who’ve shipped on Tauri. The first names the tension the skill resolves — security framing versus what builders actually want. The next two are teams who chose Tauri for concrete reasons and wrote down the tradeoff.
“The Tauri developer's focus on security in their marketing is genuine (they have paid for audits) but a little incongruous given what most of it's users want from it which is just a cross-platform UI and/or with a great rust experience.”
Fluorescence · Hacker News
On the Tauri 2.0 Release Candidate thread — a v2 + Leptos user weighing the security framing against what most builders actually reach for Tauri to do.
“Rust's performance suits this intensive task exceptionally well. Implementing this in Electron would require managing a separate process for the video stream, complicating the architecture.”
Costa Alexoglou (Hopp) · Blog
Hopp (open-source remote pair-programming app) on why the Rust backend bridge fit their low-latency video workload better than Electron's Node.js model.
“Unfortunately both Tauri and Electron suck in this regard - they replicate the entire browser infrastructure per app and per instance, with each running just a single 'tab'.”
torginus · Hacker News
A nuance on the 'is Claude an Electron app' thread: Tauri's per-app webview saves disk, but it's still a per-instance RAM cost, not free.
The contrarian take
Not everyone walks away happy. The most honest critique on the Tauri 2.0 release thread is from arresin:
“I like rust and I like the package size. But if I was to build this again I would 100% go with electron. They started working on 2.0 with the docs for v1 in a 10% state. I mostly searched through discord threads for tidbits. It was painful.”
arresin · Hacker News
From the Tauri 2.0 Release Candidate thread on Hacker News — the docs-maturity and v2 learning-curve gripe.
Fair, and worth taking seriously. The v2 migration shipped ahead of its docs, and the capabilities model is genuinely a new mental model to absorb. That gap is exactly the hole a skill fills: instead of reconstructing the capability and CSP conventions from Discord threads, the patterns land in context the moment you mention them. It doesn’t make the framework simpler — it makes the undocumented-but-correct path the one Claude reaches for first.
One more honest note on the webview tradeoff. Because Tauri uses the OS-provided webview through WRY rather than bundling Chromium, you trade a smaller binary for a softer compatibility guarantee — Eric Richardson’s quote above names it directly. In practice the divergence is rare on the major platforms, but the skill’s insistence on smoke-testing both a real tauri dev and a release build is the cheap insurance against it.
Real apps shipped with Tauri
Concrete proof the framework carries production weight. These don’t all use the Claude skill — they’re here to show the shape of a real Tauri app, so you have a target in mind when you write the prompt.
- Spacedrive — cross-platform file explorer powered by a virtual distributed filesystem, Rust core
- GitButler — Git client and source-management tool built on Tauri
- Hoppscotch — open-source API development platform, shipped as a Tauri desktop app
- Yaak — desktop API client for REST, GraphQL, and gRPC
- Cap — open-source screen recorder and Loom alternative
- Jan — offline, local-first ChatGPT alternative
Gotchas (the four that bite)
Sourced from the skill’s own rules and the Tauri v2 security docs. These are the failures that cost an afternoon if you don’t see them coming.
invoke() dies silently under a strict CSP
If connect-src doesn't list ipc: http://ipc.localhost, every invoke() call fails — often with no obvious error in the UI, only a CSP violation in the webview console. Add the IPC origin to both csp and devCsp, then watch the console during a real dev run.
Path functions are async — forgetting await burns you
@tauri-apps/api/path round-trips to Rust, so every function returns a Promise. A missing await gives you a Promise object where you expected a string, and the bug surfaces far from the call site. Await join, dirname, appLocalDataDir — all of them.
Unscoped FS permissions are a whole-disk grant
fs:allow-write-file with no allow list lets the frontend write anywhere the process can. Scope every fs permission to $APPLOCALDATA / $APPCONFIG, and have Rust canonicalize and reject paths outside the allowed directory for anything the native side owns.
Generated bindings create phantom diffs
With tauri-specta, a Rust-only change can rewrite unrelated sections of bindings.gen.ts. Commit regenerated bindings only when the public IPC contract changed, and inspect the diff first — otherwise you'll review noise and miss the real change.
Pairs well with
Tauri is Rust on one side and a web frontend on the other, so the skill composes naturally with both. Reach for the Rust skills the moment you cross the command boundary, and the frontend stack skill that matches your webview.
Two posts that compose well with this cookbook: What are Claude Code skills? covers the underlying mechanism, and What is MCP? explains the protocol side — useful context for why this is a skill and not an MCP server.
Frequently asked questions
What does the Tauri skill actually cover — is it just file paths?
It's broader than the name suggests. The Tauri skill covers Rust commands (`#[tauri::command]`, `generate_handler!`, `Result<T, E>`), the capabilities and permissions model, CSP and security config, typed IPC with tauri-specta, cross-platform path handling via `@tauri-apps/api/path`, and the `@tauri-apps/plugin-fs` filesystem plugin. Path handling is one chapter; the security and IPC patterns are the rest.
Do I need to know Rust before the Tauri skill is useful?
Not to start. Many of the recipes above — path resolution, the FS plugin, the dialog and window-state plugins — are pure frontend TypeScript. The skill writes the Rust for commands and capabilities when you need it. But Tauri's backend is Rust, so pairing this skill with rust-pro or handling-rust-errors pays off the moment you move logic across the IPC boundary.
Is there a Tauri MCP server I should use instead of the skill?
There isn't a dedicated Tauri MCP server in the catalog today, which is part of why the skill exists: it ships the Tauri command, capability, CSP, and path conventions directly into the model's context instead of behind a running service. The skill is also nearly free at idle (~100 tokens — just its name and description), whereas an MCP loads its tool schemas every turn. For authoring Tauri code from a Claude session, the skill is the right shape.
When do I use @tauri-apps/api/path versus the Node path module?
Context decides. Code running in the Tauri webview (your `apps/*/src` frontend) uses `@tauri-apps/api/path`, and every call is async because it round-trips to Rust over IPC. Code running in Node or Bun — CLI tools, `packages/*` — uses the synchronous Node `path` module. The skill's rule of thumb: if it runs in the browser, it's the Tauri path API; if it runs in Node/Bun, it's the Node module.
Why does invoke() break after I set a Content-Security-Policy?
Because your `connect-src` doesn't allow Tauri's IPC origin. The skill is explicit: always include `ipc: http://ipc.localhost` in `connect-src` or `invoke()` stops working. Set both `csp` (production) and `devCsp` (the dev override that replaces it during `tauri dev`), and only add `asset: http://asset.localhost` if you actually use `convertFileSrc`. Never ship `csp: null` — that disables CSP entirely.
How do I keep tauri-specta bindings from creating noisy diffs?
Treat `bindings.gen.ts` as derived output, not source. Regenerate it (cargo check the Rust side, then run your `bindings:tauri` script) but commit it only when the public IPC contract actually changed. If a Rust-only compile fix rewrites unrelated sections of the generated file, inspect the diff and leave the churn out. Recipe 4 above walks the exact flow.
Why is 'tauri' getting impressions on Google but no clicks?
The bare 'tauri' query returns tauri.app and the official GitHub repo, which will always win that result. This post targets the long-tail intent instead — 'tauri skill', 'tauri skills', 'tauri agent skills', and 'claude tauri skills' — where a developer wants the agent recipes, not the framework homepage. Those are the queries the cookbook above is built to answer.
Sources
Primary
- EpicenterHQ tauri skill — SKILL.md (the source for every pattern above)
- Tauri v2 official documentation
- Tauri v2 — capabilities and permissions
- @tauri-apps/api/path reference (join, dirname, base dirs)
- @tauri-apps/plugin-fs documentation
Community
- Fluorescence — Hacker News
- Costa Alexoglou (Hopp) — Blog
- torginus — Hacker News
- Eric Richardson (DoltHub) — Blog
Critical and contrarian
Internal