Plugin Development
A plugin is a directory with a static plugin.toml manifest and one or more Luau entry scripts.
Each entry runs in its own isolated Luau VM, off the UI thread, with per-call time budgets — a slow or crashing script
never stalls the shell. Plugins are trusted code: installing one is equivalent to running a user-owned script.
my-plugin/ plugin.toml # manifest: identity + entries + settings schema widget.luau # an entry script translations/en.json # optional i18n bundle (flattened, dotted keys) data.txt # any files your scripts read at runtimeThe fastest way to learn the shape is the
official-plugins repo: the example plugin shows a widget, a
service, and a shortcut sharing state, and screen_recorder is a full real-world plugin.
The manifest
Section titled “The manifest”id = "me/hello" # "<author>/<plugin>" — globally uniquename = "Hello"version = "1.0.0"min_noctalia = "5.0.0" # mandatory: minimum Noctalia versionauthor = "me"license = "MIT" # optional; defaults to MITdeprecated = false # optional; defaults to falseicon = "puzzle" # optionaldescription = "A friendly greeter."tags = ["demo"] # optional, for catalog searchname and min_noctalia are required — a manifest without either is rejected. min_noctalia gates enable, the
catalog badge, and the post-update safety rollback.
license defaults to MIT when omitted. deprecated = true marks the plugin as deprecated in plugin listings; it is a
soft status marker, not a compatibility gate.
Entries
Section titled “Entries”A plugin ships one or more entries. Each declares an id (unique within the plugin), the entry .luau file, and an
optional settings schema. The supported entry kinds:
| Table | Kind | Runs |
|---|---|---|
[[widget]] | Bar widget | Placed on a bar; ticks + handles clicks/IPC |
[[shortcut]] | Control-center tile | A toggle tile in the control center |
[[launcher_provider]] | Launcher provider | Answers launcher queries with results, behind a prefix |
[[desktop_widget]] | Desktop widget | A tile on the desktop; declares its UI as a ui.* tree |
[[service]] | Headless service | Background loop, no UI — feeds the plugin’s other entries |
An entry is addressed <author>/<plugin>:<entry-id> — e.g. a bar widget is configured with
type = "me/hello:hello".
[[widget]]id = "hello"entry = "widget.luau"
[[widget.setting]] key = "label" type = "string" label = "Label" default = "Hello"
[[service]]id = "ticker"entry = "ticker.luau"Settings schema
Section titled “Settings schema”A setting becomes a typed control in the settings GUI and is read back via noctalia.getConfig(key) (or
barWidget.getConfig(key) in a widget — same value). Only declared keys resolve — reading an undeclared key logs a loud
warning and returns nil (no silent fallbacks).
Settings come in two scopes:
- Plugin-level — a
[[setting]]block at the manifest root. One shared set for the whole plugin, edited under Settings → Plugins (the gear on the plugin’s row), and seeded into every entry — widget, shortcut, and service. Use this for configuration the service needs. - Widget entry settings — a
[[<entry>.setting]]block under a[[widget]]. These are edited with the bar widget’s own settings. Use this for widget presentation tweaks; they are not a second enabled plugin instance.
When both declare the same key, the widget entry value wins for that widget. Both scopes use the same field schema:
# Plugin-level: shared by widget + shortcut + service, edited in Settings → Plugins.[[setting]]key = "interval"type = "int"label = "Refresh seconds"default = 5
[[widget]]id = "hello"entry = "widget.luau"
# Widget entry setting: this bar widget only. [[widget.setting]] key = "label" type = "string" default = "Hello"You can define multiple named bar widgets with the same plugin entry and different widget settings:
[widget.hello-main]type = "me/hello:hello"label = "Main"
[widget.hello-short]type = "me/hello:hello"label = "Short"| Field | Notes |
|---|---|
key | required; the config key |
type | string, bool, int, double, select, file, folder, glyph, color |
label / description | shown in the settings GUI |
default | seeded value (must match the type) |
min / max | for int / double |
options | for select: array of { value, label } |
visible_when | { key = "other_key", values = ["true"] } — conditional visibility |
advanced | hide behind the “show advanced” toggle |
Entry scripts
Section titled “Entry scripts”A script defines global functions that Noctalia calls, and drives the UI through the API namespaces. Which globals apply depends on the entry kind:
| Function | Widget | Shortcut | Launcher | Desktop | Service | When |
|---|---|---|---|---|---|---|
update() | ✓ | ✓ | ✓ | every update interval | ||
onClick() / onRightClick() | ✓ | ✓ | pointer press | |||
onMiddleClick() | ✓ | middle press | ||||
onQuery(text) | ✓ | launcher text changed (behind the prefix) | ||||
onActivate(id) | ✓ | a launcher result was selected | ||||
onFrameTick(deltaMs) | ✓ | every frame, after desktopWidget.setNeedsFrameTick(true) | ||||
onIpc(event, payload) | ✓ | ✓ | ✓ | ✓ | ✓ | noctalia msg plugin … |
The top level of the script runs once at load — set up state and register noctalia.state.watch handlers there.
barWidget.* — widget presentation + settings
Section titled “barWidget.* — widget presentation + settings”setText · setGlyph · setImage · setTooltip · clearTooltip · setFont · setColor · setGlyphColor ·
setVisible · isVertical · getConfig(key)
local label = barWidget.getConfig("label")
function update() noctalia.setUpdateInterval(1000) barWidget.setGlyph("puzzle") barWidget.setText(label)end
function onClick() noctalia.notify("Hello", "you clicked me")endshortcut.* — control-center tile
Section titled “shortcut.* — control-center tile”setLabel(text) · setIcon(on [, off]) · setActive(bool) · setEnabled(bool)
local on = noctalia.state.get("toggled") == true
local function render() shortcut.setLabel(on and "On" or "Off") shortcut.setIcon("bulb") shortcut.setActive(on)endrender()
function onClick() on = not on noctalia.state.set("toggled", on) render()endlauncher.* — launcher provider
Section titled “launcher.* — launcher provider”A launcher provider answers queries behind a prefix declared in the manifest. The user types the prefix, and
everything after it is passed to onQuery(text); the provider replies with launcher.setResults(query, results).
onActivate(id) runs when a result is selected (the id is whatever you set on that result).
Manifest fields on a [[launcher_provider]] entry: prefix (the trigger — use a leading / to match the built-in
providers and appear in the launcher’s / overview), glyph (default result icon), include_in_global_search
(also answer the un-prefixed search; default false), and debounce_ms (wait this long after the last keystroke
before running onQuery — set it for network-backed providers so you aren’t called on every character; default 0).
setResults(query, results) · getConfig(key)
querymust echo thetextfromonQuery, so late results map back to the query they answer — the latest one wins. Calling it with an empty list clears the provider’s results.- Each result is a table:
{ id, title, subtitle?, glyph?, icon?, badge?, score? }.idis passed back toonActivate. Results are ordered byscore(descending), then insertion order. glyph(a Tabler/Nerd-Font name) oricon(a themed icon name) is the row’s leading visual.badgeis a short string (e.g. an emoji or=) drawn in place of the icon — setting it hidesglyph/icon. Use thesubtitlefor secondary text.
Queries run off the UI thread, so a result can arrive after onQuery returns — publish a placeholder synchronously,
then call setResults again from an async callback (HTTP, subprocess) when the real answer lands.
[[launcher_provider]]id = "translate"entry = "translator.luau"prefix = "/tr"glyph = "language"function onQuery(text) if text == "" then launcher.setResults(text, { { id = "hint", title = "Type something to translate" } }) return end launcher.setResults(text, { { id = "loading", title = "Translating…", glyph = "loader" } }) noctalia.http({ url = endpoint(text) }, function(res) launcher.setResults(text, { { id = res.body, title = res.body, glyph = "language" } }) end)end
function onActivate(id) noctalia.copyToClipboard(id, "text/plain")enddesktopWidget.* — declarative desktop widget UI
Section titled “desktopWidget.* — declarative desktop widget UI”A desktop widget does not patch a fixed control — it describes its whole UI as a tree and hands it to
desktopWidget.render(tree). The host diffs the new tree against the previous one and updates the retained native
controls in place, so calling render() with an unchanged tree is free. Build trees with the ui.* constructors
(available in every desktop-widget script); each takes a props table and an optional children array:
render(tree) · setWantsSecondTicks(bool) · setNeedsFrameTick(bool) · getConfig(key)
local color = desktopWidget.getConfig("color")
function update() desktopWidget.setWantsSecondTicks(true) -- tick update() on second boundaries desktopWidget.render(ui.column({ gap = 10, align = "center" }, { ui.label({ text = os.date("%H:%M:%S"), fontSize = 32, fontWeight = "bold", color = color }), ui.row({ gap = 8 }, { ui.button({ text = "Ping", variant = "primary", onClick = "ping" }), }), }))end
function ping() noctalia.notify("Desktop widget", "button clicked")endThe available controls and their props (sizes are logical px, scaled with the widget):
| Constructor | Props |
|---|---|
ui.column / ui.row | gap, padding, paddingH, paddingV, align (start/center/end/stretch), justify (start/center/end/space_between), fill, radius, border, borderWidth, minWidth, minHeight |
ui.label | text, fontSize, color, fontWeight (thin…heavy), maxWidth, maxLines, textAlign |
ui.glyph | name (Tabler/Nerd-Font glyph), size, color |
ui.image | path (plugin-relative or absolute), width, height, radius, fit (contain/cover/stretch) |
ui.box | fill, radius, border, borderWidth, width, height |
ui.separator | thickness, color, spacing, orientation (auto/horizontal/vertical) |
ui.spacer | flexible filler (use flexGrow) |
ui.progress | progress (0–1), fill, track, radius, width, height |
ui.button | text, glyph, fontSize, glyphSize, variant (default/primary/secondary/destructive/outline/ghost), enabled, onClick, onRightClick |
ui.graph | values / values2 (arrays of 0–1 numbers), color / color2, lineWidth, fillOpacity, width, height |
Every control also accepts width, height, flexGrow, opacity, and visible. Colors are a palette role token
(primary, on_surface, …) or a hex value (#rrggbb). An unknown control type or prop is logged and skipped — typos
surface in the log instead of failing silently.
- Callbacks:
onClick = "name"names a global function in your script; the host calls it when the button is clicked. - Identity: give list children a
keyprop so reordering reuses the same native controls. - Ticks:
setWantsSecondTicks(true)runsupdate()on second boundaries (clocks/timers).setNeedsFrameTick(true)additionally deliversonFrameTick(deltaMs)every frame for continuous animation — frames are coalesced, a slow script only ever sees the latest one. - Position is host-owned: the user places, sizes, and rotates the tile in the desktop-widgets editor; the script only renders content and reads its declared settings.
The reference implementation is the official noctalia/timer plugin — a countdown timer with start/pause/reset
buttons and a progress bar.
noctalia.* — host capabilities
Section titled “noctalia.* — host capabilities”| Group | Functions |
|---|---|
| Runtime | setUpdateInterval(ms), log(msg), isDarkMode(), focusedOutputName(), getConfig(key) |
| Time | formatTime(pattern [, unixSeconds]) |
| Notify | notify(title, body), notifyError(title, body) |
| Subprocess | runAsync(cmd [, cb]), runStream(cmd, onLine), runInTerminal(cmd), commandExists(name), processMatches(cb, ...needles), flatpakAppInstalled(id), portalAvailable(), getenv(name), expandPath(path) |
| Clipboard | copyToClipboard(text [, mime]) |
| Filesystem | readFile(path), writeFile(path, content), fileExists(path), listDir(path), pluginDir() — relative paths resolve against the plugin’s own directory |
| i18n | tr(key [, subst]), trp(key, count [, subst]) — against translations/<lang>.json |
| HTTP | http({ url = … }, cb), download(url, dest, cb) — async; the callback fires when the request lands |
| JSON | json.decode(str) → value, or nil, err on malformed input; json.encode(value [, pretty]) → string |
formatTime uses the same date-format tokens as Noctalia clock and filename settings. unixSeconds is optional; when omitted, it formats the current local time. %s expands to Unix epoch seconds.
json.decode/json.encode are synchronous pure transforms (no callback), unlike HTTP and filesystem.
Filesystem and HTTP run safely off the UI thread; results arrive via callbacks. runAsync runs a command to completion
and calls its callback once with the output; runStream runs a long-lived command and calls onLine(line) for each
output line as it arrives — the process is terminated automatically when the script reloads or the widget is removed.
Sharing state across entries
Section titled “Sharing state across entries”Entries are isolated VMs — they don’t share Lua memory. Instead they exchange plain values through a per-plugin
state channel: noctalia.state.set(key, value), noctalia.state.get(key), and noctalia.state.watch(key, fn) (fires
when the value changes). Values are copied across entries, so they must be plain data — strings, numbers, booleans,
and tables of those (not functions). A typical pattern is a [[service]] publishing data that the widget and shortcut
watch.
-- ticker.luau (service)local n = 0noctalia.setUpdateInterval(1000)function update() n = n + 1 noctalia.state.set("count", n)end
-- widget.luaunoctalia.state.watch("count", function(value) barWidget.setText(tostring(value))end)Local development
Section titled “Local development”Drop your plugin under $XDG_DATA_HOME/noctalia/plugins/<plugin>/ (or add a path source pointing at your dev
directory), then enable it once. Edits to .luau files hot-reload automatically; manifest changes are picked up on the
next config reload. Drive an entry’s onIpc handler from the shell to test:
# A bar widget — target a specific output (focused / connector / a-bar-name) or all:noctalia msg plugin me/hello:hello focused greet "hi there"
# A service — it is a singleton with no output, so address it with the `all` target:noctalia msg plugin me/hello:ticker all refreshThe target picks which live instances receive the event: focused (the focused output), a connector or
<connector>:<bar-name>, or all. Services and other non-bar entries have no output, so they only match all.
Publishing
Section titled “Publishing”Plugins are distributed from source repos — one repo holds many plugins, each in its own subdirectory matching the
part of the id after the / (so me/hello lives at hello/). Add a catalog.toml at the repo root indexing every
plugin so it can be listed and compat-checked without a full clone:
[[plugin]]id = "me/hello"name = "Hello"version = "1.0.0"author = "me"license = "MIT"icon = "puzzle"description = "A friendly greeter."deprecated = falsemin_noctalia = "5.0.0"tags = ["demo"]Catalog rows require id and name; rows without either are ignored.
Users add your repo with noctalia msg plugins source add <name> git <url>, then enable plugins from it. To contribute
to the official catalog, open a PR against
official-plugins.