Plugins
Zenkai can be extended with Lua 5.4 scripts. Drop a directory containing a manifest.json and a main.lua into one of the plugin directories and Zenkai picks it up automatically at startup.
Directory Structure
A plugin is a directory with at least two files:
external/plugins/my-plugin/
manifest.json
main.lua
Zenkai searches the following locations (in order):
| Path | Scope |
|---|---|
$HOME/.local/share/zenkai/plugins/<name>/ | User |
$HOME/.config/zenkai/plugins/<name>/ | User |
external/plugins/<name>/ | Project / portable |
/usr/local/share/zenkai/plugins/<name>/ | System |
/usr/share/zenkai/plugins/<name>/ | System |
The first match wins -- if two plugins have the same name, the second is skipped.
manifest.json
{
"name": "my-plugin",
"version": "1.0.0",
"main": "main.lua",
"description": "What it does",
"author": "you",
"disabled": false
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Unique plugin identifier. Must not be empty. |
main | string | yes | Entry point script (relative to plugin dir). Must not be empty. |
version | string | no | Semantic version string. |
description | string | no | One-line description shown in logs. |
author | string | no | Author name / handle. |
disabled | bool | no | Set true to skip loading without deleting the directory. |
If name or main is empty, or disabled is true, the plugin is skipped.
Hooks
Hooks are Lua functions defined as globals in main.lua. Zenkai scans for them after loading and only calls the ones that exist.
on_query(query)
Called on every keystroke with the current search text.
function on_query(query)
if query == "ping" then
api.add_result("Pong!", "it works", "face-smile", "NoReturn")
end
end
query-- raw input text (string)- Call
api.add_result()one or more times to populate results - Return value is ignored
on_open(id)
Called when the user selects a result whose result_type is "ExecCmd".
function on_open(id)
-- id matches the integer returned by api.add_result
end
id-- the numeric identifier returned byapi.add_result()- Only fires for results with the default type (
"ExecCmd") - Not called for
"NoReturn"results
on_close()
Called when the launcher window is closed (by pressing Escape, selecting a command result, or clicking away).
function on_close()
api.log("plugin closed")
end
on_startup()
Called once after the plugin is loaded and all hooks are registered. Use it for one-time setup.
function on_startup()
api.log("plugin ready")
end
on_shutdown()
Called during graceful shutdown. Use it to save state or clean up resources.
function on_shutdown()
-- save state, close files, etc.
end
on_keypress(key)
Called on every keypress. Receives the key name as a string.
function on_keypress(key)
if key == "F1" then
api.log("F1 pressed")
end
end
key-- key name string (e.g."F1","Return","Escape","Tab")
on_idle()
Called periodically when the user is not actively typing (debounce / idle loop).
function on_idle()
-- refresh data, check for changes, etc.
end
on_results(results)
Called with the full results table just before it is displayed. Allows filtering, reordering, or annotating results.
function on_results(results)
for i, r in ipairs(results) do
-- r.title, r.subtitle, r.icon, r.result_type
end
end
results-- array-like table of result tables, each withtitle,subtitle,icon, andresult_typefields
API
The api global provides the interface between Lua and Zenkai.
api.add_result(title, subtitle, icon, result_type)
Add a result row to the search list.
local id = api.add_result("Hello", "world", "face-smile", "NoReturn")
| Param | Type | Description |
|---|---|---|
title | string | Primary text (displayed in bold). |
subtitle | string | Secondary text (displayed below title). |
icon | string | Icon name from the system icon theme. Can be empty. |
result_type | string (optional) | "ExecCmd" (default) or "NoReturn". |
Returns an integer id that is passed to on_open(id) when the user selects this result.
Notes:
- Strings are copied internally -- no need to keep references
- If memory allocation fails, the result is silently dropped and
add_resultreturns0 - The
idcounter increments globally across all plugins
api.open_url(url)
Schedule a URL or command to open when the current selection is handled. The URL is dispatched after on_open returns.
api.open_url("https://example.com")
url-- URL string (max 1023 characters, silently truncated beyond that)- If
--clipboardwas passed, the URL is piped to the clipboard command's stdin instead - Otherwise, the URL is opened with the configured URL handler (defaults to
xdg-open) - Returns nothing
api.log(message)
Write a message to the Zenkai debug log.
api.log("hello from my plugin")
message-- string- Output appears when running with
--debugor--verbose - Returns nothing
print(...)
Zenkai overrides Lua's built-in print to route output through the internal logger. Multiple arguments are tab-separated.
print("value is", 42)
-- logs: lua print: value is 42
Result Types
| Type | Behavior |
|---|---|
"ExecCmd" (default) | Closes the launcher, fires on_open(id), then dispatches any pending URL |
"NoReturn" | Keeps the launcher open, does NOT fire on_open. Useful for inline calculators, previews, palettes |
Sandbox
Zenkai runs all plugins in a restricted Lua environment.
Removed Globals
The following standard Lua globals are set to nil:
| Global | Reason |
|---|---|
os | OS-level access (env, execute, exit, etc.) |
io | File system read/write |
loadfile | Load Lua code from arbitrary files |
dofile | Execute Lua code from arbitrary files |
require | Module loading |
package | Module searchers / path configuration |
debug | Introspection and debug hooks |
Allowed globals include load (string-based chunk loading), pcall / xpcall, string, table, math, coroutine, utf8, and all other standard Lua libraries.
Instruction Limit
Every plugin call (hook invocation) is limited to 50,000 Lua instructions. If a plugin exceeds this limit, it is immediately terminated with a "plugin exceeded instruction limit" error. This prevents runaway loops from freezing the launcher.
Loading Plugins
# Auto-load all plugins in standard directories
zenkai
# Load only one specific plugin
zenkai --plugin=calculator
# Load multiple specific plugins
zenkai --plugin=calculator --plugin=notes
# Disable all plugins
zenkai --no-plugins
# Plugin-only mode (no desktop apps)
zenkai --no-dapps --plugin=calculator
Calculator Example
The built-in calculator plugin at external/plugins/calculator/ evaluates math expressions inline.
manifest.json:
{
"name": "calculator",
"version": "1.0.0",
"main": "main.lua",
"description": "Evaluate math expressions inline",
"author": "built-in",
"disabled": false
}
main.lua with annotations:
-- Strip everything except math operators and digits
function eval_math(expr)
local safe = expr:gsub("[^%d%+%-%*%/%%%^%_%s%.%(%)%%]", "")
if safe == "" or #safe < 2 then
return nil
end
-- Use load() on a string to evaluate (loadfile is removed, load is not)
local fn, err = load("return (" .. safe .. ")")
if not fn then
return nil
end
local ok, result = pcall(fn)
-- NaN check: result == result is false for NaN
if ok and type(result) == "number" and result == result then
return result
end
return nil
end
function on_query(query)
if query == "" or #query < 2 then
return
end
local expr = query:gsub("%s+", "")
local result = eval_math(expr)
if result then
local display = tostring(result)
local rounded = tonumber(string.format("%.10g", result))
if rounded then
display = tostring(rounded)
end
-- NoReturn keeps the launcher open so the user can keep typing
api.add_result(display, expr .. " =", "accessories-calculator", "NoReturn")
end
end
-- on_open is required to exist but does nothing for NoReturn results
function on_open(id)
end
Key points:
load("return (...)" )is safe becauseloadfile,dofile,require,packageare all removed- The sandbox still allows
pcallandstring,math, etc. "NoReturn"means the launcher stays open andon_openis never called- The icon
"accessories-calculator"comes from the system icon theme
Development Tips
- Check the logs -- run
zenkai --debugto seeapi.log()andprint()output, plus any Lua errors - Use
"NoReturn"for live previews -- keeps the launcher open so users can keep typing - Keep
on_queryfast -- it runs on every keystroke; defer heavy work toon_idleor cache results on_openonly fires for"ExecCmd"-- if your result type is"NoReturn",on_openis never called- Test with
--plugin=<name>-- load only your plugin during development to avoid interference - The 50k instruction limit is per hook call -- if you need more, break work across multiple hooks or use
on_idlefor background processing - Use
pcallfor risky operations --load()can return nil + error on syntax errors; always wrap it inpcall - Icon names are system theme dependent -- use common FreeDesktop names like
"face-smile","accessories-calculator","system-search"