# Tauri Plugin Dev

> Use when authoring a custom Tauri v2 plugin — scaffolding the crate (`tauri plugin new`), exposing commands, declaring permissions with auto-generated manifest, providing a JS API package, and handling mobile platform code (Swift/Kotlin) if needed.

- Skill: `stuffbucket/tauri-plugin-dev` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add stuffbucket/tauri-plugin-dev`
- Raw SKILL.md: https://api.skillmd.com/api/skills/stuffbucket/tauri-plugin-dev/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: stuffbucket (https://skillmd.com/u/stuffbucket)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/stuffbucket/tauri-plugin-dev

---


# Authoring a Tauri v2 Plugin

A Tauri plugin is a **Cargo crate + optional npm package** (and optionally an Android library +
Swift package) that hooks into the Tauri lifecycle, exposes Rust/Kotlin/Swift commands to the
WebView, and ships permissions describing what it lets the frontend do. Unlike app-local
`#[tauri::command]`s, plugins are versioned and distributable — and **every command needs a
permission**, or the frontend gets a denial error.

## When to reach for a plugin (vs. an app command)

- You want to **reuse** the capability across multiple Tauri apps.
- You need **native mobile code** (Kotlin/Swift) — only plugins can host an `android/` and `ios/`
  project alongside Rust.
- You want a **stable command namespace** (`plugin:my-plugin|cmd`) that survives app refactors.
- You want consumers to opt into specific capabilities via your `permissions/*.toml` files.

If none of those apply, a plain `#[tauri::command]` in the app is simpler — see the `tauri-commands`
skill.

## Scaffold

Run from the parent directory where you want `tauri-plugin-<name>/` to land:

```sh
bunx @tauri-apps/cli plugin new my-plugin
# Flags:
#   --no-api       skip the npm package (Rust-only plugin)
#   --android      include android/ library project
#   --ios          include ios/ Swift package
```

This produces:

```text
tauri-plugin-my-plugin/
├── src/
│   ├── commands.rs       # #[tauri::command] handlers
│   ├── desktop.rs        # desktop impl of the public API struct
│   ├── mobile.rs         # mobile impl (calls run_mobile_plugin)
│   ├── error.rs          # crate Error + Result aliases
│   ├── lib.rs            # Builder + setup + invoke_handler + Ext trait
│   └── models.rs         # shared serde structs
├── permissions/          # *.toml manifests; autogen.toml is regenerated by build.rs
├── android/              # if --android: Kotlin library + Plugin class
├── ios/                  # if --ios: Swift package + Plugin class
├── guest-js/             # TypeScript source for the npm package
├── dist-js/              # transpiled output (published, gitignored at source)
├── build.rs              # calls tauri_plugin::Builder::new(COMMANDS).build()
├── Cargo.toml
└── package.json
```

To add mobile to an existing plugin: `bunx @tauri-apps/cli plugin android add` / `plugin ios add`.

**Naming convention:** Cargo crate is `tauri-plugin-<name>`; npm package is
`tauri-plugin-<name>-api` or (preferred) `@your-scope/plugin-<name>`. The runtime plugin identifier
passed to `Builder::new(...)` is just `<name>` — keep it short, snake_case, and matching the
directory.

## The plugin Builder (`src/lib.rs`)

The crate's public entry point is an `init()` function returning `TauriPlugin<R>`. Consumers call
`tauri::Builder::default().plugin(tauri_plugin_my_plugin::init())`.

```rust
use tauri::{
    plugin::{Builder, TauriPlugin},
    Manager, Runtime,
};

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("my-plugin")
        .invoke_handler(tauri::generate_handler![
            commands::greet,
            commands::set_counter,
        ])
        .setup(|app, _api| {
            app.manage(MyState::default());
            Ok(())
        })
        .build()
}
```

The string `"my-plugin"` is the **plugin identifier** — it shows up in `plugin:my-plugin|cmd` from
JS and as the prefix in permission identifiers. Don't change it after release without a major
version bump.

### Lifecycle hooks (chain on the Builder)

| Hook                 | Signature       | Use for          |                    |                                                                                 |
| -------------------- | --------------- | ---------------- | ------------------ | ------------------------------------------------------------------------------- |
| `.setup(\            | app, api\       | { ... Ok(()) })` | once at init       | register state, spawn background threads, read plugin config via `api.config()` |
| `.on_navigation(\    | window, url\    | bool)`           | every navigation   | return `false` to cancel; track URL changes                                     |
| `.on_webview_ready(\ | window\         | { ... })`        | each new webview   | attach per-window listeners                                                     |
| `.on_event(\         | app, run_event\ | { ... })`        | every `RunEvent`   | handle `ExitRequested`, `Exit` (cleanup), window events                         |
| `.on_drop(\          | app\            | { ... })`        | plugin destruction | flush state, close handles                                                      |

### Plugin configuration

If consumers can configure your plugin via `tauri.conf.json > plugins.<name>`, parameterize
`TauriPlugin` with a `Deserialize` config type:

```rust
#[derive(serde::Deserialize)]
pub struct Config { timeout: usize }

pub fn init<R: Runtime>() -> TauriPlugin<R, Config> {
    Builder::<R, Config>::new("my-plugin")
        .setup(|app, api| {
            let _t = api.config().timeout;
            Ok(())
        })
        .build()
}
```

Use `Builder::<R, Option<Config>>` to make the config optional.

## Commands

Commands live in `src/commands.rs` and look identical to app commands — `#[tauri::command]`, async
or sync, taking `AppHandle<R>`, `Window<R>`, `State<'_, T>`, and serde-deserializable args. The only
differences:

1. They must be registered on the **plugin Builder's** `invoke_handler`, not the app's.
2. They're invoked from JS as `invoke('plugin:my-plugin|command_name', args)`.
3. They need a permission (see next section) or every call is rejected.

```rust
// src/commands.rs
use tauri::{command, AppHandle, Runtime, State};

#[command]
pub async fn greet<R: Runtime>(
    _app: AppHandle<R>,
    state: State<'_, crate::MyState>,
    name: String,
) -> Result<String, String> {
    state.bump();
    Ok(format!("Hello, {name}!"))
}
```

Need raw access? Take `tauri::ipc::Invoke<R>` as the only parameter and handle serialization
yourself — useful for streaming or non-JSON payloads.

## Permissions — the part new plugin authors get wrong

By default **no command is callable from JS**. You must (a) declare a permission that allows it and
(b) have the consuming app include that permission in a capability.

### Autogenerated allow/deny pairs (the easy path)

In `build.rs`, list every command name (snake_case, matching the Rust fn) in `COMMANDS`. Tauri's
build script generates `permissions/autogenerated/commands/<name>.toml` with `allow-<name>` and
`deny-<name>` identifiers, plus a regenerated `permissions/autogenerated/reference.md`.

```rust
// build.rs
const COMMANDS: &[&str] = &["greet", "set_counter"];

fn main() {
    tauri_plugin::Builder::new(COMMANDS).build();
}
```

### Hand-written permission file (when you want control)

```toml
# permissions/allow-greet.toml
"$schema" = "schemas/schema.json"

[[permission]]
identifier = "allow-greet"
description = "Enables the greet command."
commands.allow = ["greet"]
```

### Default permission set

Define a `default` set that consumers get by referencing `my-plugin:default` in a capability — pull
together the commands the plugin is useless without:

```toml
# permissions/default.toml
"$schema" = "schemas/schema.json"
[default]
description = "Default permissions for my-plugin: greeting works."
permissions = ["allow-greet"]
```

Group related commands with `[[set]]`:

```toml
[[set]]
identifier = "allow-counter"
description = "Read and write the counter."
permissions = ["allow-get-counter", "allow-set-counter"]
```

### Scopes (per-command or global restrictions)

For commands that act on resources (paths, binaries, URLs), define a scope struct in `src/scope.rs`,
derive `schemars::JsonSchema`, and in `build.rs` pass it via
`.global_scope_schema(schemars::schema_for!(scope::Entry))`. Read it from commands via
`tauri::ipc::CommandScope<'_, Entry>` (per-command) or `GlobalScope<'_, Entry>` (plugin-wide). Add
`schemars = "0.8"` to **both** `[dependencies]` and `[build-dependencies]` because the scope module
is shared.

## JS API package (`guest-js/index.ts`)

Wrap raw `invoke` calls in typed friendly functions. This is what consumers `import` from your npm
package.

```ts
import { invoke } from '@tauri-apps/api/core';

export async function greet(name: string): Promise<string> {
  return await invoke<string>('plugin:my-plugin|greet', { name });
}
```

For event streaming, use `Channel` from `@tauri-apps/api/core`. For events emitted with
`trigger(...)` on mobile, expose `addPluginListener('my-plugin', 'event-name', handler)`.

`package.json` should declare `"type": "module"` and point `main`/`module`/`types` at `dist-js/`.
Run a bundler (the template uses `rollup`) so consumers get prebuilt output.

## State management

Same pattern as app commands — register in `setup`, access via `State<'_, T>`:

```rust
#[derive(Default)]
pub struct MyState(std::sync::Mutex<u64>);

impl MyState {
    pub fn bump(&self) { *self.0.lock().unwrap() += 1; }
}

// in init():
.setup(|app, _api| {
    app.manage(MyState::default());
    Ok(())
})
```

Expose a typed extension trait so consumers can read state from their own Rust code without knowing
the struct name:

```rust
pub trait MyPluginExt<R: Runtime> {
    fn my_plugin(&self) -> &MyState;
}

impl<R: Runtime, T: Manager<R>> MyPluginExt<R> for T {
    fn my_plugin(&self) -> &MyState { self.state::<MyState>().inner() }
}
```

Now `app.my_plugin().bump()` works in the consumer app after
`.plugin(tauri_plugin_my_plugin::init())`.

## Mobile (Android / iOS)

When `--android` or `--ios` is passed at scaffold time, the template splits the public API:

- `src/desktop.rs` — pure Rust implementation.
- `src/mobile.rs` — calls `run_mobile_plugin("commandName", payload)` on a `PluginHandle`.
- `src/lib.rs` — `cfg`-switches between the two and exposes a single shared struct.

```rust
// src/mobile.rs (sketch)
impl<R: Runtime> MyPlugin<R> {
    pub fn open_camera(&self, payload: CameraRequest) -> crate::Result<Photo> {
        self.0.run_mobile_plugin("openCamera", payload).map_err(Into::into)
    }
}
```

The mobile-side handler is a Kotlin class extending `app.tauri.plugin.Plugin` with `@TauriPlugin`
and `@Command`-annotated methods (Android), or a Swift class extending `Plugin` with `@objc func
name(_ invoke: Invoke)` methods (iOS). Args are parsed via `invoke.parseArgs(MyArgs::class.java)` /
`invoke.parseArgs(MyArgs.self)`; results go through `invoke.resolve(...)` / `invoke.reject(...)`.

Use `trigger("event-name", payload)` from mobile code to emit events that JS picks up via
`addPluginListener`. Listening from JS still requires the plugin's permission in the consumer's
capability.

Mobile permission prompts (camera, notifications, etc.) — override `checkPermissions` /
`requestPermissions` on the Plugin class. Tauri auto-exposes them as the `checkPermissions` and
`requestPermissions` commands; no extra Rust wiring is needed beyond the `run_mobile_plugin` call
from a Rust convenience method.

Calling Rust from mobile native code (when you want shared logic and the WebView may be suspended):
add `jni = "0.21"` under `[target.'cfg(target_os = "android")'.dependencies]`, define `external fun`
declarations in Kotlin matching `Java_<pkg>_<class>_<method>` `#[no_mangle] pub extern "system" fn`
exports in Rust. On iOS, `@_silgen_name("fn_name")` in Swift maps to `#[no_mangle] pub unsafe extern
"C" fn fn_name(...)` in Rust — pass `*const c_char` for strings and ship a matching `free_*` to drop
the `CString`.

## Build script (`build.rs`)

Always present. Two responsibilities:

1. Tell Tauri which commands to autogenerate permissions for (the `COMMANDS` const).
2. Optionally register a scope schema with `.global_scope_schema(...)`.

The build script regenerates `permissions/autogenerated/` on every build — never edit those files by
hand; edit `COMMANDS` and rebuild.

## Publishing

Crate and npm package are versioned **together** — bump both, tag once, publish in this order:

```sh
# 1. Build the JS package
cd guest-js && bun run build && cd ..

# 2. Publish npm (uses dist-js/)
npm publish --access public

# 3. Publish crate
cargo publish
```

Consumers install:

```sh
cargo add tauri-plugin-my-plugin
bun add @your-scope/plugin-my-plugin   # or tauri-plugin-my-plugin-api
```

And wire it up:

```rust
tauri::Builder::default()
  .plugin(tauri_plugin_my_plugin::init())
  .run(tauri::generate_context!())
  .expect("error while running tauri application");
```

```json
// src-tauri/capabilities/default.json — consumer must opt in
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": ["my-plugin:default"]
}
```

## Common failure modes

- **"Command plugin:foo|bar not allowed by ACL"** — consumer's capability is missing `foo:default`
  or `foo:allow-bar`. The plugin author can't fix this from inside the plugin; document the required
  permission in your README.
- **Commands missing from JS** — forgot to add to `tauri::generate_handler![...]` on the **plugin**
  Builder (not the app's).
- **`autogenerated/` not regenerating** — `COMMANDS` const in `build.rs` doesn't include the new
  command name, or you edited the generated `.toml` directly (it gets clobbered).
- **State `State<'_, T>` panics with "state not managed"** — `setup` returned `Err` or
  `app.manage(...)` was never called. Make sure `setup` doesn't short-circuit.
- **Mobile command never fires** — Rust side called `run_mobile_plugin("openCamera", ...)` but the
  Kotlin/Swift method name is `open_camera` — names must match exactly (camelCase is convention on
  the mobile side).
- **Changing the plugin identifier** breaks every consumer capability silently. Treat
  `Builder::new("...")` as a public API.

## Templates

See `templates/`:

- `lib.rs` — full plugin skeleton: Builder + setup + state + extension trait + one command.
- `permissions/allow-greet.toml` — hand-written permission file.
- `guest-js-index.ts` — typed JS API wrapper.

