Learn Zellij - Building Your First Plugin (Rust + WASM)
Series/Learn Zellij/Episode 16
Episode 16 of 29

Learn Zellij - Building Your First Plugin (Rust + WASM)

building your first Zellij plugin with Rust and WASM: crate structure, the zellij-tile dependency, the wasm32-wasi target, the ZellijTile trait for rendering and handling events, sending actions, plus a real status widget.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

In episode 15 you learned the concept map of the Zellij plugin system: the WASM architecture, the Zellij Message Protocol, the load → event → render → action lifecycle, and the Plugin Manager. Now it's time to pay that theory off. Episode 16 is the most practical episode in the Learn Zellij series: you will build your first Zellij plugin with Rust and WebAssembly, from zero until it actually runs inside a session.

Our target is simple but real: a status widget that displays the active Zellij mode, the currently focused tab, and a keypress counter — placed as a small bar at the bottom of the workspace. It's small, but it covers every core concept of plugin development: crate structure, the zellij-tile dependency, the wasm32-wasi compilation target, implementing the ZellijTile trait, handling events, rendering UI, and sending actions back to the host. All you need is three things: an installed Rust toolchain, a working Zellij 0.44.x (episode 0), and patience to read your first compilation errors.

Why does this matter? Because plugins are the path to a Zellij that's truly yours. The built-in tab-bar and status-bar are only starting points — once you can build your own widgets, you can add anything to your workspace: a git branch indicator, build metrics, notifications from external processes, even custom controls that don't exist in stock Zellij. At the same time, this process trains the mental model of Zellij's architecture we've been building over fifteen episodes. Let's start with the project structure.

Crate Structure & the wasm32-wasi Toolchain

Create a new crate as a library — a Zellij plugin isn't a binary, but a module called by the host:

Creating the plugin project
cargo new --lib status-widget
cd status-widget

Then fill in Cargo.toml with the dependency and the right output configuration:

Cargo.toml
[package]
name = "status-widget"
version = "0.1.0"
edition = "2021"
 
[dependencies]
zellij-tile = "0.44"
 
[lib]
crate-type = ["cdylib", "rlib"]

Two parts deserve attention. zellij-tile is the official library providing the trait, prelude, and all helper functions — its major version tracks Zellij's. crate-type = ["cdylib"] makes the compilation produce a module with exported symbols the WASM host can call, not just an internal library.

Next, add the WASM compilation target to the Rust toolchain:

Adding the wasm32-wasi target
rustup target add wasm32-wasi
rustup show active-toolchain

To avoid naming the target every time you call cargo build, create a cargo configuration file that locks the target by default:

.cargo/config.toml
[build]
target = "wasm32-wasi"

With this configuration, every cargo build and cargo test automatically targets wasm32-wasi. It's a decision that frees you from typos and makes building a plugin as ordinary as any other build.

There's one convention worth holding onto from the start: the build output path. With the target locked in the config, plugin artifacts always live in target/wasm32-wasi/release/, and the file name follows the crate name with underscores — status-widget produces status_widget.wasm. You'll use this stable path over and over to load plugins, so understand its location once now and save time later. If you're working on a machine with limited RAM, build without --release for faster iteration; use --release once the plugin is stable.

Note

wasm32-wasi is the WASM target that provides access to stdin, stdout, and the host environment — exactly what a plugin needs to talk to Zellij through the Zellij Message Protocol. Don't confuse it with wasm32-unknown-unknown, which is more limited and has no access to standard stdin/stdout.

The ZellijTile Trait: The Plugin Lifecycle

All plugin logic lives inside an implementation of the ZellijTile trait. This trait defines the contract between plugin and host: the host calls certain methods at lifecycle points, and the plugin fills in its behavior. Three methods form the backbone of a plugin:

MethodCalled WhenJob
loadThe plugin is loadedPrepare state, subscribe to events, request permissions
handle_eventThere's an event from the hostUpdate state based on the event
renderThe screen is about to be drawnDraw the UI via text to stdout

The plugin structure is always the same: a struct with #[derive(Default)] to hold state, wired into the trait via impl ZellijTile, then registered with the register_tile! macro. That macro is what exports the load, update, and render functions the WASM host calls — without it, the host can't find the entry point into your plugin.

The practical rule: load once, handle_event many times, render every time the state changes. You don't need to redraw manually — just update the state in handle_event, and Zellij will call render when it's time to draw. That cause-and-effect relationship is the core of the plugin mental model.

It's important to understand that handle_event and render aren't one big self-running loop. The host calls both asynchronously according to events: a keypress triggers handle_event, a tab change triggers another handle_event, and Zellij decides when the screen needs redrawing. Your plugin just stores state and waits for the calls — this mental model keeps you from writing loop logic that would slow rendering and make your widget unresponsive.

Building the Status Widget

Now let's implement everything. Write the following code to src/lib.rs:

src/lib.rs — the complete status widget
use std::collections::BTreeMap;
use zellij_tile::prelude::*;
 
#[derive(Default)]
struct StatusWidget {
    mode: String,
    tab: String,
    counter: u32,
}
 
register_tile!(StatusWidget);
 
impl ZellijTile for StatusWidget {
    fn load(&mut self, _config: BTreeMap<String, String>) {
        request_permission(&[PermissionType::ReadApplicationState]);
        subscribe(&[EventType::ModeUpdate, EventType::TabUpdate, EventType::Key]);
    }
 
    fn handle_event(&mut self, event: Event) {
        match event {
            Event::ModeUpdate(mode_info) => {
                self.mode = format!("{:?}", mode_info.mode);
            }
            Event::TabUpdate(tabs) => {
                if let Some(active) = tabs.iter().find(|tab| tab.active) {
                    self.tab = active.name.clone();
                }
            }
            Event::Key(key) => {
                if key == Key::Char('c') {
                    self.counter += 1;
                }
            }
            _ => {}
        }
    }
 
    fn render(&mut self, _rows: usize, cols: usize) {
        let line = format!(
            "{} | tab: {} | tombol c ditekan: {} kali",
            self.mode, self.tab, self.counter
        );
        println!("{:width$}{}", "", line, width = 0);
    }
}

Let's break down what's happening. In load, we request the ReadApplicationState permission — without it, ModeUpdate and TabUpdate events would never be sent to the plugin. We also subscribe to three event types: mode changes, tab changes, and keypresses. This determines "which world" our widget is allowed to see.

handle_event is the center of the logic. Every event arrives through the Event parameter, and we match it with match. ModeUpdate carries mode_info, which contains Zellij's active mode; TabUpdate carries the list of tabs and we look for the active one; Key carries a keypress — here we count how many times the c key was pressed. All results are stored in the state struct.

render is the stage. Zellij calls it every time the plugin needs to be redrawn, with the pane dimensions (rows and cols). We compose a single line of text and write it with println!. Notice that println! sends to the plugin's stdout, and Zellij renders that stdout as the pane screen — this is the same rendering mechanism for all plugins, including strider and the status-bar.

This code is a complete plugin, not a fragment. When you load it, Zellij calls load once, then render to draw the first line; every time you switch modes, change tabs, or press c, handle_event updates the state and render redraws. Observe its reactions one by one — that's the best way to build an intuition for when each method is called, before you move on to more complex plugins like the ones we'll see in the rest of the series.

Important

Every println! redraws the entire plugin pane — it doesn't append lines. Don't print an ever-growing list of lines inside render; compose the output to fit the pane height (the rows argument), or your widget will look like it's "stacking". Always think of render as a full repaint.

Sending Actions: Responding to Keypresses

So far the plugin only displays information. Now let's make the plugin react — sending actions back to the host. Actions are how a plugin changes the Zellij world: switching modes, writing to a pane's stdin, opening a new pane. The action functions are already available in the zellij_tile prelude.

Let's extend handle_event to respond to keypresses with actions:

Sending actions from a plugin
fn handle_event(&mut self, event: Event) {
    match event {
        Event::Key(key) => match key {
            Key::Char('t') => {
                switch_to_mode(InputMode::Tab);
            }
            Key::Char('r') => {
                write_to_stdin("clear\n".to_owned());
            }
            Key::Char('n') => {
                open_command_pane(CommandToRun {
                    path: "htop".to_owned(),
                    args: vec![],
                    cwd: None,
                });
            }
            _ => {}
        },
        _ => {}
    }
}

These three actions represent three categories you'll use often. switch_to_mode(InputMode::Tab) sends the action to switch to tab mode — exactly like pressing Ctrl+t. write_to_stdin writes text to the active pane's stdin, like typing in the shell. open_command_pane opens a new pane running a given command — here htop. All three prove that a plugin can be real control, not just display.

Note that some actions require additional permissions. write_to_stdin and open_command_pane, for instance, aren't covered by ReadApplicationState — they write and control, not read. If your actions silently fail, double-check the request_permission list in load: Zellij rejects actions that exceed the granted permissions, and the failure is often quiet. The habit of checking permissions whenever a plugin acts "odd" will save you many times.

Tip

Combine keypresses with state to create your own modes inside a plugin. For example, store a boolean "arm" — if a is pressed once, the next n opens a pane; if not, n only counts. With this little trick, a single plugin can hold many behaviors without colliding with other Zellij keybindings.

Compiling, Loading & Iterating

Time to see the result of your work. Make sure the state in src/lib.rs is complete with load, handle_event, render, and register_tile!, then build. For fast iteration during development, use a debug build; for a plugin you'll keep permanently, use release:

cargo build
Building the plugin
cargo build --target wasm32-wasi --release
ls -lh target/wasm32-wasi/release/status_widget.wasm

That status_widget.wasm file is your plugin. Now load it into a running Zellij session:

Loading the plugin into a session
zellij plugin load -f ./target/wasm32-wasi/release/status_widget.wasm

A new pane appears with your status widget — the Zellij mode, the active tab, and the c key counter. Change modes with Ctrl+t or press c repeatedly, and the widget reacts instantly. This is the plugin development cycle: change code, cargo build, zellij plugin load -f again, see the result. No need to restart Zellij.

When the plugin doesn't behave as expected, the two simplest debugging tools are in your hands. First, eprintln! inside handle_event and render — Zellij writes the plugin's stderr output to its own log. Second, check that log in Zellij's cache directory (~/.cache/zellij/) or run Zellij in debug mode to see more detailed messages. Adding a single eprintln! on the first line of handle_event to print incoming events often immediately reveals a wrong assumption.

For permanent use, load the plugin through a layout so the widget always appears:

layouts/dev.kdl — a plugin in a layout
layout {
    pane split_direction="vertical" {
        pane
        pane size="1" borderless=true {
            plugin location="file:/home/devnull/projects/status-widget/target/wasm32-wasi/release/status_widget.wasm"
        }
    }
}

Now let's add one experiment that makes the widget feel alive: a timer. With set_timeout and the Timer event, a plugin can refresh itself periodically without any user input — the foundation for clocks, build metrics, or any monitor:

Adding a periodic timer
fn load(&mut self, _config: BTreeMap<String, String>) {
    request_permission(&[PermissionType::ReadApplicationState]);
    subscribe(&[EventType::Timer, EventType::ModeUpdate]);
    set_timeout(1.0);
}
 
fn handle_event(&mut self, event: Event) {
    match event {
        Event::Timer(_) => {
            self.counter += 1;
            set_timeout(1.0);
        }
        _ => {}
    }
}

Note that set_timeout is called again inside the Timer handler. Timers in Zellij are one-shot, so you must reschedule every time you receive the event — this pattern keeps the plugin continuously refreshed without wasting resources.

Warning

Don't schedule endlessly recurring timers that are too tight. Every Timer triggers handle_event and a potential render — and every render repaints the pane. For a widget that only displays text, a 1-second interval is more than enough; millisecond timers will only burn CPU.

Common Pitfalls

  1. The wasm32-wasi target isn't installed. cargo build fails with a target-not-found message. Fix: run rustup target add wasm32-wasi before you start, and lock the target in .cargo/config.toml.
  2. Forgetting crate-type = ["cdylib"]. The plugin fails to load because there are no exported functions the host can call. Fix: make sure [lib] crate-type = ["cdylib", "rlib"] exists in Cargo.toml.
  3. Forgetting register_tile!. Your Rust code is correct, but Zellij can't find the plugin's entry point. This macro is what exports load, update, and render — without it, the plugin never runs.
  4. Printing without composing the screen. Using println! repeatedly without respecting rows and cols makes the render stack up and look broken. Treat every render as a full repaint from a blank screen.
  5. Keypresses never arrive. The plugin doesn't receive Event::Key because it didn't subscribe to EventType::Key. Check the subscribe list in load — events that aren't registered are never sent.

Closing

Congratulations — you just built your first Zellij plugin and watched it run inside a session. From merely using the filepicker in episode 14, you're now on the other side: the maker. You've gone through the entire plugin development cycle — crate structure, the wasm32-wasi toolchain, the ZellijTile trait, handling events, rendering UI, sending actions, timers — and ended up with a status widget that's truly yours.

The points to take away:

  • A plugin is a Rust crate with zellij-tile as a dependency and crate-type = ["cdylib"].
  • Required toolchain: rustup target add wasm32-wasi and the target locked in .cargo/config.toml.
  • The ZellijTile trait has three core methods: load, handle_event, render.
  • A plugin draws via println! and changes the world via actions like switch_to_mode, write_to_stdin, open_command_pane.
  • Build with cargo build --target wasm32-wasi --release, load with zellij plugin load -f — repeat for fast iteration.
  • set_timeout + the Timer event makes a plugin live periodically.

You now have the entire foundation for building a workspace that's completely your own. In episode 17, we step into Phase 5: session persistence & resurrection — saving and restoring Zellij sessions (layout, tabs, panes, even running commands) automatically, plus managing many sessions with the session-manager. See you in episode 17.

Learn Zellij - Building Your First Plugin (Rust + WASM) | Learn Zellij