the Zellij plugin ecosystem: the WASM render/event/action lifecycle and the Zellij Message Protocol, writing Rust plugins, loading plugins from the web, managing them via the plugin manager, and combining plugins with pipes.

In episode 14 you used the filepicker and strider — and you may not have realized that you've been interacting with Zellij's plugin system for a long time. The tab-bar on top, the status-bar at the bottom, strider on the side: all of them are plugins. This isn't mere technical detail — it's the reason Zellij can thrive and evolve in an ecosystem full of other terminal tools that are rigid and hard to modify.
Episode 15 goes to the heart of Zellij's architecture. We'll dissect the plugin system: how WASM modules are loaded and run inside the Zellij server, their lifecycle (render, event, action), and the Zellij Message Protocol that connects plugins to the host. After that we'll learn to write and load plugins — from local files to web URLs — then manage them with the Plugin Manager introduced in version 0.41, and combine plugins with pipes for two-way automation.
After this episode, you'll no longer see Zellij as a fixed collection of finished features, but as a framework you can fill however you like. You'll know exactly where to look when a plugin misbehaves, when a plugin can be replaced, and why features like pipes (episode 13) and the filepicker (episode 14) are actually one and the same system.
Why does this matter? Because understanding the plugin system means understanding the limits of Zellij's capabilities — and those limits turn out to be very far away. You can replace the built-in tab-bar and status-bar with your own, write custom widgets for your workflow, or load community plugins from the web without modifying Zellij's code at all. In episode 16 we'll build a real plugin; this episode is its concept map. Let's start with the architecture.
Every Zellij UI element you see — the tab bar, the status bar, the compact bar, the file explorer, the session manager — is a WebAssembly (WASM) module loaded into the Zellij server. This is a bold design decision: instead of hardcoding the UI into the binary, Zellij executes plugins as an isolated sandbox that communicates with the host through messages.
The most interesting consequence of this decision is that the language no longer matters. As long as a language can compile to the wasm32-wasi target, it can become a Zellij plugin. Rust is a first-class citizen because its ecosystem is the most mature, but Go, Zig, C, and other languages also work — this is the meaning of the cross-language UI components carried since version 0.39. Plugins aren't "Rust apps stuck on", but UI components that speak the same protocol, whatever their origin language.
All of Zellij's built-in plugins are registered as aliases in the plugins block of config.kdl:
| Alias | Plugin | Function |
|---|---|---|
zellij:tab-bar | tab-bar | Shows tabs, active pane indicator |
zellij:status-bar | status-bar | Shows mode, keybinding hints, session name |
zellij:strider | strider | File explorer / filepicker |
zellij:compact-bar | compact-bar | Minimal one-line status bar |
zellij:session-manager | session-manager | Manage and create sessions |
zellij:plugin-manager | plugin-manager | Manage running plugins |
Because they're all plugins, you can swap their implementations. You can replace the status-bar alias with a community-built status plugin, or replace the filepicker with a custom file picker implementation — as long as it follows the same contract, Zellij doesn't care who draws the UI. That's a degree of freedom tmux doesn't have.
Security is a consideration that can't be ignored. Each plugin runs in a WASM sandbox that separates plugin memory from host memory, and the permissions a plugin requests determine what capabilities it can use — for example reading application state, writing to stdin, or controlling panes. This isn't about blind trust; it's about giving a plugin only what it needs, nothing more. This permission model is similar to browser permissions, and it's the reason loading third-party plugins is far safer than running arbitrary scripts in a terminal.
Note
Plugins run on the Zellij server, not on the client. That means a plugin stays alive as long as the session runs, even when nobody is attached. The consequence: the resources a plugin uses (memory, CPU for rendering) are charged to the server — so don't load plugins you don't need.
How do plugins and the host talk? Through the Zellij Message Protocol — a set of messages exchanged between the host and the WASM module. The host calls functions the plugin exports to inform it of the state; the plugin replies with actions and rendered text. No shared memory, no pointers — only messages crossing the WASM boundary.
A plugin's lifecycle consists of several phases:
| Phase | When | Plugin's Job |
|---|---|---|
load | Once, at load time | Register subscribed events, request permissions |
update (event) | On every event | Update internal state, decide whether a re-render is needed |
render | Every time the screen is drawn | Draw the UI via text sent to stdout |
pipe | On every pipe message | Receive data from outside (CLI, keybinding, other plugins) |
The flow is a clear one-way street: the state of the Zellij world arrives as events (mode, tab, pane changes, keypresses, mouse, timers), the plugin responds in update by refreshing its state, then render draws the result. When the plugin wants to change the world — say, switch modes, open a pane, or write to stdin — it sends an action back to the host.
A minimal plugin skeleton looks like this (we'll dissect it fully in episode 16):
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
#[derive(Default)]
struct MonitorTile {
mode: String,
}
register_tile!(MonitorTile);
impl ZellijTile for MonitorTile {
fn load(&mut self, _config: BTreeMap<String, String>) {
request_permission(&[PermissionType::ReadApplicationState]);
subscribe(&[EventType::ModeUpdate]);
}
}The two calls inside load are the key: subscribe determines which events the host will send to the plugin, and request_permission determines the capabilities the plugin is allowed to use. Without both, the plugin will only receive basic events and can't access application state.
The render flow deserves a deeper look because it's often misunderstood. When render is called, the plugin draws via text sent to stdout — each printed line becomes a line on the plugin's pane screen. The host manages the screen buffer, colors, and scrollback; the plugin just writes what should appear. This is why a plugin can be written in any language: as long as a language can write text to stdout and receive events from stdin, it can be a Zellij UI. All of this runs on the same WASM interface, which is why Zellij calls this ecosystem cross-language UI components.
Important
subscribe and request_permission are only valid when called inside load. Permission requests made after the plugin is already running will be rejected by the host. Decide the plugin's needs up front, not halfway through.
Writing a plugin starts with a Rust crate that depends on zellij-tile — the library providing all the helper functions: request_permission, subscribe, action sending, and the ZellijTile trait we saw earlier. The compilation result is a single .wasm file, and that file is what Zellij loads. It can be loaded from a local file or from a web URL — two sources exchanged through the same mechanism:
zellij plugin load -f ./target/wasm32-wasi/release/monitor.wasmOnce loaded, the plugin appears as a new pane in the active session. When you update your code and recompile, run the same command again to load the latest version — this build → load → see the result loop is the development cycle we'll use in episode 16.
Since version 0.39, Zellij can also load plugins directly from the web — just give the URL of a .wasm file, like the second tab above. This feature opens the door to an ecosystem: plugins can be shared as links, loaded without manual installation, and updated simply by loading the same URL again. In a KDL layout, plugins from a web URL or a local file are both loaded through a plugin block with a location property:
layout {
pane split_direction="vertical" {
pane
pane size="15%" borderless=true {
plugin location="file:/home/devnull/.config/zellij/plugins/monitor.wasm"
}
}
}Placing a plugin in a layout means the plugin is loaded automatically every time the layout runs — the right pattern for widgets that should always be present, like a custom status bar.
Warning
Loading a plugin from a web URL means executing third-party code inside your session. Although the WASM sandbox limits access according to the permissions requested, still verify the source of the plugins you load — especially ones that request high permissions like ReadApplicationState or the ability to write to stdin. Make a habit of loading from official URLs or files you compile yourself.
As plugins start to multiply, you need a way to see and control them. That's where the Plugin Manager (introduced in version 0.41) comes in. Open it with Ctrl+o then p — in session mode, the Plugin Manager is one of the few plugins that can be triggered directly from the default keybinding.
The Plugin Manager shows all the plugins currently running in the session, complete with their origin: whether it's a built-in plugin (the zellij:... alias), a local file, or a web URL. From here you can:
Combine the Plugin Manager with the alias concept in the plugins block: instead of memorizing long URLs, you can define short aliases and use them in layouts, keybindings, and pipes. You can create aliases for your own custom plugins:
plugins {
tab-bar location="zellij:tab-bar"
status-bar location="zellij:status-bar"
monitor location="file:/home/devnull/.config/zellij/plugins/monitor.wasm"
}Once the monitor alias is registered, every location that accepts a plugin URL also accepts monitor — in layouts, the LaunchPlugin keybinding, and pipes.
Besides loading plugins manually, Zellij supports automatic background loading when a session starts. Through the load_plugins block in the configuration, headless plugins — plugins that don't draw anything but still process events and pipes — can be started at session startup without needing a pane at all. This combination is very useful for widgets that must always run, such as a status monitor waiting for pipe messages from outside (a real example in the next section), or a plugin tasked with capturing pane output for logging.
In episode 13 you met pipes as a bridge between processes and panes. Pipes can also be pointed at plugins — and that's what makes plugins truly programmable from the outside. Through zellij action pipe or its alias zpipe, you send a message to a running plugin (or load it if it isn't running yet):
zellij action pipe --name refresh --plugin file:/home/devnull/.config/zellij/plugins/monitor.wasm -- refresh
echo '{"threshold": 90}' | zellij action pipe --name configure --plugin monitorOn the plugin side, this message is received in the pipe phase as a PipeMessage — containing the pipe name and payload. The plugin can respond by updating its UI, triggering an action, or even sending data back to the caller's stdout. The communication direction becomes two-way: the outside world sends commands to the plugin, the plugin sends results back.
This is a powerful automation blueprint: a pane runs a long process, a pipe captures its output, an external process analyzes it, then sends a decision back to the plugin through an action pipe. Zellij's entire pipeline — terminal panes, pipes, and plugins — works as one programmable system.
A natural extension of this idea: plugins can talk to each other. A plugin that captures a certain keybinding can forward a message to another plugin through a pipe message, forming a processing chain within a single session. Combined with pipes' ability to load a plugin that isn't running yet, you get a micro-architecture inside Zellij: each plugin holds a single responsibility, and pipes become the communication bus connecting them. This is a pattern that will help a lot once you have more than two or three plugins in a single workspace.
Tip
Use zpipe as a shortcut — it's an alias for zellij action pipe. Since a plugin that isn't running yet is automatically loaded when it receives a pipe message, you can use zpipe as a way to "wake it from afar": send one message, and the plugin appears with a configuration already prepared.
subscribe. A plugin that doesn't register events won't receive any information from the host — its UI looks frozen. Always register the events you need in load.load. Permissions requested outside the load phase are rejected. Plan all permission needs from the start.size or borderless. A plugin that occupies a whole pane or carries a border can wreck a layout. Specify the size and borderless true for bar-shaped plugins.This episode gave you a complete map of the Zellij plugin ecosystem. You understand that Zellij's entire UI is WASM modules speaking the Zellij Message Protocol; that plugins have a load → event → render → action lifecycle; that plugins can be loaded from files or from the web; that the Plugin Manager (0.41) is its control center; and that pipes make plugins programmable from the outside.
The points to take away:
load (subscribe + permission), update, render, pipe.Ctrl+o then p) manages them.zellij action pipe / zpipe) open the door to two-way automation.Now it's time to get your hands dirty. In episode 16, we'll build your first Zellij plugin in Rust and WASM: crate structure, the wasm32-wasi toolchain, the ZellijTile trait, rendering UI, and loading it into a session — from zero until it appears on screen. See you in episode 16.