Skip to content

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 runtime

The 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.

id = "me/hello" # "<author>/<plugin>" — globally unique
name = "Hello"
version = "1.0.0"
min_noctalia = "5.0.0" # mandatory: minimum Noctalia version
author = "me"
license = "MIT" # optional; defaults to MIT
deprecated = false # optional; defaults to false
icon = "puzzle" # optional
description = "A friendly greeter."
tags = ["demo"] # optional, for catalog search

name 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.

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:

TableKindRuns
[[widget]]Bar widgetPlaced on a bar; ticks + handles clicks/IPC
[[shortcut]]Control-center tileA toggle tile in the control center
[[launcher_provider]]Launcher providerAnswers launcher queries with results, behind a prefix
[[desktop_widget]]Desktop widgetA tile on the desktop; declares its UI as a ui.* tree
[[service]]Headless serviceBackground 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"

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"
FieldNotes
keyrequired; the config key
typestring, bool, int, double, select, file, folder, glyph, color
label / descriptionshown in the settings GUI
defaultseeded value (must match the type)
min / maxfor int / double
optionsfor select: array of { value, label }
visible_when{ key = "other_key", values = ["true"] } — conditional visibility
advancedhide behind the “show advanced” toggle

A script defines global functions that Noctalia calls, and drives the UI through the API namespaces. Which globals apply depends on the entry kind:

FunctionWidgetShortcutLauncherDesktopServiceWhen
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")
end

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)
end
render()
function onClick()
on = not on
noctalia.state.set("toggled", on)
render()
end

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)

  • query must echo the text from onQuery, 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? }. id is passed back to onActivate. Results are ordered by score (descending), then insertion order.
  • glyph (a Tabler/Nerd-Font name) or icon (a themed icon name) is the row’s leading visual. badge is a short string (e.g. an emoji or =) drawn in place of the icon — setting it hides glyph/icon. Use the subtitle for 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")
end

desktopWidget.* — 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")
end

The available controls and their props (sizes are logical px, scaled with the widget):

ConstructorProps
ui.column / ui.rowgap, padding, paddingH, paddingV, align (start/center/end/stretch), justify (start/center/end/space_between), fill, radius, border, borderWidth, minWidth, minHeight
ui.labeltext, fontSize, color, fontWeight (thinheavy), maxWidth, maxLines, textAlign
ui.glyphname (Tabler/Nerd-Font glyph), size, color
ui.imagepath (plugin-relative or absolute), width, height, radius, fit (contain/cover/stretch)
ui.boxfill, radius, border, borderWidth, width, height
ui.separatorthickness, color, spacing, orientation (auto/horizontal/vertical)
ui.spacerflexible filler (use flexGrow)
ui.progressprogress (0–1), fill, track, radius, width, height
ui.buttontext, glyph, fontSize, glyphSize, variant (default/primary/secondary/destructive/outline/ghost), enabled, onClick, onRightClick
ui.graphvalues / 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 key prop so reordering reuses the same native controls.
  • Ticks: setWantsSecondTicks(true) runs update() on second boundaries (clocks/timers). setNeedsFrameTick(true) additionally delivers onFrameTick(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.

GroupFunctions
RuntimesetUpdateInterval(ms), log(msg), isDarkMode(), focusedOutputName(), getConfig(key)
TimeformatTime(pattern [, unixSeconds])
Notifynotify(title, body), notifyError(title, body)
SubprocessrunAsync(cmd [, cb]), runStream(cmd, onLine), runInTerminal(cmd), commandExists(name), processMatches(cb, ...needles), flatpakAppInstalled(id), portalAvailable(), getenv(name), expandPath(path)
ClipboardcopyToClipboard(text [, mime])
FilesystemreadFile(path), writeFile(path, content), fileExists(path), listDir(path), pluginDir() — relative paths resolve against the plugin’s own directory
i18ntr(key [, subst]), trp(key, count [, subst]) — against translations/<lang>.json
HTTPhttp({ url = … }, cb), download(url, dest, cb) — async; the callback fires when the request lands
JSONjson.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.

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 = 0
noctalia.setUpdateInterval(1000)
function update()
n = n + 1
noctalia.state.set("count", n)
end
-- widget.luau
noctalia.state.watch("count", function(value)
barWidget.setText(tostring(value))
end)

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:

Terminal window
# 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 refresh

The 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.

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 = false
min_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.