Lumi
Extensions

Writing an extension

Add actions, flow nodes, settings and windows to Lumi with a Rust crate compiled to WebAssembly.

An extension is a small Rust crate, compiled to a WebAssembly component and shipped beside a manifest.toml. It can give Lumi:

  • Actions — commands a person binds to a shortcut, a leader menu step, a double-tap or an Fn combination, like any built-in action.
  • Flow nodes — steps that appear in the Flow Editor's palette and run on the items that reach them.
  • Settings — a form Lumi draws for it under Settings → Extensions.
  • Windows — dialogs of its own, drawn from HTML files in the package.

It runs in Lumi's sandbox and reaches exactly what its manifest declares. That is enforced by Lumi on every call the extension makes, not by review: an extension that never declared network cannot reach the network whatever its code tries.

The API is 0.2

An extension built against version 0.2 of the SDK installs on every Lumi that speaks 0.2. When a later Lumi moves the interface to a new version, it says so at install — rebuild against the new SDK, or update Lumi — rather than loading an extension it cannot run. Coming from 0.1? Point the dependency at 0.2, add on_lifecycle (returning Ok(()) is enough), and rebuild — see Lifecycle.

What you need

  • A Rust toolchain with the WebAssembly component target:

    rustup target add wasm32-wasip2
  • Lumi on the same Mac, to install and run what you build.

A first extension

Make the crate

cargo new --lib hello-lumi
cd hello-lumi

An extension is a library with no main — Lumi calls into it. In Cargo.toml:

[lib]
crate-type = ["cdylib"]

[dependencies]
# Until the crate is on crates.io, it is taken from the store's repository.
# The tag names the interface version, so an update to the SDK is a choice
# you make.
lumi-extension-api = { git = "https://github.com/thiennguyen93/lumi-store", tag = "api-v0.2.0" }
serde_json = "1"

Write the code

src/lib.rs implements one trait and registers it:

use lumi_extension_api as lumi;

struct Hello;

impl lumi::Guest for Hello {
    fn run_command(name: String, _params: String) -> Result<String, String> {
        match name.as_str() {
            "greet" => {
                let found = lumi::selection()?;
                lumi::alert(&format!("You selected {} characters", found.text.chars().count()))?;
                Ok("greeted".to_string())
            }
            other => Err(format!("no {other} command")),
        }
    }

    fn run_node(name: String, _params: String, _items: String) -> Result<String, String> {
        Err(format!("no {name} node"))
    }

    fn run_ui(window: String, _request: String) -> Result<String, String> {
        Err(format!("no {window} window"))
    }

    fn on_lifecycle(_event: lumi::Lifecycle) -> Result<(), String> {
        Ok(())
    }
}

lumi::register!(Hello);

Declare it

manifest.toml beside Cargo.toml says who the extension is, what it contributes, and what it may reach:

[extension]
id = "dev.you.hello"
name = "Hello"
version = "0.1.0"
description = "Counts what you selected."
author = "You"
capabilities = ["accessibility"]

[[command]]
name = "greet"
label = "Count the selection"

lumi::selection() needs accessibility, so the manifest asks for it. Leave it out and the call is refused, by name, the first time it runs — see Capabilities.

Build and package

cargo build --release --target wasm32-wasip2

mkdir -p package
cp manifest.toml package/
cp target/wasm32-wasip2/release/hello_lumi.wasm package/extension.wasm
tar -czf dev.you.hello.tar.gz -C package .

A package is a gzipped tar holding manifest.toml and extension.wasm, and optionally icon.svg and a ui/ directory — see The package.

Install it

In Lumi, open Settings → Extensions and drag the .tar.gz onto the list. Or press Install from file — the package icon at the end of the row above the list, beside Store — and pick it. The review sheet lists what the extension declares; it says not signed because only the store signs packages, which is the truth about a file you built yourself.

Bind Count the selection to a shortcut like any other action, select some text anywhere, and press it.

How a run works

Every call into an extension — a command, a flow node, a request from one of its windows — is one run, and a run is bounded:

BoundLimit
Memory64 MiB, across every memory your component defines
Wall-clock time15 seconds, waiting included — a sleep counts
Calls into Lumi200 per run
Answer size4 MiB
Runs at once4 per extension

The first two stop the run where it stands: a run that asks for more memory, or is still going at fifteen seconds, is ended and the person is told the extension was stopped. Lumi itself carries on — that is the point of the sandbox.

The next two are refusals rather than stops. Past the 200th call, every call into Lumi answers an error instead of doing anything, and with ? that ends your run on the spot. A call refused for a missing capability still counts toward the 200. An answer over 4 MiB is replaced with an error saying how large it was.

The last one is not per run: a fifth run started while four of yours are still going is refused straight away rather than queued, with a sentence saying the extension is busy. A window that fires requests in a loop is the usual way to meet it — send the next one when the last has answered.

A component may also define at most 4 memories, 16 tables and 32 instances. Rust's wasm32-wasip2 toolchain builds one memory, two tables and three instances, so this only matters to a component built some other way.

A flow node's run is ended when the person presses Stop on the flow, not only the steps after it.

A run starts from nothing each time: there is no state carried between runs in memory. What must last goes in your settings, which Lumi stores for you.

Where next

On this page