Registering Commands in a Plugin

How to define Rust commands, register them in a custom Tauri v2 plugin, configure permissions, and invoke them from a React frontend

Commands are the primary mechanism a plugin exposes to move logic from the web frontend into Rust. Without commands, a plugin is just configuration and lifecycle hooks—useful, but not interactive. This section walks through every layer required to get a command from a Rust function inside a custom plugin all the way to a typed invoke call in a React component, including the permission model that trips up most first-time plugin authors. The same #[tauri::command] rules as Creating Your First Rust Command apply; the difference is the plugin namespace and permission files.

Defining the Command Function

A plugin command starts as an ordinary Rust function with the #[tauri::command] attribute. It can accept primitive types, complex structs that implement Deserialize, and Tauri-managed state. The return type is typically a Result<T, impl Serialize> so the frontend can distinguish success from failure.

Create the file src/commands.rs inside the plugin crate. If you generated the plugin with tauri plugin new, this file already exists with placeholder commands.

// tauri-plugin-hello/src/commands.rs
use tauri::{AppHandle, Runtime, State};
use crate::HelloState;
#[tauri::command]
pub fn greet(name: String) -> String {
    format!("Hello, {}! You have been greeted from a custom plugin.", name)
}
#[tauri::command]
pub async fn fetch_greeting<R: Runtime>(
    _app: AppHandle<R>,
    url: String,
    state: State<'_, HelloState>,
) -> Result<String, String> {
    let response = reqwest::get(&url)
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())?;
    let count = {
        let mut counter = state.counter.lock().map_err(|e| e.to_string())?;
        *counter += 1;
        *counter
    };
    Ok(format!("{} (called {} times)", response, count))
}

HelloState is a simple struct we will manage in the plugin setup. Notice that the second command is async and accepts State. Both commands will be callable from JavaScript once registered.

Registering Commands with the Plugin Builder

In the plugin’s lib.rs (or desktop.rs / mobile.rs depending on platform-specific logic), you create the plugin instance with a Builder. The invoke_handler ties the command functions to the plugin’s IPC namespace.

// tauri-plugin-hello/src/lib.rs
use tauri::{
    plugin::{Builder, TauriPlugin},
    Manager, Runtime,
};
use std::sync::Mutex;
mod commands;
mod error;
pub use error::*;
pub struct HelloState {
    pub counter: Mutex<usize>,
}
/// Initializes the plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("hello")
        .setup(|app, _api| {
            app.manage(HelloState {
                counter: Mutex::new(0),
            });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            commands::greet,
            commands::fetch_greeting,
        ])
        .build()
}

generate_handler! is a macro that creates the necessary bridge between command signatures and the IPC system. Every command you want the frontend to call must appear in this list.

At this point, the Rust side is complete, but the frontend still cannot call these commands. Tauri v2 enforces a capability-based permission model, and a plugin’s commands are invisible until two additional pieces are in place: the plugin manifest and the app’s capabilities file.

Exposing Commands to the Frontend (Permissions)

Tauri v2 requires each plugin to declare its commands at compile time so the runtime knows which commands exist and can enforce access control. This is done through a build.rs manifest, even for local custom plugins you wrote yourself. Then the consuming app must grant permissions to those commands in a capability file.

Step 1: Register the plugin manifest in build.rs

Inside the plugin crate’s build.rs, register each command by name. This tells the Tauri build system, "these commands belong to this plugin."

// tauri-plugin-hello/build.rs
fn main() {
    tauri_build::try_build(
        tauri_build::Attributes::new()
            .plugin(
                "hello",
                tauri_build::InlinedPlugin::new()
                    .commands(&["greet", "fetch_greeting"]),
            ),
    )
    .expect("failed to run tauri-build");
}

The string "hello" must match the plugin name used in Builder::new("hello"). The command names must exactly match the function names after the #[tauri::command] attribute (the name you’d pass to invoke from the frontend).

Missing build.rs causes Plugin not found:

Skipping the build.rs registration is the single most common reason a custom plugin’s commands produce the error "command not allowed. Plugin not found" in the browser console. The runtime has no way to discover your commands without it.

Step 2: Add permissions to the app’s capabilities

In the Tauri app that consumes the plugin (the project that has src-tauri/ and calls .plugin(hello::init()) in lib.rs), you must add the plugin’s permissions to a capability file. Usually this is src-tauri/capabilities/default.json.

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default-capability",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "hello:allow-greet",
    "hello:allow-fetch-greeting"
  ]
}

Each permission follows the pattern plugin-name:allow-command-name. For greet, that’s hello:allow-greet; for fetch_greeting, hello:allow-fetch-greeting. Omitting a permission causes the command to fail silently or log a permission error in the DevTools console.

Permissions are per command:

Even if you grant one command, others remain blocked. Add each command explicitly. Wildcard permissions like hello:allow-all do not exist by default; they must be defined in the plugin’s permission files if desired.

After both the manifest and the capability are in place, rebuild the app. The commands are now callable.

Invoking Commands from React

With the plugin registered in the app’s lib.rs (.plugin(hello::init())) and permissions configured, the React frontend can use the invoke function from @tauri-apps/api/core.

// src/App.tsx
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
function App() {
  const [greeting, setGreeting] = useState("");
  const [fetchResult, setFetchResult] = useState("");
  const [error, setError] = useState("");
  const callGreet = async () => {
    try {
      const result = await invoke<string>("greet", { name: "React" });
      setGreeting(result);
      setError("");
    } catch (e) {
      setError(String(e));
    }
  };
  const callFetchGreeting = async () => {
    try {
      const result = await invoke<string>("fetch_greeting", {
        url: "https://api.example.com/greeting",
      });
      setFetchResult(result);
      setError("");
    } catch (e) {
      setError(String(e));
    }
  };
  return (
    <div>
      <button onClick={callGreet}>Greet</button>
      {greeting && <p>{greeting}</p>}
      <button onClick={callFetchGreeting}>Fetch Greeting</button>
      {fetchResult && <p>{fetchResult}</p>}
      {error && <p style={{ color: "red" }}>Error: {error}</p>}
    </div>
  );
}
export default App;

Each invoke call takes the command name (the same string used in build.rs and generate_handler!) and a payload object whose keys match the Rust function’s parameter names. The type parameter (<string>) tells TypeScript what to expect—it is not enforced at runtime, but a mismatch will cause a deserialization error.

Working command confirmation:

If the button click shows "Hello, React! You have been greeted from a custom plugin." and the network call returns data with a call count, the entire registration chain is correct.

A Complete Walkthrough — Step by Step

When you add a new command to an existing plugin, the order matters. Missing any step produces hard-to-diagnose failures.

1

Step 1: Write the Rust command

Add a new function in src/commands.rs (or lib.rs) annotated with #[tauri::command]. Accept and return serializable types.

#[tauri::command]
pub fn add_note(title: String, body: String) -> Result<(), String> {
    // persist logic
    Ok(())
}
2

Step 2: Register in the plugin builder

Inside the init function, add the command to generate_handler!.

.invoke_handler(tauri::generate_handler![
    commands::greet,
    commands::fetch_greeting,
    commands::add_note,
])
3

Step 3: Declare the command in build.rs

In the plugin’s build.rs, add the command name to the InlinedPlugin commands list.

.plugin(
    "hello",
    tauri_build::InlinedPlugin::new()
        .commands(&["greet", "fetch_greeting", "add_note"]),
)
4

Step 4: Grant the permission in the app

In src-tauri/capabilities/default.json (or the relevant capability file), append "hello:allow-add-note" to the "permissions" array.

5

Step 5: Call from React

Use invoke("add_note", { title: "Shopping", body: "Milk, bread" }) in a component. Handle both resolve and reject.

How Beginners Should Think About Command Registration

Imagine a plugin as a secure building. The Rust function is a room inside. The generate_handler! macro installs a door from the IPC hallway into that room. The build.rs manifest gives the building management a list of doors that exist. The app’s capability file gives a specific visitor (the frontend window) a key to that door. If any of these is missing—no door, no room, no key—the visitor stands outside and sees nothing.

This mental model helps diagnose the most common failure: a command works in one environment but not another, or works after a full rebuild but not after a frontend-only hot reload. Usually one of the three registration layers is not yet in sync.

Common Mistakes and How to Spot Them

  • Missing build.rs manifest. The frontend logs "command not allowed. Plugin not found" even though the plugin is listed in Cargo.toml and called in lib.rs. Fix: add the command name to the InlinedPlugin commands in build.rs and rebuild.
  • Typo in command name. The name passed to invoke, listed in build.rs, and present in generate_handler! must match exactly (case-sensitive). A mismatch fails silently. The DevTools console is your first place to look—it will show the failed IPC request.
  • Permission not added to capability. The command is defined and registered but the app capability file lacks the allow-... entry. The error message in the console will say something like "hello:add_note not allowed. Permissions associated with this command: hello:allow-add-note". That message is telling you exactly which permission to add.
  • Not rebuilding after build.rs changes. Because build.rs affects compile-time metadata, a simple npm run tauri dev may not pick up the change if the Rust code was cached. Run cargo clean inside src-tauri or force a full rebuild.
  • Confusing the plugin name. The string in Builder::new("hello"), the key in build.rs plugin registration, and the permission prefix (hello:allow-...) must be identical. Using a different string in one place creates a mismatch.

Summary

Registering a command in a Tauri v2 plugin is a three‑part operation: Rust definition, plugin‑builder registration, and permission propagation via manifest and capability file. Every layer exists for a reason—the builder connects the function, the manifest declares it to the runtime, and the capability grants access to the frontend. The most frequent failure is skipping the build.rs step, which is unique to the plugin context and often overlooked by developers familiar with app‑level commands. Once you understand that each command is a door that needs to be built, listed, and unlocked, the process becomes mechanical.