Files
vikingowl 1075eefbf3 docs(phase-3): full Lua API spec + D23/D24 decisions
docs/lua-api.md (new, 380 lines):
- Section 1-2: Why Lua + file location (owlry.lua, NOT init.lua per D23)
- Section 3: Quick reference (one self-contained example covering every
  surface)
- Section 4: API reference (owlry.set / providers / tabs / provider /
  theme) with per-field tables and rules
- Section 5: Host API in scope (full stdlib + owlry.util convenience
  helpers); no sandbox in 2.1
- Section 6: How providers/tabs/provider{} compose at runtime — the
  three orthogonal axes (compiled in / enabled / shown as tab) made
  explicit with a worked example and a what-if table
- Section 7: Hot reload via notify crate (re-added in Phase 3)
- Section 8: Validation via 'owlry config validate' / 'config show'
- Section 9: Migration via 'owlry migrate-config' with full TOML→Lua
  mapping table
- Section 10: Open questions resolved before Phase 3 ships
- Section 11: Version compatibility roadmap (2.0 -> 2.1 -> 2.2 -> 3.0)
- Section 12: Implementation outline (handoff to engineering)

docs/RESTRUCTURE-V2.md:
- D23: config file named owlry.lua (brand identity over init.lua
  convention; file is loaded explicitly, not via Lua's require)
- D24: owlry.providers vs owlry.tabs distinction made explicit — three
  orthogonal axes (compiled in / enabled / shown as tab), full
  composition spec lives in lua-api.md §6

The Lua API doc is intended as both the design spec we're committing to
AND the user-facing reference once Phase 3 ships.
2026-05-13 03:41:11 +02:00

26 KiB

Owlry Lua API

The owlry Lua configuration system. Lands in 2.1 as an opt-in preview alongside TOML; becomes the canonical config format in 3.0 when TOML support is removed.

Status: design spec for Phase 3 of the v2 rewrite plan. Becomes the user-facing reference doc once Phase 3 ships.


1. Why Lua

Owlry's 2.0 config is ~/.config/owlry/config.toml. TOML is fine for flat key/value settings, but it can't express the things people actually want from a launcher config:

  • "Use a different SSH command for :ssh host-foo than for everything else"
  • "Add a Hyprland shutdown menu as a :hs provider"
  • "Build the bookmarks list from ~/Documents/links.md instead of Firefox"
  • "Conditionally enable systemd-units only on machines where $HOSTNAME matches a regex"

These are all one-liners in a real programming language. They're impossible in TOML.

Owlry 2.1 ships a Lua-driven config: the file is real Lua 5.4, evaluated at startup, and the same file holds:

  1. Global settings (theme, dimensions, terminal command)
  2. Enabled providers (which built-ins run)
  3. Tab layout (which providers appear as tab buttons)
  4. User-defined providers (custom search sources)
  5. Theme overrides

This is the same model Hyprland adopted (hyprlang) and that AwesomeWM / wezterm / xmonad have always used: the config language IS the extension language. No second-class scripting bolted on alongside a static config.


2. File location & loading order

The config file is ~/.config/owlry/owlry.lua.

Not init.lua — the name owlry.lua is intentional brand identity. init.lua is Lua's require entry-point convention; this file isn't loaded by require, it's loaded explicitly by owlry, so the convention doesn't apply.

Resolution

On startup the daemon resolves config in this order:

  1. $XDG_CONFIG_HOME/owlry/owlry.lua (default: ~/.config/owlry/owlry.lua) — if this file exists, it's the only source of truth. TOML is ignored entirely when Lua is present.
  2. $XDG_CONFIG_HOME/owlry/config.toml — fallback for 2.x users who haven't migrated. Behavior unchanged from 2.0.
  3. Shipped defaults/usr/share/owlry/default.lua (a stub that calls owlry.set {} with reasonable defaults). Used when neither user file exists.

In 3.0, step 2 is removed. Users who haven't migrated get a clear error from owlry config validate and a pointer to owlry migrate-config.

Why Lua "wins" over TOML when both exist

To avoid the "did you remember to delete config.toml?" footgun. If you ever write an owlry.lua, that's your config — the TOML next to it doesn't quietly partial-override anything. Clear winner: predictable behavior.


3. Quick reference

-- ~/.config/owlry/owlry.lua
local owlry = require("owlry")

-- ──────────────────────────────────────────────────────────────────────────
-- Global settings
-- ──────────────────────────────────────────────────────────────────────────
owlry.set {
  theme      = "owl",
  width      = 850,
  height     = 650,
  font_size  = 14,
  terminal   = "kitty",
  use_uwsm   = false,
  show_icons = true,
  max_results = 100,
}

-- ──────────────────────────────────────────────────────────────────────────
-- Which providers run (the "enabled set")
-- ──────────────────────────────────────────────────────────────────────────
owlry.providers {
  "app", "cmd", "calc", "conv", "power",     -- always-on built-ins
  "systemd", "ssh", "websearch", "filesearch", "emoji", "clipboard",
}

-- ──────────────────────────────────────────────────────────────────────────
-- Which providers get a tab button in the UI header bar (Ctrl+1..N)
-- Must be a subset of the enabled set. If omitted, defaults to all providers.
-- ──────────────────────────────────────────────────────────────────────────
owlry.tabs { "app", "cmd", "uuctl" }

-- ──────────────────────────────────────────────────────────────────────────
-- A user-defined provider — automatically joins the enabled set.
-- ──────────────────────────────────────────────────────────────────────────
owlry.provider {
  id         = "hs",
  prefix     = ":hs",
  tab_label  = "Shutdown",
  icon       = "system-shutdown",
  items = function()
    return {
      { name = "Lock",     command = "hyprlock" },
      { name = "Shutdown", command = "systemctl poweroff" },
      { name = "Reboot",   command = "systemctl reboot" },
    }
  end,
}

-- ──────────────────────────────────────────────────────────────────────────
-- Theme selection (or inline definition; see §4.5)
-- ──────────────────────────────────────────────────────────────────────────
owlry.theme("catppuccin-mocha")

4. API reference

The owlry module is the only thing in scope. require("owlry") returns it. All functions are callable in any order, multiple times — last-write-wins on most settings; some accumulate (see each function's note).

4.1 owlry.set { ... } — global settings

Sets top-level config values. Takes a table of key-value pairs. Calling owlry.set multiple times merges — later calls override earlier values for the same key. Keys not mentioned keep their default.

Key Type Default What it does
theme string nil (system theme) Theme name (see §4.5 for options)
width integer 850 Launcher window width in pixels
height integer 650 Launcher window height in pixels
font_size integer 14 Base font size in points
border_radius integer 12 Window corner radius in pixels
terminal string | nil autodetected Terminal command for items marked terminal=true. Falls back to $TERMINALxdg-terminal-exec → common terminals
use_uwsm boolean false Launch apps via uwsm app -- for systemd session integration
show_icons boolean true Show provider icons in results
max_results integer 100 Cap on results returned per query
frecency boolean true Boost frequently/recently used items
frecency_weight number 0.3 Frecency boost weight (0.0 = off, 1.0 = strong)
search_engine string "duckduckgo" Engine for :web / ? queries (see §6)

Example:

owlry.set { theme = "owl", width = 900 }
owlry.set { font_size = 16 }   -- merged: theme + width still applied

Unknown keys produce a owlry config validate warning, not an error — forward-compat for 2.2+ keys.


4.2 owlry.providers { ... } — enabled set

Lists which providers run. Takes a sequence (array) of provider IDs. Order doesn't matter.

owlry.providers { "app", "cmd", "calc", "conv", "power" }

Rules:

  • A provider must be in this list to produce any results. If it's not enabled, neither typing its prefix (:foo) nor including it in owlry.tabs will make it active.
  • Built-in IDs are: app, cmd, dmenu, calc, conv, power, systemd (alias: uuctl), ssh, clipboard, emoji, websearch, filesearch. Pre-2.0 aliases (sys, systempower; uuctlsystemd) still work.
  • User providers defined via owlry.provider {} auto-join the enabled set — you don't list them here unless you want to.
  • Calling owlry.providers multiple times replaces the list (it's not additive). Use one call.
  • If a feature isn't compiled into the binary (e.g. someone built with --no-default-features), the provider is silently ignored at runtime — owlry doctor reports the mismatch.

Default if omitted: all compiled-in providers are enabled. (The AUR build has --features full, so this defaults to everything.)


4.3 owlry.tabs { ... } — UI tab buttons

Lists which providers appear as tab buttons in the header bar. Tabs cycle with Tab / Shift+Tab and can be jumped to with Ctrl+1..9.

owlry.tabs { "app", "cmd", "uuctl" }

Rules:

  • Each entry must be in owlry.providers (or be a owlry.provider {} user provider). Listing an unknown ID produces a owlry config validate warning and is silently dropped at runtime.
  • Order is preserved — Ctrl+1 targets the first entry, Ctrl+2 the second, etc.
  • The implicit "All" tab is always present at position 0 (Ctrl+0).
  • Default if omitted: all enabled providers get tabs in the order they were registered.
  • owlry.tabs {} (empty) is valid: hides all tab buttons. The "All" tab is still there.

The tabsproviders rule, made obvious:

provider IS enabled         ┌──────────────────────────────────────────┐
(runs, contributes results) │ app cmd power calc conv systemd ssh emoji│   <- owlry.providers
                            └─────┬─────┬───────────────┬──────────────┘
                                  │     │               │
                            ┌─────▼─────▼───────────────▼──────────────┐
                            │ app cmd                  uuctl           │   <- owlry.tabs
                            │ ↑1   ↑2                  ↑3              │      (subset; Ctrl+N order)
                            └──────────────────────────────────────────┘

A provider in providers but not in tabs is fully searchable (auto mode + :prefix works), it just doesn't get a permanent tab button. This is the right place for calc/conv/websearch/filesearch — they're triggered with = / > / ? / / and don't need to take up tab-bar real estate.


4.4 owlry.provider { ... } — user-defined provider

Defines a custom provider that runs alongside the built-ins. The minimum useful shape:

owlry.provider {
  id     = "hs",                         -- type_id, used internally
  prefix = ":hs",                        -- search prefix (optional)
  items  = function(query)               -- called to populate results
    return {
      { name = "Lock",     command = "hyprlock" },
      { name = "Shutdown", command = "systemctl poweroff" },
    }
  end,
}

Full field reference:

Field Type Required Default What it does
id string yes Unique provider ID. Used for filter matching (-m <id>) and tab listings. Must be lowercase, alphanumeric + -/_.
items function(query: string) -> table yes Returns the list of items. Called on every query if dynamic = true, otherwise cached after the first call (see refresh below).
prefix string | nil no nil Search prefix (e.g. ":hs "). Typing the prefix narrows results to this provider.
name string no id capitalised Display name shown in owlry providers.
tab_label string no name Tab button text.
icon string no "application-x-addon" XDG icon name shown next to items.
search_noun string no id Search placeholder noun (e.g. "Search SSH hosts…").
dynamic boolean no false If true, items is called on every keystroke. If false, items is called once at startup and cached. 2.1 ships only false (static); true arrives in 2.2.
priority integer no 0 Tiebreaker for ordering when multiple providers match. Higher = first.

The item table:

Each entry in the table returned by items is itself a table:

Field Type Required What it does
name string yes Item title shown in the result row
command string yes Shell command executed on launch
description string no Secondary text below the title
icon string no Per-item icon (overrides provider icon)
terminal boolean no If true, launches inside a terminal
tags table no List of tag strings for :tag:X filtering

Multiple owlry.provider calls accumulate — each call registers an additional provider. Calling twice with the same id overrides the previous registration (warning emitted).

Lifecycle: the provider's items function is called by the daemon. It runs in the Lua state with the full host API available (see §5). It must return within a reasonable time — slow items functions block the UI. If you need to do work that takes >100ms, cache it externally and refresh on demand (see dynamic = true for the 2.2 path).


4.5 owlry.theme(...) — theme selection

Two forms:

-- Built-in or user theme by name
owlry.theme("catppuccin-mocha")

Looks up the theme in order: ~/.config/owlry/themes/{name}.css → bundled theme. Built-in themes: owl, catppuccin-mocha, nord, rose-pine, dracula, gruvbox-dark, tokyo-night, solarized-dark, one-dark, apex-neon.

-- Inline color overrides (merges on top of selected theme or system defaults)
owlry.theme {
  background       = "#1e1e2e",
  background_secondary = "#313244",
  border           = "#45475a",
  text             = "#cdd6f4",
  text_secondary   = "#a6adc8",
  accent           = "#cba6f7",
  accent_bright    = "#f5c2e7",
  -- Per-provider badge colors (optional)
  badge_app        = "#a6e3a1",
  badge_cmd        = "#fab387",
  badge_power      = "#f38ba8",
  badge_uuctl      = "#9ece6a",
}

The two forms can be combined — call owlry.theme(name) first, then owlry.theme { ... } to layer overrides on top.


5. The host API in Lua scope

User code runs in a Lua 5.4 state with the mlua runtime. Beyond the standard library, owlry exposes a small host API for things provider code is likely to need.

5.1 What's available in owlry.lua

  • Full Lua 5.4 stdlibmath, string, table, os, io, coroutine, package. No sandboxing. It's your config file on your machine; deal with it.
  • The owlry moduleset, providers, tabs, provider, theme. (Documented above.)
  • Convenience helpers under owlry.util (added incrementally; ship in 2.1):
    • owlry.util.shell(cmd) -> string — run a shell command, return stdout. Blocking; use sparingly.
    • owlry.util.shell_lines(cmd) -> table — same, split into a list of lines.
    • owlry.util.read_file(path) -> string|nil — read a file's contents, nil if missing.
    • owlry.util.glob(pattern) -> table — list paths matching a glob.
    • owlry.util.env(name, default?) -> string — read an env var with a fallback.
    • owlry.util.hostname() -> string — current hostname.

5.2 What's NOT in scope

  • No process spawning at config-load time for non-trivial setup. Provider items functions can shell out via owlry.util.shell, but the top-level config eval should be fast. If the daemon spends >500ms running owlry.lua at startup, something's wrong.
  • No network helpers in 2.1. If a user provider needs HTTP, they can os.execute("curl ...") for now. A first-class owlry.util.http_get lands in 2.2.
  • No reactive state. Each provider's items function should be stateless (or use local upvalues for caching). Cross-call state via shared mutable globals will work but isn't a supported pattern.

5.3 Sandbox stance

None in 2.1. This is the user's config file on the user's machine, by analogy to bashrc or init.vim. If a future "share your config" community emerges, sandbox concerns kick in then. For now: assume the user wrote the file they're running.


6. How providers + tabs + provider {} compose at runtime

The single most-asked question this section pre-empts.

6.1 The three axes

  1. Compiled in — cargo features at build time. AUR build = all of them. Users on cargo install --no-default-features only have a minimal set. Cannot be enabled at runtime.
  2. Enabledowlry.providers { ... } decides which compiled-in providers actually run. Plus all owlry.provider {} user definitions are auto-enabled.
  3. Shown as tabowlry.tabs { ... } decides which enabled providers get a button in the header bar.

A provider must be compiled in AND enabled to do anything. Being shown as a tab is purely a UI convenience.

6.2 Selection at use time

On top of the three axes, the user picks at launch time:

owlry                # auto mode — every enabled provider contributes, tabs cycle through
owlry -m auto        # explicit form of above
owlry -m app         # single-mode — only `app` runs, even though others are enabled
owlry --profile dev  # profile (see §6.5) — pre-baked subset of enabled providers

Plus the prefix override inside the UI: typing :uuctl foo narrows the current query to the systemd provider regardless of the mode.

6.3 Worked example

Given this config:

owlry.providers { "app", "cmd", "calc", "conv", "power", "systemd", "websearch" }
owlry.tabs      { "app", "cmd", "systemd" }
owlry.provider  { id = "hs", prefix = ":hs", items = function() ... end }

Behavior:

Invocation / typed prefix What runs Tabs shown
owlry (no flags), empty query every enabled provider scored by frecency. app, cmd, calc, conv, power, systemd, websearch, hs all contribute app, cmd, systemd, hs (hs auto-added because it wasn't in owlry.tabs → defaults policy kicks in for user providers; see note below)
owlry then user types :hs only the hs provider tabs unchanged
owlry -m app only app only the app tab is highlighted
owlry, user types = 2+3 calculator catches the = trigger, returns 5 tabs unchanged
owlry, user types :foo bar nothing (no provider with id foo) tabs unchanged
owlry, user types :bookmarks rust nothing (bookmarks not in providers → not enabled, even though it's compiled in) warning surfaces in owlry doctor

Default tab policy when owlry.tabs is omitted: all enabled providers get tabs. When owlry.tabs IS present: user providers from owlry.provider {} are NOT auto-added — the user explicitly chose their tab list. To pin a user provider, include its id in owlry.tabs. (Open: do we want auto-add behavior here? See §10.)

6.4 What happens if a config refers to something missing

Situation Behavior Where surfaced
owlry.providers lists a provider not compiled in Silently dropped at runtime; warning logged owlry doctor lists it under "Not compiled in"
owlry.providers lists an unknown id Warning at config-validate time owlry config validate prints the offending line
owlry.tabs lists an id not in owlry.providers Warning, dropped from tab bar owlry config validate
owlry.provider { items = function() ... end } errors Provider returns 0 items for that query; full traceback in daemon log owlry doctor shows the provider as "errored" with the last exception
Two owlry.provider {} calls with same id Second one wins; warning emitted owlry config validate

6.5 Profiles

Pre-baked alternate enabled sets (a la 2.0's --profile). Inline in Lua:

owlry.profiles {
  dev    = { "app", "cmd", "ssh" },
  media  = { "emoji", "clipboard" },
  minimal = { "app" },
}
owlry --profile dev    # uses { app, cmd, ssh } instead of owlry.providers's full set

Profiles override owlry.providers for the launch but inherit owlry.set / owlry.theme / owlry.tabs.


7. Hot reload

The daemon watches owlry.lua and any files it requires. On save:

  1. Re-evaluate the Lua state in an isolated context.
  2. If eval succeeds → swap the new config into the running daemon. Providers reload. No window flicker; no socket disconnect.
  3. If eval fails → keep the old config alive. The error is logged to the daemon journal and surfaced on the next owlry doctor invocation.

There is no systemctl reload required. Edit the file, save, the next query reflects the change.

Hot reload uses the notify filesystem watcher (re-added to deps in Phase 3 — it was removed in v2 demolition).


8. Validation & error reporting

owlry config validate

Runs the Lua file in dry-run mode (no side effects, no daemon swap). Reports:

  • Syntax errors with line numbers.
  • Unknown keys in owlry.set.
  • Unknown IDs in owlry.providers / owlry.tabs.
  • owlry.tabs entries not in owlry.providers.
  • Duplicate id in owlry.provider {} calls.
  • Providers compiled out (warning, not error).

Exit code: 0 on clean, 1 on any error, 2 on warnings only (configurable).

owlry config show

Prints the resolved config as TOML (yes, TOML — for diffability and consistency with how owlry config show works today). Useful for debugging "what does owlry actually see?"

owlry config show --lua

Prints the resolved config as a Lua file (round-trippable). Useful for owlry migrate-config diff comparison.


9. Migration from TOML

owlry migrate-config

Reads ~/.config/owlry/config.toml, writes ~/.config/owlry/owlry.lua with the equivalent settings. Refuses to overwrite an existing owlry.lua unless --force is passed.

Mapping:

TOML Lua
[general] theme = "owl" owlry.set { theme = "owl" }
[general] tabs = ["app", "cmd"] owlry.tabs { "app", "cmd" }
[providers] app = true included in owlry.providers { ... }
[providers] app = false excluded from owlry.providers { ... }
[appearance.colors] badge_app = "..." merged into owlry.theme { badge_app = "..." }
[profiles.dev] modes = [...] owlry.profiles { dev = { ... } }

Edge cases:

  • Pre-v2 aliases (system config key, badge_sys color) are normalized to v2 names (power, badge_power) in the emitted Lua.
  • Comments in the TOML are translated to Lua comments where they survive serialization round-trips. Order is preserved as much as possible.
  • Unknown TOML keys (e.g. removed config options) are commented out in the output with a -- DROPPED: prefix.

The migrator is deterministic — running it twice on the same input produces byte-identical output.


10. Open questions (resolved before Phase 3 ships)

Q Default proposed Notes
Auto-add user providers to owlry.tabs when tabs is non-empty? No — explicit tabs list is explicit Easy to revisit; user feedback after 2.1 will decide
owlry.theme separate vs folded into owlry.set? Separate — themes are big enough to deserve their own verb Matches Hyprland's general / decoration split
Profile keybinds (per-key Lua functions)? Deferred to 2.2 Needs careful design re: which thread runs the function
Error reporting depth: traceback in UI banner? No in 2.1 — log to journal, surface via owlry doctor UI banner gets noisy fast
Allow owlry.lua to be a directory of files merged together? No — single file. Users wanting modularity use Lua require
Multi-file config via require? Yes, supported transparently — same hot-reload watcher follows required files Standard Lua semantics

11. Version & compatibility roadmap

Owlry version Lua config TOML config Notes
2.0 (shipped) not supported canonical owlry migrate-config exists as a stub
2.1 (Phase 3) opt-in preview still works owlry migrate-config becomes functional
2.2 (Phase 3 polish) preferred; documented works but warning emitted on load dynamic providers, owlry.bind, owlry.util.http_get
3.0 (D18) canonical removed owlry config validate errors loudly when TOML is found without owlry.lua

12. Implementation outline (engineering, not user-facing)

Tracked in docs/RESTRUCTURE-V2.md Phase 3. High level:

  1. Add mlua dep (Lua 5.4, vendored, send, serialize)
  2. New crates/owlry/src/lua/ module: runtime, api surface, error types, host API utilities
  3. crates/owlry/src/config/loader.rs: resolve order Lua → TOML → defaults; both paths produce the same Config struct
  4. LuaProvider impl Provider/DynamicProvider from a Lua closure
  5. notify watcher re-added; daemon listens for owlry.lua changes, re-evaluates in an isolated state, hot-swaps
  6. owlry migrate-config functional; reads TOML, emits Lua
  7. Characterization tests for every section of this doc; integration test that loads each example end-to-end
  8. README + CLAUDE.md + owlry(1) updated

Target release: 2.1.0.