Shell API
Learn how to open external URLs and files with the default system app, run system commands from a Tauri app, and lock down what can be executed using the capability-based security model.
The Shell API gives a Tauri app the ability to reach past the webview and interact with the user's operating system. It handles two distinct jobs: opening a resource with the default program (a URL in a browser, a PDF in a PDF reader) and running a command-line program as a child process, with full access to its input and output.
In Tauri v2, the shell functionality ships as a plugin. You opt into it by adding both the Rust crate and the JavaScript package to your project. Nothing is available unless you intentionally register it. The Introduction covers that install in isolation.
Plugin, not built-in:
The Shell API requires the plugin @tauri-apps/plugin-shell (npm) and tauri-plugin-shell (Cargo). It is not part of the core @tauri-apps/api package. If you see import errors, the plugin has likely not been added or registered.
Installing the Shell Plugin
Before any shell function can be called, the plugin must be wired up on both the Rust side and the JavaScript side. The steps are short, and the process is the same as any other Tauri v2 plugin.
Add the Rust crate
Add tauri-plugin-shell to src-tauri/Cargo.toml under [dependencies]:
[dependencies]
tauri-plugin-shell = "2"
Register the plugin in the Rust backend
In src-tauri/src/lib.rs, call .plugin(tauri_plugin_shell::init()) on the Tauri builder. The file should look similar to this:
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Install the JavaScript package
Run the package manager command for your project:
npm install @tauri-apps/plugin-shell
Verify the installation:
If the app compiles and you can import from @tauri-apps/plugin-shell without a build error, the plugin is registered correctly. The next step is to grant capabilities.
Opening External Resources
An external resource is anything the operating system knows how to handle that sits outside your application window: a website, a local file, a directory, or even a mailto: link. The open function asks the OS to launch the default program for that path or URL.
import { open } from '@tauri-apps/plugin-shell';
// Open a website in the default browser
await open('https://tauri.app');
// Open a local file with its associated app
await open('/Users/alex/documents/report.pdf');
// Open a folder in the system file explorer
await open('/Users/alex/projects');
The call returns a promise. If the OS cannot find a program to handle the resource, the promise rejects. There is no return value on success — the external program takes over and your app continues running independently.
The open function in a React component
This example renders a text input and a button. A user can type a URL or file path and open it with one click.
import { useState } from 'react';
import { open } from '@tauri-apps/plugin-shell';
function App() {
const [path, setPath] = useState('https://tauri.app');
const handleOpen = async () => {
if (!path.trim()) return;
try {
await open(path);
} catch (error) {
console.error('Failed to open resource:', error);
}
};
return (
<div>
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="URL or file path"
/>
<button onClick={handleOpen}>Open</button>
</div>
);
}
export default App;
The code imports open directly from the shell plugin. No additional Rust code is required beyond the plugin registration shown earlier. However, for open to actually work, the capability file must permit it — a blank configuration will block every shell call, including open.
Capabilities block everything by default:
If you register the plugin but do not add any shell permission, calling open will fail silently or throw a permission error. Tauri v2 treats all APIs as deny-by-default. You must explicitly allow shell:open in a capability file.
Spawning Child Processes
Opening a resource is a fire-and-forget action. Running a command is a conversation: you launch a process, optionally write to its stdin, and read its stdout and stderr. Tauri exposes this through the Command class. For restarting or exiting the app itself, use the Process plugin rather than shell.
The two primary methods are execute and spawn. execute runs the process to completion and returns all output at once. spawn returns a handle while the process is still running, and you listen to events for each chunk of output.
Running a command and collecting output
import { Command } from '@tauri-apps/plugin-shell';
const output = await Command.create('echo', ['hello from tauri']).execute();
console.log(output.stdout); // "hello from tauri\n"
console.log(output.code); // 0
Command.create takes the program name as the first argument and an array of arguments as the second. It mirrors how you would type a command in a terminal. The returned ChildProcess object contains stdout, stderr, code, and signal.
This is ideal for short-lived utilities: git status, node --version, ffmpeg -i input.mp4 -f null -. The promise resolves only when the process exits, so a stuck process will hang the call.
Spawning a long-running process
When a command produces output gradually — a build script, a server, a file conversion — spawn lets you handle output as it arrives. You register event listeners on the command's stdout and stderr emitters before calling spawn.
import { Command } from '@tauri-apps/plugin-shell';
const cmd = Command.create('ping', ['-c', '4', 'tauri.app']);
cmd.stdout.on('data', (line) => {
console.log('stdout:', line);
});
cmd.stderr.on('data', (line) => {
console.error('stderr:', line);
});
cmd.on('close', (payload) => {
console.log(`Process exited with code ${payload.code}`);
});
cmd.on('error', (error) => {
console.error('Failed to start process:', error);
});
const child = await cmd.spawn();
console.log('Spawned PID:', child.pid);
The child handle returned by spawn gives you the process ID and two methods: write to send data to stdin, and kill to terminate the process.
// Write to the process's stdin
await child.write('some input\n');
// Forcefully stop the process
await child.kill();
The event-based API is the same pattern Node.js developers know from child_process.spawn. A key difference: Tauri does not pipe stdin/stdout through the webview's JavaScript event loop as a raw stream; each line of output triggers a data event as a string.
Command names are scoped identifiers:
The string 'echo' in Command.create('echo', ...) is not the raw OS command. It is a scope name you define in the capability file. The actual binary that runs is configured under cmd in that scope entry. If the scope maps the name 'run-echo' to /bin/echo, your JavaScript must use 'run-echo'.
Streaming output in a React component
A practical example: a text area where the user enters a command, and the output streams into a scrollable log below.
import { useState, useRef } from 'react';
import { Command } from '@tauri-apps/plugin-shell';
function App() {
const [command, setCommand] = useState('echo Hello');
const [output, setOutput] = useState('');
const outputRef = useRef('');
const handleRun = async () => {
setOutput('');
outputRef.current = '';
const [program, ...args] = command.split(' ');
const cmd = Command.create(program, args);
cmd.stdout.on('data', (line) => {
outputRef.current += line;
setOutput(outputRef.current);
});
cmd.stderr.on('data', (line) => {
outputRef.current += `[stderr] ${line}`;
setOutput(outputRef.current);
});
cmd.on('close', (payload) => {
outputRef.current += `\n--- exit code: ${payload.code} ---`;
setOutput(outputRef.current);
});
cmd.on('error', (err) => {
outputRef.current += `\nError: ${err}`;
setOutput(outputRef.current);
});
await cmd.spawn();
};
return (
<div>
<input
type="text"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="e.g., echo Hello"
/>
<button onClick={handleRun}>Run</button>
<pre style={{ whiteSpace: 'pre-wrap', background: '#1e1e1e', color: '#ddd', padding: '1rem' }}>
{output}
</pre>
</div>
);
}
export default App;
The component splits the command string on spaces to separate the program name from arguments. This is fine for a demo, but a real app should handle quoted strings and escaped characters. The <pre> block preserves newlines so the streaming output appears line by line just as it would in a terminal.
Arguments are not parsed by a shell:
Command.create('echo', ['$HOME']) prints the literal string $HOME, not the value of the environment variable. There is no shell expansion. If you need shell features like variables, pipes, or redirection, launch a shell explicitly: Command.create('bash', ['-c', 'echo $HOME']). This has its own security implications — any user-supplied input in a bash -c string is a command injection risk.
Security Configuration
The Shell API can do irreversible things: delete files, send network requests, install software. Tauri v2 locks it down with a two-layer permission system: capabilities declare which shell operations are allowed, and scopes restrict which specific programs and arguments can be used. The dedicated Security Considerations page goes deeper on injection risks.
Capabilities
Every permission a Tauri app needs is granted in a capability file, typically src-tauri/capabilities/default.json. The shell plugin recognizes three permission identifiers:
| Permission | Controls |
|---|---|
shell:allow-open | The open function |
shell:allow-execute | The Command.create function |
shell:allow-spawn | The Command.spawn method |
An app that only needs to open URLs does not need shell:allow-execute or shell:allow-spawn. Each permission is a separate gate.
{
"identifier": "default",
"description": "Default capabilities for the app",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-open"
]
}
This configuration allows open but blocks every form of command execution. The reverse — allowing execute but not open — is equally possible.
Command scopes
Granting shell:allow-execute without a scope means any program name can be passed to Command.create, which is equivalent to giving the frontend a full shell. The scope array restricts this to an explicit list of programs.
A scope entry is an object inside the capability file's shell permission. Each entry maps a name (used in JavaScript) to a real binary path and defines what arguments are allowed.
{
"identifier": "default",
"description": "Default capabilities for the app",
"windows": ["main"],
"permissions": [
"core:default",
"shell:allow-execute",
"shell:allow-spawn",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "run-git-commit",
"cmd": "git",
"args": [
"commit",
"-m",
{ "validator": "\\S+" }
]
}
]
}
]
}
Now the JavaScript side can only create Command.create('run-git-commit', ['commit', '-m', 'fix typo']). Passing 'git' directly will be rejected because 'git' is not a scope name — 'run-git-commit' is. The args array specifies that exactly three arguments are required: the literal string "commit", the literal string "-m", and a non-whitespace string validated by the regex \S+.
Sidecar binaries:
A sidecar is an external binary you bundle with the Tauri app. Its scope entry looks similar but includes "sidecar": true. The name must match the binary filename defined under tauri > bundle > externalBin in tauri.conf.json. The cmd field is ignored for sidecars — the binary name is the command.
Open scope
The open function can also be scoped. By default, granting shell:allow-open permits any URL or path. To restrict it to specific domains or schemes, attach a scope with a regex pattern.
{
"identifier": "default",
"description": "Default capabilities for the app",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "shell:allow-open",
"allow": [
{ "validator": "^https://tauri\\.app/" }
]
}
]
}
With this configuration, open('https://tauri.app/docs') works, but open('https://github.com') is rejected. The regex runs against the entire string passed to open.
Practical permission strategy
A thoughtful capability file is the single most impactful security decision in a Tauri app that uses shell. The principle is to grant the minimum surface area the feature actually needs.
- If the app only opens a specific documentation URL, scope
opento that exact prefix. - If the app runs a CLI tool, create a dedicated scope entry that fixes the argument structure. Never allow
args: trueunless the arguments are fully trusted and non-user-supplied. - Avoid
shell:allow-executewith an empty or[{ "name": "*", "cmd": "*" }]scope outside of development. This gives the frontend the same access as the user running the app.
Unrestricted scope is a remote code execution risk:
If the frontend can run any binary with any arguments, a single cross-site scripting vulnerability in the webview becomes arbitrary code execution on the host machine. The scope array is a security boundary — treat it like a firewall rule.
Common Mistakes and Troubleshooting
Shell API issues often surface as silent failures or console errors. The root cause is almost always a mismatch between the capability file and the JavaScript call.
Using the OS binary name instead of the scope name. Command.create('git', ...) fails when the scope entry is named 'run-git'. The first argument to create must match the name field in the scope, not the cmd field.
Forgetting to add the permission to the capability file. A fresh install of the plugin with no changes to capabilities/default.json will block every shell call. The open call will hang or throw a permission denied error. Check the console output during tauri dev for lines like permission not granted.
Expecting shell syntax to work. Redirections (>), pipes (|), environment variable expansion ($HOME), and glob patterns (*.txt) are features of a shell, not the operating system's process spawn API. They do not work in Command.create unless the command itself is a shell: Command.create('bash', ['-c', 'ls *.txt > out.txt']).
Scope regex too restrictive. The validator regex must match the entire argument string, not a substring. The pattern \\S+ matches one word; .* matches anything; \\d+ matches a number. A regex that does not match the argument the user typed will cause the command to fail before the process even starts.
Diagnosing scope rejections:
Run the app with RUST_LOG=debug in the terminal to see detailed logs from the shell plugin. Rejected commands appear with the reason they were blocked — for example, "scope not found" or "argument validation failed". This is the fastest way to debug a permission issue.
The Shell API is the bridge between a Tauri app's web frontend and the full power of the host operating system. Used with the correct scoping, it opens external resources and runs trusted utilities safely. A capability file that precisely defines what can be opened and executed turns the shell plugin from a broad backdoor into a controlled, auditable surface.
Introduction to the Shell API
Understand what the Tauri Shell API is, why it exists, and how to set it up securely in a React Vite project with Tauri v2.
Opening External Resources
Learn how to open URLs, files, and folders in their default applications from a Tauri v2 application using the Shell plugin.
Security Considerations
Learn how to configure permissions securely and avoid common risks when executing shell commands in Tauri v2 with the Shell plugin