External Binaries (Sidecars)

Embed standalone binaries in your Tauri v2 app to ship tools like FFmpeg, Python scripts, or custom servers without requiring users to install anything extra

Not every piece of functionality fits neatly inside a Rust command or a JavaScript module. You might need to ship a small CLI tool, a compiled Python script, or a lightweight server binary alongside your application. Tauri calls these bundled executables sidecars, and they run as separate processes managed by your app.

The term comes from motorcycle sidecars — the binary sits alongside your main application, attached but independent. Sidecars let you include tools written in any language without forcing users to install them separately. Common use cases include bundling FFmpeg for media processing, shipping a Python backend as a standalone executable, or embedding a small database engine.

This guide covers the full lifecycle: understanding what sidecars are, adding them to your configuration, handling cross-platform naming, running them from Rust and JavaScript, passing arguments, and following best practices to avoid common pitfalls.

What a Sidecar Actually Is

A sidecar is an external executable file bundled inside your Tauri application package. When your app launches, the binary sits in the installation directory. Your code can spawn it as a child process, communicate with it via standard input/output, and terminate it when needed.

The binary is not linked into your Rust code. It runs as a completely separate operating system process with its own memory space. This means it can be written in any language — Python compiled with PyInstaller, a Go CLI, a C++ tool, or even another Rust binary that you want to keep separate from the main Tauri core.

From the operating system's perspective, the sidecar is just another program that happens to live inside your application's bundle. On macOS, it ends up inside the .app/Contents/MacOS/ directory. On Windows, it's in the same folder as the .exe. On Linux, it sits next to the main binary.

Sidecar vs. Resource:

Resources are data files like images, config files, or JSON documents. Sidecars are executable binaries. If you just need to ship a file that gets read at runtime, use resources instead.

Adding an External Binary to Your Project

The setup has three sequential parts: preparing the binary file, telling Tauri about it in the configuration, and granting the shell plugin permission to execute it. Each step depends on the previous one completing correctly.

1

Step 1: Place the binary in src-tauri/binaries with the correct target triple suffix

Tauri needs to know which binary belongs to which platform. It does this by looking for a file with the same name as the one you specify in externalBin, plus a suffix that identifies the operating system and CPU architecture.

Create a binaries directory inside src-tauri/ and place your compiled executable there. Rename it so that the filename ends with -{target-triple}. For example, if your binary is called my-tool, you might have:

  • src-tauri/binaries/my-tool-x86_64-unknown-linux-gnu on 64-bit Linux
  • src-tauri/binaries/my-tool-aarch64-apple-darwin on Apple Silicon macOS
  • src-tauri/binaries/my-tool-x86_64-pc-windows-msvc.exe on 64-bit Windows

To find your current platform's target triple, run this command in a terminal:

rustc --print host-tuple

The output will be something like x86_64-unknown-linux-gnu or aarch64-apple-darwin. Use that exact string as the suffix.

If you need to automate this renaming — for example, in a build script — here is a Node.js script that appends the correct triple:

import { execSync } from 'child_process';
import fs from 'fs';
const extension = process.platform === 'win32' ? '.exe' : '';
const targetTriple = execSync('rustc --print host-tuple').toString().trim();
if (!targetTriple) {
  console.error('Failed to determine platform target triple');
  process.exit(1);
}
fs.renameSync(
  `src-tauri/binaries/my-tool${extension}`,
  `src-tauri/binaries/my-tool-${targetTriple}${extension}`
);

This script renames my-tool (or my-tool.exe) to include the target triple. Use it as a starting point for your own build pipeline.

Without the target triple, Tauri will not bundle the binary:

The bundler strictly expects a file with the -{triple} suffix. If you only have my-tool without the suffix, you will get an error like "Failed to copy external binaries" or the binary will simply be missing at runtime. Always include the suffix.

2

Step 2: Add the binary to the tauri.conf.json externalBin array

Open src-tauri/tauri.conf.json and locate the bundle section. Add an externalBin array with the relative path from the src-tauri directory to your binary, without the target triple suffix or file extension.

src-tauri/tauri.conf.json
{
  "bundle": {
    "externalBin": [
      "binaries/my-tool"
    ]
  }
}

The path binaries/my-tool tells Tauri: "look in src-tauri/binaries/ for files starting with my-tool- followed by a target triple." Tauri resolves the correct file at build time based on the target you are compiling for.

You can specify multiple binaries, and you can use absolute paths if needed. The bundler will copy each matching binary into the final application package.

Do not include the target triple or extension in the config path:

Writing "binaries/my-tool-x86_64-unknown-linux-gnu" will cause Tauri to look for a file named my-tool-x86_64-unknown-linux-gnu-{triple} — which does not exist. The config path must be the base name only.

3

Step 3: Grant the shell plugin permission to run the sidecar

Tauri v2 enforces a permission system. Before your code can spawn a sidecar, you must declare the capability in src-tauri/capabilities/default.json.

Add the shell:allow-execute or shell:allow-spawn permission with a sidecar: true entry for your binary. The name field must match the exact path you used in externalBin.

src-tauri/capabilities/default.json
{
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "name": "binaries/my-tool",
          "sidecar": true
        }
      ]
    }
  ]
}

This tells the shell plugin that the binary at binaries/my-tool is a sidecar and may be executed. Without this entry, any attempt to spawn the sidecar will be blocked by Tauri's security layer.

Verify your setup:

After completing these three steps, run tauri build --debug to confirm the binary gets bundled. If the build succeeds without "Failed to copy external binaries" errors, the sidecar is correctly placed inside your app bundle.

Adding the Shell Plugin Dependency

The sidecar functionality lives inside the tauri-plugin-shell plugin. If you have not added it yet, install it in both the Rust backend and the frontend:

Rust (src-tauri/Cargo.toml):

src-tauri/Cargo.toml
[dependencies]
tauri-plugin-shell = "2"

JavaScript/React (terminal):

npm install @tauri-apps/plugin-shell

Then register the plugin in your main.rs or lib.rs:

src-tauri/src/lib.rs
use tauri_plugin_shell::ShellExt;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Running a Sidecar from Your Code

Once the binary is bundled and permissions are set, you can spawn it. The approach differs between Rust and JavaScript, but both use the same underlying mechanism.

In Rust, import the ShellExt trait and call the sidecar() method on the app handle. The method returns a command builder that you can configure with arguments and then spawn.

src-tauri/src/commands.rs
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri::Emitter;
#[tauri::command]
async fn run_my_sidecar(app: tauri::AppHandle) -> Result<(), String> {
    let sidecar_command = app
        .shell()
        .sidecar("my-tool")
        .map_err(|e| format!("Failed to create sidecar command: {}", e))?;
    let (mut rx, mut child) = sidecar_command
        .spawn()
        .map_err(|e| format!("Failed to spawn sidecar: {}", e))?;
    // Read output from the sidecar and emit it to the frontend
    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes);
                    app.emit("sidecar-output", line.to_string())
                        .expect("failed to emit event");
                }
                CommandEvent::Stderr(line_bytes) => {
                    let line = String::from_utf8_lossy(&line_bytes);
                    app.emit("sidecar-error", line.to_string())
                        .expect("failed to emit event");
                }
                CommandEvent::Terminated(payload) => {
                    app.emit("sidecar-terminated", payload.code)
                        .expect("failed to emit event");
                }
                _ => {}
            }
        }
    });
    // Optionally write data to the sidecar's stdin
    child
        .write("input data\n".as_bytes())
        .map_err(|e| format!("Failed to write to stdin: {}", e))?;
    Ok(())
}

The sidecar() method automatically appends the correct target triple for the current platform. You pass only the base name, just like in the configuration.

The spawn call returns a receiver (rx) for events and a handle to the child process (child). The receiver delivers stdout, stderr, and termination events asynchronously. You can forward these to the frontend using Tauri's event system, write data to the sidecar's stdin, or kill the process with child.kill().

Child processes must be explicitly killed:

Tauri does not automatically terminate sidecar processes when the application closes. If you do not kill the child process, it becomes an orphaned process consuming resources on the user's machine. Always store the child handle and call child.kill() during the app's cleanup lifecycle, or use the Drop trait to manage it.

Passing Arguments to a Sidecar

Most real-world tools need arguments — a file path to process, a port to listen on, a verbosity flag. Tauri requires you to declare allowed arguments in the capability file before passing them, preventing unexpected argument injection.

Arguments come in two forms: static arguments (fixed strings like --verbose or serve) and dynamic arguments (variable values like file paths, which you validate with a regex). You must list them in the exact order the sidecar expects.

First, define the allowed arguments in your capability file:

src-tauri/capabilities/default.json
{
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "name": "binaries/my-tool",
          "sidecar": true,
          "args": [
            "process",
            "--output",
            {
              "validator": "\\S+"
            }
          ]
        }
      ]
    }
  ]
}

This configuration allows exactly three arguments in order: the static string process, the static flag --output, and a dynamic argument that matches the regex \S+ (any non-whitespace string). The sidecar will be called as my-tool process --output <value>.

Then, pass the arguments when creating the command. They must match the declared list exactly.

In Rust:

let sidecar_command = app
    .shell()
    .sidecar("my-tool")
    .unwrap()
    .args(["process", "--output", "/path/to/output"]);
let (mut rx, mut child) = sidecar_command.spawn().unwrap();

In JavaScript:

const command = Command.sidecar('binaries/my-tool', [
  'process',
  '--output',
  '/path/to/output',
]);
const result = await command.execute();

If the argument list does not exactly match what is declared in the capability file — in count, order, or content — the shell plugin will reject the command with a permission error.

Dynamic arguments are not arbitrary strings:

The regex validator is the last line of defense. If you set the validator to .*, any string will be accepted, including potentially dangerous inputs. Prefer restrictive patterns that match the expected format. For file paths, use something like [a-zA-Z0-9_/.-]+ rather than a catch-all.

A Practical End-to-End Example

Suppose you have a small Python script that generates QR codes, compiled into a standalone binary called qr-gen. The practical examples page walks through more complete sidecar setups. You want to bundle it, call it from your React frontend, and display the result.

The binary is placed in src-tauri/binaries/qr-gen-x86_64-unknown-linux-gnu (and equivalents for other platforms). The configuration:

src-tauri/tauri.conf.json
{
  "bundle": {
    "externalBin": ["binaries/qr-gen"]
  }
}

The capability file allows one dynamic argument — the text to encode:

src-tauri/capabilities/default.json
{
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "name": "binaries/qr-gen",
          "sidecar": true,
          "args": [
            {
              "validator": ".+"
            }
          ]
        }
      ]
    }
  ]
}

The Rust backend exposes a Tauri command that spawns the sidecar and returns the output:

src-tauri/src/commands.rs
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::CommandEvent;
#[tauri::command]
async fn generate_qr(app: tauri::AppHandle, text: String) -> Result<String, String> {
    let sidecar_command = app
        .shell()
        .sidecar("qr-gen")
        .map_err(|e| e.to_string())?
        .args([&text]);
    let (mut rx, _child) = sidecar_command
        .spawn()
        .map_err(|e| e.to_string())?;
    let mut output = String::new();
    while let Some(event) = rx.recv().await {
        if let CommandEvent::Stdout(bytes) = event {
            output.push_str(&String::from_utf8_lossy(&bytes));
        }
    }
    Ok(output)
}

The React component calls the command:

src/App.tsx
import { useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
function App() {
  const [text, setText] = useState('');
  const [qrData, setQrData] = useState('');
  async function handleGenerate() {
    try {
      const result = await invoke<string>('generate_qr', { text });
      setQrData(result);
    } catch (error) {
      console.error('QR generation failed:', error);
    }
  }
  return (
    <div>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Enter text for QR code"
      />
      <button onClick={handleGenerate}>Generate QR</button>
      {qrData && <img src={`data:image/png;base64,${qrData}`} />}
    </div>
  );
}
export default App;

This pattern works for any binary that takes input and returns output. The Rust layer acts as a thin bridge between the frontend and the sidecar, handling process lifecycle and argument validation.

When the binary produces binary data:

If your sidecar outputs raw bytes (like an image), handle the stdout as bytes instead of converting to a UTF-8 string. Use CommandEvent::Stdout(line_bytes) directly and forward the bytes to the frontend as a base64-encoded string, as shown above.

Best Practices for Sidecars

The flexibility of external binaries comes with responsibility. A badly managed sidecar can leave zombie processes, leak resources, or create security holes. Follow these sidecar best practices as a checklist.

Always clean up child processes

A sidecar that continues running after the main application closes is a resource leak. Store the child process handle and call kill() when the app is shutting down. In Rust, you can use the Drop implementation of a wrapper struct. In JavaScript, listen for the window's beforeunload event or use a cleanup function in React's useEffect.

struct SidecarGuard(tauri_plugin_shell::process::CommandChild);
impl Drop for SidecarGuard {
    fn drop(&mut self) {
        let _ = self.0.kill();
    }
}

Handle errors at every stage

The sidecar binary might be missing, fail to start, return a non-zero exit code, or produce malformed output. Never assume the happy path. Check exit codes, log stderr, and surface meaningful error messages to the user instead of silently failing.

Restrict arguments tightly

Declare only the arguments your binary actually needs. Avoid broad validators like .* unless absolutely necessary. Each static argument should be a literal string, and each dynamic argument should have a regex that rejects unexpected input. This prevents a compromised frontend (via XSS, for example) from passing arbitrary commands to your sidecar.

Test on every target platform

A sidecar compiled for Linux will not run on Windows. You need a separate build for each target triple your application supports. Test the binary on each platform early. A common mistake is developing only on one OS and discovering at release time that the sidecar crashes on another.

Keep binary size in mind

Sidecars are included in the final installer, so their size adds directly to the download. A 200 MB binary bloats the app. Consider compressing with UPX or choosing a language that produces small binaries (Go, Rust) for sidecars that will be bundled.

Use the shell plugin's built-in spawning

Avoid using std::process::Command directly to run bundled binaries. Tauri's shell plugin handles target triple resolution, permission checks, and platform-specific quirks. Using raw OS process spawning bypasses these safeguards and makes cross-platform support harder.

Watch for macOS code signing issues

On macOS, sidecars bundled inside the .app must be code-signed. If you encounter "cannot be opened because the developer cannot be verified" errors, ensure your sidecar is signed along with the rest of the bundle. For universal macOS builds (targeting both Intel and Apple Silicon), you may need to combine architecture-specific sidecars into a single universal binary using lipo. Tauri's bundler expects a single binary with the target triple, so for universal-apple-darwin, you must create that combined binary yourself before bundling.

lipo -create my-tool-x86_64-apple-darwin my-tool-aarch64-apple-darwin -output my-tool-universal-apple-darwin

Universal sidecars need manual lipo merging:

Tauri v2 does not automatically combine architecture-specific sidecars into a universal binary. If you target universal-apple-darwin, build both arch-specific binaries, run lipo, and place the resulting file in the binaries folder with the -universal-apple-darwin suffix.

Summary

Sidecars give your Tauri application a practical escape hatch: any tool that can be compiled into a standalone executable can be bundled and controlled from your app. The three-step setup — place the binary with the target triple suffix, declare it in externalBin, and grant shell permissions — is the gate you must pass through once per binary. After that, Rust and JavaScript both offer clean, event-driven APIs for spawning, communicating with, and terminating sidecar processes.

The most common failure point is forgetting the target triple suffix. The second is mismatched paths between the configuration, capability file, and the sidecar() call. If nothing happens when you try to spawn the sidecar, check those three strings first.

With sidecars working, you can now bundle tools written in any language, call them from a React frontend via Tauri commands, and manage their entire lifecycle.

Understanding Sidecars

What sidecar binaries are in Tauri v2, the problems they solve, and how the bundling and naming system works before you start adding your own.

Adding External Binaries

Configure the externalBin field in Tauri v2 to bundle platform-specific executables as sidecars

Running Sidecars

How to execute external binaries from Tauri v2, pass arguments, capture output, handle errors, and manage sidecar processes with React and Vite.

Practical Examples

Step-by-step examples for bundling FFmpeg, Python scripts, Git, ImageMagick, and custom CLI tools as Tauri sidecars.

Best Practices for Sidecar Binaries

Production-tested best practices for embedding, securing, and maintaining external binaries as sidecars in Tauri v2 desktop applications