.claude/skills/tauri-2/tauri-syntax/tauri-syntax-commands/SKILL.md
Use when writing Tauri commands, calling invoke(), handling IPC errors, or streaming data from Rust to JavaScript. Prevents command registration omissions, argument type mismatches between Rust and JS, and missing error serialization. Covers #[tauri::command] macro, sync/async commands, argument types, return types, thiserror, invoke() API, and Channel streaming. Keywords: tauri command, invoke, IPC, async command, thiserror, Channel, generate_handler, command registration.
npx skillsauth add OpenAEC-Foundation/OpenAEC-Workspace-Composer tauri-syntax-commandsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
| Pattern | Syntax | Notes |
|---------|--------|-------|
| Sync command | #[tauri::command] fn name() -> T | Runs on main thread |
| Async command | #[tauri::command] async fn name() -> T | Spawned on tokio runtime |
| Forced async | #[tauri::command(async)] fn name() -> T | Sync function on tokio runtime |
| Renamed args | #[tauri::command(rename_all = "snake_case")] | JS passes snake_case keys |
| Rust Type | JS Type | Notes |
|-----------|---------|-------|
| i32, u64, f64 | number | Direct mapping |
| bool | boolean | Direct mapping |
| String | string | Owned string |
| Vec<T> | T[] | Array to Vec |
| Option<T> | T \| null \| undefined | Nullable |
| PathBuf | string | Path string |
| Custom struct | object | Must #[derive(Deserialize)] |
| Rust Return | JS Result | Notes |
|-------------|-----------|-------|
| () | void / null | No return value |
| T (Serialize) | T | Direct serialization |
| Result<T, E> | T or throw E | E must impl Serialize |
| Response | ArrayBuffer | Binary data bypass |
| Rust Parameter | Purpose | Example |
|----------------|---------|---------|
| app: AppHandle | Application handle | app.emit("event", data) |
| window: WebviewWindow | Calling window | window.label() |
| state: State<'_, T> | Managed state | state.inner() |
| request: tauri::ipc::Request | Raw IPC request | request.headers(), request.body() |
| Feature | Syntax |
|---------|--------|
| Import | import { invoke } from '@tauri-apps/api/core' |
| Basic call | await invoke('command_name') |
| With args | await invoke('cmd', { argName: value }) |
| Typed return | await invoke<ReturnType>('cmd') |
| With headers | await invoke('cmd', args, { headers: {...} }) |
| Binary data | await invoke('cmd', new Uint8Array([...])) |
NEVER use snake_case keys in JS invoke() arguments -- Tauri auto-converts camelCase to snake_case. Passing { file_path: "x" } will NOT match Rust parameter file_path.
NEVER forget to implement Serialize on error types -- Result<T, E> requires E to implement Serialize to send errors to the frontend.
NEVER use &str parameters in async commands -- borrowed references cannot cross the async spawn boundary. Use String instead.
NEVER register commands with multiple invoke_handler() calls -- only the last one takes effect.
NEVER mark commands in lib.rs as pub -- glue code generation prevents it. Move to a separate module if pub is needed.
ALWAYS wrap invoke() calls in try/catch -- Rust Result::Err rejects the JS Promise.
ALWAYS use Channel<T> for streaming data instead of multiple events -- channels are tied to the command invocation and type-safe.
ALWAYS derive serde::Deserialize on custom argument types and serde::Serialize on custom return types.
// src-tauri/src/commands.rs
#[tauri::command]
pub fn greet(name: String) -> String {
format!("Hello, {}!", name)
}
// src/main.ts
import { invoke } from '@tauri-apps/api/core';
const greeting = await invoke<string>('greet', { name: 'World' });
console.log(greeting); // "Hello, World!"
#[derive(Debug, thiserror::Error)]
enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("not found: {0}")]
NotFound(String),
}
// REQUIRED: Serialize impl for sending errors to frontend
impl serde::Serialize for Error {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: serde::ser::Serializer {
serializer.serialize_str(self.to_string().as_ref())
}
}
#[tauri::command]
async fn read_file(path: String) -> Result<String, Error> {
let content = tokio::fs::read_to_string(&path).await?;
Ok(content)
}
try {
const content = await invoke<string>('read_file', { path: '/tmp/data.txt' });
console.log(content);
} catch (error) {
// error is the serialized string: "not found: ..." or "io error: ..."
console.error('Failed:', error);
}
For richer error handling on the frontend, use tagged enum serialization.
#[derive(serde::Serialize)]
#[serde(tag = "kind", content = "message")]
#[serde(rename_all = "camelCase")]
enum AppError {
Io(String),
NotFound(String),
Unauthorized(String),
}
#[tauri::command]
async fn load_data(id: u32) -> Result<Data, AppError> {
if id == 0 {
return Err(AppError::NotFound("Item not found".into()));
}
Ok(Data { /* ... */ })
}
try {
const data = await invoke<Data>('load_data', { id: 42 });
} catch (err: any) {
// err = { kind: 'notFound', message: 'Item not found' }
switch (err.kind) {
case 'notFound':
showNotFoundUI(err.message);
break;
case 'unauthorized':
redirectToLogin();
break;
default:
console.error('Unexpected error:', err);
}
}
These parameters are injected by Tauri -- the frontend does NOT pass them.
use tauri::{AppHandle, State, WebviewWindow};
#[tauri::command]
async fn complex_command(
app: AppHandle, // injected: app handle
window: WebviewWindow, // injected: calling window
state: State<'_, MyState>, // injected: managed state
// Regular parameters (passed from JS):
query: String,
limit: Option<u32>,
) -> Result<Vec<Item>, String> {
println!("Called from window: {}", window.label());
let config = state.inner();
// ... use app, window, state as needed
Ok(vec![])
}
// Frontend only passes the regular parameters -- injected params are omitted
const items = await invoke<Item[]>('complex_command', {
query: 'search term',
limit: 10,
});
use tauri::ipc::Channel;
#[derive(Clone, serde::Serialize)]
struct DownloadProgress {
bytes_read: u64,
total: u64,
percent: f64,
}
#[tauri::command]
async fn download_file(
url: String,
on_progress: Channel<DownloadProgress>,
) -> Result<String, String> {
let response = reqwest::get(&url).await.map_err(|e| e.to_string())?;
let total = response.content_length().unwrap_or(0);
let mut bytes_read = 0u64;
// Stream progress updates
on_progress.send(DownloadProgress {
bytes_read,
total,
percent: 0.0,
}).unwrap();
// ... read chunks, updating progress ...
Ok("download complete".into())
}
import { invoke, Channel } from '@tauri-apps/api/core';
interface DownloadProgress {
bytesRead: number;
total: number;
percent: number;
}
const onProgress = new Channel<DownloadProgress>();
onProgress.onmessage = (progress) => {
updateProgressBar(progress.percent);
console.log(`${progress.bytesRead}/${progress.total}`);
};
const result = await invoke<string>('download_file', {
url: 'https://example.com/file.zip',
onProgress, // Pass the channel as a regular argument
});
Bypass JSON serialization for large binary payloads.
use tauri::ipc::{Request, Response};
// Receiving binary data from JS
#[tauri::command]
fn upload(request: Request) -> Result<String, String> {
let tauri::ipc::InvokeBody::Raw(data) = request.body() else {
return Err("Expected binary data".into());
};
let auth = request.headers().get("Authorization");
println!("Received {} bytes", data.len());
Ok(format!("Uploaded {} bytes", data.len()))
}
// Returning binary data to JS
#[tauri::command]
fn get_image(path: String) -> Response {
let data = std::fs::read(path).unwrap();
Response::new(data)
}
// Sending binary data
const data = new Uint8Array([1, 2, 3, 4, 5]);
const result = await invoke<string>('upload', data, {
headers: { Authorization: 'Bearer token123' },
});
// Receiving binary data
const imageData = await invoke<ArrayBuffer>('get_image', { path: '/photo.jpg' });
// src-tauri/src/lib.rs
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
commands::greet,
commands::read_file,
commands::download_file,
commands::upload,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Rule: ALL commands MUST be listed in a single generate_handler![] macro call. Multiple invoke_handler() calls do NOT merge.
Tauri automatically converts between JS camelCase and Rust snake_case.
| Rust Parameter | JS Argument Key |
|----------------|-----------------|
| file_path: String | filePath |
| user_name: String | userName |
| item_count: u32 | itemCount |
| is_active: bool | isActive |
To override this behavior:
#[tauri::command(rename_all = "snake_case")]
fn my_command(file_path: String) { }
// With rename_all = "snake_case", pass snake_case keys
await invoke('my_command', { file_path: '/tmp/file.txt' });
| Feature | Channel | Events |
|---------|---------|--------|
| Direction | Rust -> JS (response stream) | Bidirectional |
| Lifetime | Tied to command invocation | App lifetime |
| Targeting | Specific caller | Broadcast or filtered |
| Type safety | Generic Channel<T> | Serialized JSON |
| Use case | Progress, streaming data | App-wide notifications |
ALWAYS use Channel<T> for progress updates and streaming data from a command. NEVER use events for command-specific streaming -- channels are scoped to the invocation.
When writing commands for plugins or libraries that need to be runtime-agnostic:
use tauri::{AppHandle, Runtime, WebviewWindow};
#[tauri::command]
async fn plugin_command<R: Runtime>(
app: AppHandle<R>,
window: WebviewWindow<R>,
data: String,
) -> Result<String, String> {
Ok(format!("Processed: {}", data))
}
With the default wry feature, R resolves to Wry. This generic is only necessary for plugin/library code.
development
Use when integrating Vite with a backend framework, rendering Vite assets from server-side templates, or setting up dev/production HTML serving. Prevents incorrect manifest.json traversal and missing CSS chunk resolution in production. Covers build.manifest configuration, .vite/manifest.json structure, ManifestChunk properties, dev mode HTML setup, production rendering, CSS/JS chunk resolution, and modulepreload polyfill. Keywords: backend integration, manifest.json, ManifestChunk, Django, Laravel, Rails, modulepreload.
development
Use when encountering dev server startup failures, HMR issues, proxy errors, CORS blocks, or module not found errors during development. Prevents misconfiguring server.hmr behind reverse proxies and forgetting appType: 'custom' in middleware mode. Covers HMR full-reload debugging, proxy configuration, CORS setup, HTTPS certificates, server.fs.strict violations, port conflicts, WebSocket failures, file watcher issues, and middleware mode. Keywords: dev server, HMR, proxy, CORS, HTTPS, WebSocket, port conflict, server.fs.strict, middleware mode, file watcher.
development
Use when encountering pre-bundling errors, dependency resolution failures, stale cache issues, or slow development server startup. Prevents excluding CJS dependencies from pre-bundling (which breaks runtime module resolution) and misconfiguring optimizeDeps. Covers CJS/ESM conversion failures, missing dependency auto-discovery, optimizeDeps configuration, monorepo linked dependencies, cache invalidation, browser cache staleness, and large dependency tree performance. Keywords: pre-bundling, optimizeDeps, CJS, ESM, cache, dependency resolution, monorepo, node_modules/.vite.
development
Use when encountering Vite build failures, chunk size warnings, or version-specific build errors. Prevents the common mistake of using deprecated rollupOptions in v8 or misconfiguring build targets and minifiers. Covers Rolldown/Rollup bundling failures, CSS minification errors, sourcemap problems, library mode build failures, BundleError handling, and asset processing errors. Keywords: build error, Rolldown, chunk size, sourcemap, library mode, minify, BundleError, rollupOptions, build.target.