Skip to content

Runtime API

Most noctalia.* functions are synchronous. Methods that start subprocesses, HTTP requests, or downloads return a boolean immediately to say whether the work was accepted, then deliver results to a callback.

MethodReturnsDescription
noctalia.setUpdateInterval(ms)nothingSets how often this entry’s update() function runs. Values below 16 ms are clamped.
noctalia.log(msg)nothingWrites a message to Noctalia’s log with this plugin’s script context.
noctalia.isDarkMode()boolReturns whether the active theme mode is dark.
noctalia.focusedOutputName()string or nilReturns the focused output’s connector name, falling back to the preferred interactive output when needed.
noctalia.getConfig(key)setting value or nilReads a declared plugin or entry setting. Undeclared keys log a warning and return nil.
MethodReturnsDescription
noctalia.outputs()arrayReturns connected outputs as { name, description, width, height, x, y, scale, focused } tables.
noctalia.setWallpaperEnabled(connector, enabled)nothingEnables or disables Noctalia’s own wallpaper surface on one output. This is runtime-only and clears on restart.
noctalia.setWallpaper(path)nothingApplies and persists a wallpaper image on every output.
noctalia.setWallpaper(connector, path)nothingApplies and persists a wallpaper image on one output.
noctalia.wallpaperDirectory()string or nilReturns the resolved wallpaper folder for the current theme mode, or nil when unset.
noctalia.togglePanel("author/plugin:panel")nothingOpens or closes a plugin panel by full entry id.
noctalia.openSettings()nothingOpens the settings window at this plugin’s own settings, closing any open panel. Requires plugin_api = 15.

outputs() uses the DRM connector name in name; that is the same connector string expected by compositor tools such as mpvpaper. A [[service]] may also define onOutputsChanged(), which Noctalia calls whenever the output set or geometry changes.

setWallpaper(path) behaves like the built-in wallpaper panel. Prefer it and togglePanel(id) over shelling out to the noctalia CLI from a plugin; the API path runs in-process and avoids an IPC round trip.

openSettings() takes no id - the host uses the calling plugin’s own id, so a plugin can only ever open its own page. It is the way to offer a “configure me” affordance from a panel or widget, since plugins read config but cannot write it. Nothing happens when the plugin declares no settings; the host logs a warning.

MethodReturnsDescription
noctalia.openColorPicker(initialColor, onClose)boolOpens the color picker with a #RRGGBB initial color. The callback receives the selected canonical #RRGGBB value, or nil when cancelled.

The return value says whether the request was accepted. An entry can have one color picker callback pending at a time.

noctalia.openColorPicker("#FFF59B", function(color)
if color ~= nil then
noctalia.log("Selected " .. color)
end
end)
MethodReturnsDescription
noctalia.appIconPath(appId, sizePx)string or nilResolves an app id to an icon file path with the same lookup the built-in taskbar uses. sizePx is optional.

The lookup matches appId against desktop entries (entry id, then StartupWMClass) to find the icon name, then resolves it through the host’s XDG icon-theme resolver. Input that matches no desktop entry is treated as a raw icon name, so themed icons such as "folder-music" resolve directly. sizePx is the intended on-screen pixel size and selects the best-fitting theme size; without it the largest available icon wins.

The returned path plugs straight into a ui.image path prop. Resolution scans the filesystem on a cache miss, so cache results keyed by app id instead of resolving on every update().

MethodReturnsDescription
noctalia.formatTime(pattern)stringFormats the current local time with the same tokens as Noctalia clock and filename settings.
noctalia.formatTime(pattern, unixSeconds)stringFormats a specific Unix timestamp in local time.
noctalia.formatTime(pattern, unixSeconds, timezone)stringFormats a Unix timestamp in an IANA timezone (for example "Europe/Berlin"). Omit or pass nil for unixSeconds to use now. Invalid zones fall back to local time. Requires plugin_api = 19.
noctalia.timeFormat()stringCurrent [shell].time_format (for example "{:%H:%M}"). Requires plugin_api = 19.
noctalia.dateFormat()stringCurrent [shell].date_format (for example "%A, %x"). Requires plugin_api = 19.
noctalia.isValidTimezone(name)boolTrue when name is empty (system local) or a known IANA timezone. Requires plugin_api = 19.
noctalia.nowMs()numberWall-clock milliseconds since the Unix epoch. Requires plugin_api = 12.
noctalia.notify(title, body)nothingShows an informational notification. body is optional.
noctalia.notifyError(title, body)nothingShows an error notification. body is optional.
noctalia.copyToClipboard(text, mime)boolCopies text to the clipboard with an explicit MIME type, for example "text/plain" or "text/uri-list".
noctalia.clipboardText()string or nilLatest text clipboard content (nil when empty or non-text).

%s in formatTime() expands to Unix epoch seconds.

formatTime() and Luau’s own os.time() are both whole-second. nowMs() is the only way to see sub-second time, for example to phase an entry’s updates onto a second boundary.

MethodReturnsDescription
noctalia.systemStats()table or nilSnapshot of the host’s system monitor. nil when [system.monitor] is disabled. Requires plugin_api = 12.
noctalia.cpuCores()array or nilPer-core CPU usage percentages. nil when the monitor is disabled or no sample is ready yet. Requires plugin_api = 12.
noctalia.diskMounts()arrayPhysical block-device-backed filesystems available to diskStats(). Requires plugin_api = 16.
noctalia.diskStats(path)table or nilLatest disk usage snapshot for an absolute or ~/ path. nil when the monitor or path is unavailable. Requires plugin_api = 16.

systemStats() returns a copy of the host’s most recent sample. Its first call opts the plugin into the optional CPU temperature and GPU probes represented in the snapshot; those probes are released when the plugin unloads.

local stats = noctalia.systemStats()
if stats ~= nil then
noctalia.log(string.format("cpu %.0f%% ram %.0f%%", stats.cpu.usagePercent, stats.ram.usagePercent))
end
FieldTypeNotes
sampledAtMsnumberUnix epoch milliseconds for the latest aggregate sample. Absent until the first sample. Requires plugin_api = 16.
cpu.usagePercentnumberAggregate CPU busy percentage, 0-100.
cpu.tempCnumberAbsent when no CPU temperature sensor was found.
ram.usagePercent, ram.usedMb, ram.totalMbnumber
swap.usedMb, swap.totalMbnumber
gpu.tempC, gpu.usagePercent, gpu.vramUsedBytes, gpu.vramTotalBytesnumberEach is absent when that GPU probe is unavailable.
net.rxBytesPerSec, net.txBytesPerSecnumberTotalled across interfaces.
net.interfacestableReceive/transmit rates keyed by interface name. Requires plugin_api = 16.
loadAvgarrayThe 1, 5, and 15 minute load averages.

Absent sensors are nil rather than 0, so a plugin can tell “no probe” from “idle”. Test with if stats.gpu.tempC ~= nil then before using an optional field. A newly enabled probe may remain nil until its first configured poll.

Values refresh on the poll intervals configured in [system.monitor], CPU every 2 seconds by default, not on every call. Calling systemStats() more often than that returns the same sample again.

for _, mount in ipairs(noctalia.diskMounts()) do
local disk = noctalia.diskStats(mount.path)
if disk ~= nil then
noctalia.log(string.format("%s %.0f%% used", mount.path, disk.usagePercent))
end
end

diskMounts() returns { path, source, filesystem } for physical block-device-backed filesystems, deduplicated by source and sorted by mount path. Pseudo filesystems, loop and squashfs mounts, and boot mounts are excluded.

diskStats() accepts an absolute path or a path beginning with ~/. The first call for each valid path opts that path into disk sampling until the plugin unloads. The result contains usagePercent, totalBytes, freeBytes, and availableBytes. It does not expose sample history.

local cores = noctalia.cpuCores()
if cores ~= nil then
for i, usage in ipairs(cores) do
noctalia.log(string.format("core %d: %.0f%%", i, usage))
end
end

The first call opts the plugin into per-core sampling, which costs one extra /proc/stat read per second for as long as the plugin stays loaded. systemStats() does not enable per-core sampling, so only call cpuCores() if you actually draw per-core data. Sampling runs on a fixed 1 second cadence, independent of cpu_poll_seconds.

Two reads are needed to produce the first result, so expect nil for up to a second after the first call. Treat nil as “not ready yet” rather than “this machine has no cores”.

Cores are in /proc/stat order. Offline cores are absent from that file, so the array length can change between calls, and an entry’s position is not necessarily its core id.

MethodReturnsDescription
noctalia.runAsync(cmd)boolStarts a detached shell command and returns whether it launched. No output is captured.
noctalia.runAsync(cmd, cb)boolRuns a shell command with output capture, then calls cb(result).
noctalia.runStream(cmd, onLine)boolRuns a long-lived shell command and calls onLine(line) for each stdout line.
noctalia.runInTerminal(cmd)boolStarts a shell command in a terminal. Uses $TERMINAL when set (no -e in the value), otherwise discovers a terminal on PATH. See Shell settings.
noctalia.commandExists(name)boolChecks whether an executable is available on PATH.
noctalia.processMatches(cb, ...needles)boolAsynchronously checks running processes; cb(matched) receives a boolean.
noctalia.flatpakAppInstalled(id)boolChecks whether a Flatpak app id is installed.
noctalia.portalAvailable()boolChecks whether the desktop portal is available.
noctalia.getenv(name)string or nilReads an environment variable.
noctalia.expandPath(path)stringExpands a path such as ~/Pictures to an absolute user path.

runAsync(cmd, cb) calls cb(result) once with:

{
exitCode = 0,
stdout = "...",
stderr = "...",
timedOut = false,
stdoutTruncated = false,
stderrTruncated = false,
}

runStream() is meant for long-running processes. The runtime terminates active streams when the script reloads, the entry is removed, or Noctalia stops the plugin.

MethodReturnsDescription
noctalia.readFile(path)string, or nil, errReads a file as bytes.
noctalia.writeFile(path, content)bool, optional errorWrites a file, replacing existing contents.
noctalia.mkdirAll(path)bool, optional errorCreates a directory and any missing parents (an existing directory is success).
noctalia.removeFile(path)bool, optional errorDeletes a file. Refuses directories.
noctalia.renameFile(from, to)bool, optional errorRenames or moves a file.
noctalia.fileExists(path)boolChecks whether a path exists.
noctalia.fileInfo(path)table, or nil, err{ size, mtime, isDir }, size in bytes, mtime in unix seconds.
noctalia.listDir(path)array, or nil, errLists filenames in a directory.
noctalia.pluginDir()string or nilReturns this plugin’s runtime directory.
noctalia.pluginDataDir()string, or nil, errReturns this plugin’s persistent data directory, creating it on demand.
noctalia.loadFont(path)string, or nil, errRegisters a font file and returns its family name.

Filesystem paths resolve as follows: ~ expands to $HOME, absolute paths are used as-is, and relative paths resolve against the plugin’s own directory. Plugins are trusted code, so these helpers are not sandboxed.

noctalia.state (below) lives only in memory - it is cleared when the plugin stops. To keep data across restarts, write it to a file. Do not persist inside noctalia.pluginDir(): for git-installed plugins that directory is a runtime copy that Noctalia rewrites on update, so anything you save there is lost.

Use noctalia.pluginDataDir() instead. It returns a per-plugin folder that survives updates, is scoped to your plugin id, follows Noctalia’s NOCTALIA_STATE_HOME override, and is created for you on first call.

local dir = noctalia.pluginDataDir()
local path = dir .. "/data.json"
-- save
noctalia.writeFile(path, noctalia.json.encode(myTable))
-- restore on load
local raw = noctalia.readFile(path)
if raw then myTable = noctalia.json.decode(raw) end
MethodReturnsDescription
noctalia.sound.load(name: string, path: string, onLoaded: (ok: boolean, error: string?) -> ())booleanAccepts an asynchronous sound load and calls onLoaded after decoding. Requires plugin_api = 20.
noctalia.sound.play(name: string)nothingPlays a loaded sound from this runtime’s sound bank. Requires plugin_api = 20.

Define the completion callback before requesting the load, and play only after it reports success:

local function onClickLoaded(ok, err)
if not ok then
noctalia.notifyError("Could not load click sound", err)
return
end
noctalia.sound.play("click")
end
local accepted = noctalia.sound.load("click", "sounds/click.ogg", onClickLoaded)
if not accepted then
noctalia.notifyError("Could not queue click sound")
end

Sound paths resolve like filesystem paths. Bundled relative assets such as sounds/click.ogg need no manifest declaration. A true return means the request was accepted, not that the file decoded; a decode failure calls the callback with false and a non-empty error. At most eight loads may be pending in one runtime, and requesting a name that already has a pending load returns false.

Sound names are scoped to one runtime instance. Reloading or stopping it cancels pending callbacks and releases its cached names, while a sound already playing finishes safely. Playback honors [audio].enable_sounds and [audio].sound_volume. Triggering the same loaded sound while it is still playing is suppressed.

MethodReturnsDescription
noctalia.http(request, cb)boolStarts an async HTTP request and calls cb(response).
noctalia.httpStream(request, onLine, onClose)handle or nilStarts a long-lived streaming request (e.g. SSE) and delivers each line to onLine.
noctalia.download(url, dest, cb)boolDownloads a URL to a file and calls cb(ok) with a boolean.

request is a table:

{
url = "https://example.com",
method = "GET",
body = "",
headers = { "Accept: application/json" },
basic_username = "user",
basic_password = "pass",
follow_redirects = false,
allow_insecure_tls = false,
}

allow_insecure_tls requires plugin_api = 7. When true, it disables certificate-chain and hostname verification for the request, including any HTTPS redirects. Keep it false unless the endpoint is explicitly trusted and cannot use a publicly trusted certificate. The option applies to both http() and httpStream() and does not disable HTTPS proxy certificate verification.

Only url is required. http() calls cb(response) once with:

{
ok = true,
status = 200,
body = "...",
}

download() resolves dest like other filesystem paths, so a relative destination writes inside the plugin directory.

httpStream() (plugin_api = 4) keeps the connection open and delivers the response body incrementally - use it for server-sent events or any line-oriented streaming endpoint. Because headers travel through the native HTTP client, credentials such as an Authorization: Bearer ... header never appear in a process command line, unlike a spawned curl.

local stream = noctalia.httpStream({
url = "http://homeassistant.local:8123/api/stream",
headers = { "Accept: text/event-stream", "Authorization: Bearer " .. token },
}, function(line)
-- one call per received line, CR trimmed
end, function(result)
-- fires exactly once when the transfer ends: { ok = false, status = 0 }
if not result.ok then reconnect() end
end)
stream.stop() -- cancel; idempotent, suppresses onClose

It returns a handle with a stop() function, or nil when the stream could not be started. onClose fires exactly once when the transfer ends - server close, network error, or an HTTP error response fully read - unless the stream was stopped. Non-2xx responses are not special-cased: their body streams to onLine and the status arrives in onClose (ok = true, status = 401 for a rejected token). There is no overall transfer timeout; reconnecting after onClose is the caller’s job. Active streams are cancelled when the script reloads, the entry is removed, or Noctalia stops the plugin. With [shell] offline_mode = true the stream never connects and onClose fires with ok = false.

MethodReturnsDescription
noctalia.tr(key)stringLooks up a translated string from translations/<lang>.json, falling back to the key.
noctalia.tr(key, subst)stringLooks up a string and replaces {name} placeholders from a table.
noctalia.trp(key, count)stringPlural translation helper; prefers <key>.one or <key>.other, then the bare key.
noctalia.trp(key, count, subst)stringPlural translation helper with substitutions. count is also available as {count}.
noctalia.json.decode(str)value, or nil, errParses JSON into Luau values.
noctalia.json.encode(value)string, or nil, errEncodes a Luau value as compact JSON.
noctalia.json.encode(value, true)string, or nil, errEncodes a Luau value as pretty JSON.
noctalia.string.trim(str)stringTrims leading and trailing whitespace.
noctalia.string.urlEncode(str)stringPercent-encodes a string for use in URLs.
noctalia.string.urlDecode(str)stringDecodes percent-encoded URL text.
noctalia.fuzzyScore(pattern, text)number or nilScores a fuzzy match with Noctalia’s native matcher. nil means no match.

json.* and string.* are synchronous pure transforms. Prefer string.urlEncode() when building query strings for http() or download().

Entries are isolated VMs - they don’t share Lua memory. Instead they exchange plain values through a per-plugin state channel:

MethodReturnsDescription
noctalia.state.set(key, value)nothingPublishes a plain value for other entries in the same plugin.
noctalia.state.get(key)value or nilReads the latest value for a key.
noctalia.state.watch(key, fn)nothingCalls fn(value) whenever the key 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.

This channel is in-memory only - it is not saved to disk and is cleared when the plugin stops. For data that must survive a restart, write it to noctalia.pluginDataDir().

-- 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)