Tauri Plugin py-runtime
Build Tauri desktop apps with a Python backend.
py-runtime lets you write the backend of a Tauri 2 application in Python while keeping Tauri's small footprint, native webview and security model. Python functions become commands your frontend can call with full type information, streaming, cancellation and structured errors. When you ship, your backend is compiled into a self-contained executable, so end users never need to install Python.
# src-python/main.py
=
return f
// frontend
import { pyInvoke } from "tauri-plugin-py-runtime-api";
const message = await pyInvoke<string>("greet", { name: "Tauri" });
Highlights
- Python-first commands. Plain
defandasync deffunctions become frontend commands. Arguments are validated with pydantic, and camelCase keys map to snake_case automatically. - Real-time communication. Stream progress over channels, exchange events in both directions, and send binary data without base64 overhead.
- Production-grade reliability. Every call supports timeouts and cancellation. The runtime is supervised, can restart after a crash, and shuts down gracefully with bounded deadlines.
- Zero-dependency distribution.
tauri-runtime buildpackages the backend with Nuitka into a standalone executable that ships inside your app bundle. - One-command setup.
tauri-runtime createandtauri-runtime initscaffold a project or add Python to an existing one, including dependencies, configuration and permissions. - Scales with your codebase. Routers with prefixes organize large backends into modules.
- Secure by default. Access goes through Tauri capabilities, and a webview can only cancel its own calls.
How it works
flowchart LR
FE["Frontend<br/>tauri-plugin-py-runtime-api"] -- "invoke · events · channels" --> RS["Rust plugin<br/>tauri-plugin-py-runtime"]
RS -- "framed messages over stdin/stdout" --> PY["Python process<br/>tauri-runtime SDK"]
PY -- "responses · events · stream data" --> RS
RS --> FE
The Rust plugin starts your Python backend as a child process and supervises it. The two talk over stdin/stdout using a compact length-prefixed protocol with JSON headers and optional binary bodies, described in Protocol. The frontend never talks to Python directly: every call goes through Tauri's IPC and permission system.
| Component | Package | Install |
|---|---|---|
| Rust plugin | tauri-plugin-py-runtime (plugin name py-runtime) |
cargo add tauri-plugin-py-runtime |
| Python SDK | tauri-runtime, imported as tauri_runtime |
uv add tauri-runtime |
| Frontend API | tauri-plugin-py-runtime-api |
pnpm add tauri-plugin-py-runtime-api |
Requirements
| Requirement | Version |
|---|---|
| Tauri | 2.x |
| Rust | 1.77.2 or newer |
| Python | 3.11 or newer (the SDK depends on pydantic>=2.9) |
| uv | Recommended. The CLI uses it to manage the backend environment |
| C compiler | Needed only for packaging: Xcode Command Line Tools on macOS, MSVC or MinGW on Windows, gcc on Linux |
Quick start
Create a new app with a Python backend:
To add a Python backend to an existing Tauri project instead, run this in the directory that
contains src-tauri:
During development, pnpm tauri dev runs src-python/main.py with the project's virtual
environment. pnpm tauri build compiles the backend and bundles it into the app.
What the CLI sets up
create runs create-tauri-app and then init. init does the following:
| Area | Change |
|---|---|
| Python | Creates src-python/ with pyproject.toml (depends on tauri-runtime, with Nuitka as a dev dependency), a starter main.py and .gitignore, then runs uv sync |
| Rust | Adds the crate with cargo add and registers the plugin after tauri::Builder::default() in src-tauri/src/lib.rs |
| Frontend | Installs the API package with pnpm, yarn, bun or npm. The package manager is detected from the lockfile or beforeDevCommand |
| Permissions | Adds py-runtime:default to capabilities/default.json |
| Packaging | Configures beforeBuildCommand and bundle.resources in tauri.conf.json, and build.rs, as described in Distribution |
Re-running the CLI is safe: files that are already set up are left alone. If src-python
already exists, init stops; --force continues the setup without overwriting your files. If a
step can't be applied safely, for example an unusual lib.rs, Tauri.toml or list-form
bundle.resources, the CLI prints exact instructions to apply it by hand.
Package sources. By default the CLI installs released packages from crates.io, npm and PyPI. There are two alternatives:
--gitinstalls all three packages from the GitHub repository. With pnpm this requires pnpm 10 or newer; the CLI passes--allow-buildso that pnpm can build the frontend package from git.--path <checkout>uses a local clone of this repository. Build its frontend package first withpnpm install && pnpm build.
Guide
Writing commands
=
...
# receives a Uint8Array / ArrayBuffer from the frontend
...
await
return
# register under a custom name
Execution model
async defcommands run on the event loop. Plaindefcommands run in a thread pool.- Cancelling a call cancels the asyncio task of an
async defcommand. A plaindefcommand keeps running in its worker thread until it returns, because Python can't interrupt threads, and its result is discarded.
Arguments
- Parameters are filled from the JSON arguments, with camelCase keys mapped to snake_case.
- A missing required parameter fails with
invalid_args. - Annotated parameters are validated with pydantic. A validation failure fails with
invalid_args, and the validation errors are sent asdata.
Special parameter types. Annotate a parameter with one of these types to receive the value described instead of a JSON value:
| Annotation | Receives |
|---|---|
Raw |
The raw request body (data.bytes) when the frontend passes a Uint8Array or ArrayBuffer |
Channel[T] |
A stream to the frontend Channel passed under the same name (onProgress → on_progress). Send with await ch.send(value), or ch.send_sync(value) from a plain def command. The channel is valid only until the command returns |
Command |
The call context: id, cmd, window (label of the calling webview) and cancelled |
App |
The app instance |
Return values
- Return any value pydantic can serialize to JSON, including dataclasses and pydantic models.
- Return
Raw(...)orbytesto send binary data. The frontend receives aUint8Array. NaNand±Infinityare not valid JSON and are rejected. Returning them fails the call withinternal; sending them on a channel or withemitraisesValueError.
Errors
- Raise
TauriError(code, message, data)to return a structured error with your own code. - If
dataisn't JSON-serializable, the error is sent withoutdataand a[WARN]is logged. - Any other exception becomes
internalwithdata = {"type": "<ExceptionName>"}. SetTAURI_RUNTIME_DEBUG=1in the Python environment to also include the traceback.
Events and lifecycle hooks
# receives pyEmit("user:login", ...) from the frontend
await
- A listener receives an
Eventwithname,payloadandwindow. await app.emit(name, payload, target=None)sends an event to the frontend.targetis a webview label; without one the event is broadcast to all webviews.- The name
exitedis reserved for the runtime's ownpy://exitedevent, soapp.emit("exited", ...)raisesValueError.
There are three lifecycle hooks:
| Hook | When it runs |
|---|---|
startup |
Before the handshake. An exception aborts the runtime |
ready |
After the Rust side has confirmed the handshake |
shutdown |
When the app exits, within the shutdown timeout |
print is safe to use: stdout is reserved for the protocol, so sys.stdout is redirected to
stderr, and stderr is forwarded to the app log (see Logging).
Organizing larger projects
Every decorator has an imperative counterpart: app.add_command(func, name=None),
app.add_commands(*funcs), app.add_listener(event, func) and app.add_hook(hook, func).
To split a backend into modules without importing the App instance everywhere, group
registrations in a Router and mount it once in the entry file:
# users.py
=
# invoked as pyInvoke("user.login", { name })
return f
...
# "user.logout"
# event names are not prefixed
...
# main.py
=
- The prefix is joined with
..router.include(other)nests routers, which gives names such as"user.session.login". - A
Routersupports the samecommand,listenandondecorators and the sameadd_*methods asApp. app.include(...)checks handler signatures and duplicate names when it mounts the router, and it takes a snapshot: anything added to the router afterwards is ignored.
Calling Python from the frontend
import { Channel, pyEmit, pyInvoke, pyListen, pyStatus, PyRuntimeError } from "tauri-plugin-py-runtime-api";
const results = await pyInvoke<string[]>("search", { query: "tauri" }, { timeoutMs: 5000 });
const onProgress = new Channel<number>();
onProgress.onmessage = (n) => console.log(n);
await pyInvoke("export", { rows: 100, onProgress });
const unlisten = await pyListen("welcome", (e) => console.log(e.payload));
await pyEmit("user:login", { name: "Kylin" });
console.log(await pyStatus()); // "starting" | "running" | "crashed" | "stopped" | "failed"
pyInvoke(cmd, args?, options?)
Arguments
argsis either a plain object, which is sent as JSON, or aUint8Array/ArrayBuffer, which is sent as raw bytes and received by aRawparameter.Channelvalues are detected only at the top level ofargs. A channel nested inside an object or array is serialized as a plain value and is not connected to Python.- Binary results come back as a
Uint8Array.
Timeouts
options.timeoutMsoverrides the default invoke timeout for this call. It must be a non-negative integer; otherwisepyInvokethrowsinvalid_args.- The value is capped at 10× the configured
invoke_timeout, which is 5 minutes with the default of 30 seconds. - On timeout the call rejects with
timeout, and Python receives a cancel for it.
Cancellation
options.signaltakes anAbortSignal. On abort the promise rejects withcancelledimmediately, without waiting for the backend, and a cancel is forwarded to Python.- An already-aborted signal rejects before anything is sent.
Call ids. Each call carries a client call id that is scoped to the calling window:
- A webview can cancel only its own calls.
- A second call that reuses an id still pending in the same window is rejected with
invalid_args. pyInvokegenerates unique ids for you.
Channels
- Values sent with
ch.send(...)arrive inonmessagein order. - Raw bytes (
Raw/bytes) arrive as anArrayBuffer, not aUint8Array, so declare the channel asChannel<ArrayBuffer>. - Channel messages and the call result travel separately, so the last
onmessagecan run afterpyInvokehas resolved. If you need strict ordering, send an explicit end marker on the channel (for example{ done: true }) and wait for it instead of relying on the promise.
Events
pyListen(event, handler)listens topy://<event>. Python'sapp.emit("x", ...)arrives aspy://x.pyListenreturns an unlisten function.pyListen("exited", ...)(py://exited) fires whenever the Python process exits unexpectedly. Its payload is{ code: number | null }.pyEmit(event, payload)sends an event to Python's@app.listen(event)handlers. The PythonEvent.windowis the label of the sending webview. If Python isn't running,pyEmitfails withruntime_unavailable.- Event payloads are JSON. If Python emits raw bytes as an event, the frontend receives them as
an array of numbers; use a channel or a
pyInvokeresult for binary data.
Error handling
Every failure rejects with a PyRuntimeError (name, code, message, optional data). It
mirrors the { code, message, data? } error shape used across Rust and Python:
try {
await pyInvoke("files.remove", { path: "" });
} catch (e) {
if (e instanceof PyRuntimeError) console.log(e.code, e.message, e.data);
}
See Error codes for the full list.
Configuring the Rust plugin
default
.plugin
| Method | Default | Description |
|---|---|---|
script(path) |
Required unless a bundle is used | Python entry script, see Script path resolution |
python(program) |
<script dir>/.venv if present, otherwise python3 (python on Windows) |
Interpreter to run |
bundle(name) |
None | In release builds, run the Nuitka-packaged backend instead, see Distribution |
args(iter) |
None | Extra arguments passed after the script path, or to the packaged executable |
env(key, value) |
None | Extra environment variable for the Python process. Call repeatedly to set several |
invoke_timeout(Duration) |
30 s | Default timeout per call. A per-call timeoutMs is capped at 10× this value |
shutdown_timeout(Duration) |
3 s | Time Python gets to run its shutdown hooks |
handshake_timeout(Duration) |
10 s | Time Python gets to start and send its handshake. Calls made while the runtime is starting wait this long too |
restart_on_crash(bool) |
false |
Restart the process after a crash, see Process lifecycle |
The Python process always gets PYTHONUNBUFFERED=1 and PYTHONIOENCODING=utf-8.
From Rust code you can reach the runtime with app.py_runtime().supervisor() (via
PyRuntimeExt), for example to call commands or read the status.
Configuration file
The same settings can be set in plugins.py-runtime in tauri.conf.json, except env and
handshake_timeout, which are available only on the Builder. Keys are camelCase, and unknown
keys are rejected so that a typo fails at startup instead of being silently ignored:
Values set on the Builder take precedence over the configuration file. To configure the plugin
only through the file, register it with tauri_plugin_py_runtime::init().
Permissions
Add "py-runtime:default" to your capability. It allows invoke, cancel, emit and
status; the individual permissions are named py-runtime:allow-<name>.
Script path resolution
- An absolute path is used as is.
- A relative path is tried against the following locations, and the first existing file wins:
- Debug builds only: the process working directory. Under
tauri devthat issrc-tauri, so"../src-python/main.py"points at<project>/src-python/main.py. - The app's resource directory, with every
..component replaced by_up_. This is the same layout Tauri's bundler uses forbundle.resources.
- Debug builds only: the process working directory. Under
- Release builds look only in the resource directory, so a script that runs in release builds
has to be shipped via
bundle.resources, in one of two forms:- List form:
"resources": ["../src-python/**/*"]together withscript: "../src-python/main.py". - Map form:
"resources": { "../src-python/": "src-python/" }together withscript: "src-python/main.py".
- List form:
- If nothing is found, plugin setup fails with an error that lists every path it tried.
- Python runs with the script's directory as its working directory.
In script mode the interpreter must be able to import tauri-runtime (and therefore
pydantic). Without an explicit python, the plugin uses the virtual environment next to the
script (.venv/bin/python, or .venv\Scripts\python.exe on Windows), so running uv sync in
src-python is all tauri dev needs.
Distribution
tauri-runtime build compiles the Python backend with Nuitka in
standalone mode. The result is a folder containing an executable, the Python runtime and all
dependencies, so your users don't need Python installed. Debug builds keep running the script
for fast iteration; release builds with bundle set run the packaged executable.
Projects created with tauri-runtime create or init are already configured. To set up
packaging by hand:
-
Add Nuitka to the Python project:
&& -
Configure the build in
src-python/pyproject.toml. This step is optional; the values below are the defaults:[] = "main.py" # entry script = "py-backend" # executable and folder name = "dist" # output directory = [] # extra Nuitka arguments, e.g. ["--include-data-dir=assets=assets"]uv run tauri-runtime buildproducesdist/py-backend/py-backend(py-backend.exeon Windows). Arguments after--are passed to Nuitka. The build runs in a temporary directory and replaces the previous output only if it succeeds. -
Build before the app and bundle the output as a resource in
tauri.conf.json.beforeBuildCommandruns in the directory that containssrc-tauri:tauri-build checks that resource paths exist even for
tauri dev, so create the folder insrc-tauri/build.rsbefore the backend is first packaged: -
Point the plugin at the bundle:
new .script // debug builds .bundle // release builds: <resources>/py-backend/py-backend .build
Notes
- The switch between script and bundle follows
debug_assertions, sotauri build --debugstill runs the script. - In release builds,
pythonandscriptare ignored whenbundleis set. If the executable isn't found, plugin setup fails with the path it looked for. - Nuitka follows imports. Modules that are imported dynamically, and data files, need extra
nuitka-argssuch as--include-module,--include-package-dataor--include-data-dir. - The first build takes a few minutes. Nuitka caches compiled C files (ccache), so later builds are faster.
Reference
Error codes
| Code | Meaning |
|---|---|
command_not_found |
No Python command with that name |
invalid_args |
Bad arguments: a missing parameter, pydantic validation failure or malformed request |
internal |
Unhandled Python exception or internal error |
cancelled |
The call was cancelled |
timeout |
The call exceeded its timeout |
runtime_unavailable |
Python isn't running: it failed to start, its handshake failed or timed out, or it was stopped |
runtime_exited |
Python exited while the call was in flight |
protocol_mismatch |
The Python SDK speaks a different protocol version |
frame_too_large |
A message exceeded the 64 MiB frame limit |
Codes raised with TauriError in Python are passed through unchanged.
Process lifecycle
Startup
- The Python process starts during plugin setup.
- Python sends a handshake that lists its commands. Rust checks the protocol version and
replies; then
readyhooks run and the status becomesrunning. - Calls made while the runtime is starting wait for the handshake.
Crashes and restarts
- If Python exits unexpectedly, in-flight calls fail with
runtime_exited,py://exitedis emitted and the status becomescrashed. - With
restart_on_crash/restartOnCrashenabled (it is off by default), the process is restarted with exponential backoff: 1 s, 2 s, 4 s and so on, capped at 30 s. - The backoff resets once a process has stayed up for at least 30 s.
- If a restarted process fails its handshake, restarting stops and the status becomes
failed.
Shutdown
- On app exit, Rust sends a
shutdownmessage withtimeoutMs, set to the shutdown timeout. - Python cancels running calls and runs
shutdownhooks, all within that one time budget. An invalidtimeoutMs, or a closed stdin, falls back to 3 s. Python then exits. - Handlers that ignore cancellation, and stuck
defhandlers, are abandoned rather than awaited. - If the process is still alive after the shutdown timeout plus 1 s of grace, it is killed.
app.run() always ends the process. If an exception escapes, including sys.exit() or
KeyboardInterrupt raised from a handler, it flushes output and exits with the sys.exit code,
or with 1 for any other exception.
Logging
Python's stderr, including print output (which is redirected there), is forwarded line by line
to the Rust log crate under target py-runtime. A leading [DEBUG], [INFO], [WARN] or
[ERROR] selects the level and is stripped; other lines are logged at INFO:
# -> WARN py-runtime: disk almost full
Install a logger such as tauri-plugin-log to see the output.
Protocol
Rust and Python exchange frames over the child process's stdin/stdout. The current protocol version is 2.
Frame layout
- Every frame is
[u32 BE length][u32 BE header length][header JSON][body]. - The outer length counts everything after itself and is capped at 64 MiB.
- The header is one JSON object tagged by
type:
type |
Direction | Fields |
|---|---|---|
hello |
Python → Rust | protocol, sdk, commands |
ready |
Rust → Python | protocol, app: { name, version } |
shutdown |
Rust → Python | timeoutMs |
request |
Rust → Python | id, cmd, channels? (argument name → channel id), window?, payload |
response |
Python → Rust | id, payload |
error |
Python → Rust | id, code, message, data? |
cancel |
Rust → Python | id, reason ("client" or "timeout") |
event |
Both | event, target? (Python → Rust), window? (Rust → Python), payload |
channel_data |
Python → Rust | channel, payload |
channel_close |
Python → Rust | channel |
Payloads
- A payload is either a JSON value inline in the header as
value(an absentvaluemeans none, ornull), or binary data in the frame body with"raw": true. - The body must be empty unless
rawis set. NaNand±Infinityare never sent.
Error handling
- Unknown header fields are ignored.
- A frame whose header can't be decoded is logged and skipped. That covers invalid JSON, an
unknown
type, and a missing or mistyped field. - An oversized frame ends the connection.
- If the first frame from Python isn't a decodable
hellowith the same protocol version, startup fails withprotocol_mismatch.
Implementation. The message types are a serde enum on the Rust side
(bridge::protocol::Message) and pydantic models on the Python side (tauri_runtime.protocol).
python-sdk/tests/fixtures/golden_frames.json holds one encoded frame per message type, and
both test suites encode and decode them byte for byte.
Platform support
| Platform | Development (tauri dev) |
Packaged backend |
|---|---|---|
| macOS (Apple silicon) | Supported | Supported and verified |
| Windows | Supported | Same layout (.exe suffix), not yet verified |
| Linux | Supported | Same layout, not yet verified |
Code signing and notarization of the packaged backend are not yet handled.
Example
examples/vue is a complete Vue application that demonstrates commands, raw bytes,
concurrency, errors, timeouts, cancellation, channels and events. Its backend in
examples/vue/src-python is a standalone uv project that depends on the in-repo SDK in editable
mode.
&& && &&
Development
&& && &&
Roadmap
| Stage | Scope | Status |
|---|---|---|
| 1. Runtime core | Process supervision, protocol, commands, events, channels, Python SDK and frontend API | Available |
| 2. Build toolchain | Standalone packaging with Nuitka and project scaffolding | Available; signing and cross-platform verification pending |
| 3. Desktop native APIs | Native desktop capabilities exposed to Python | Planned |
| 4. System extensions | Deeper operating system integration | Planned |
| 5. Hardware access | Access to devices and peripherals | Planned |