require("otter.…"). The host decides which binding modules exist. A capability permits access to a module or operation; it does not install that module in every host.
Built-in modules
The plugin host supplies these modules:
Timer callbacks receive the expiration count.
spawn invokes on_exit(code, stdout, stderr), where a signal termination uses a negative signal number. launch starts a detached process without captured output. argv is a string list, executed with PATH lookup without a shell.
File-watch callbacks receive (name, change). Pass { name = "entry" } to filter to one directory entry. File reads/listing and writes report failures as nil, message where applicable.
Request only what you need
Inplugin.toml:
$XDG_CONFIG_HOME/otter-shell/plugins.conf:
spawn, even though the plugin requests it. A gated operation raises if permission is missing. Removing the capabilities line allows all declared capabilities; setting it to an empty string grants none.
Policy accepts exact capability names, *, and supported prefixes such as fs:* and env:OTTER_*. An fs:<root> policy entry permits requested readable roots at or beneath it. An fs-write:<root> grant also permits reading within that root.
Filesystem roots can start with ~/ or $XDG_RUNTIME_DIR, $XDG_CACHE_HOME, $XDG_CONFIG_HOME, $XDG_DATA_HOME, or $XDG_STATE_HOME. Unset/relative XDG values fall back to their home-directory defaults, except the runtime directory, which has no fallback. Roots are canonicalized. Directory roots must exist; a single-file root can name a missing file when its parent exists.
Bounded file and process work
Per plugin instance, built-ins allow up to 64 timers, 16 programs, and 16 file watches. Captured process output is bounded to 1 MiB per stream.fs.read returns at most 4 MiB; fs.write accepts at most 1 MiB.
File paths must be absolute or start with ~/. The implementation canonicalizes them and checks opened descriptors through /proc/self/fd against the granted roots. Writes use a temporary file beside the target and rename it into place. New files use mode 0600; replacements retain the existing regular file’s mode. Non-regular targets, including symlinks, are refused.
Native library bindings
Available library modules depend on what the host registers. Examples include:
For theme-aware widgets, read resolved colors and metrics rather than branching on theme names. With
otter.theme registered:
theme.get() when drawing. A failed theme reload retains the previous valid theme and does not notify subscribers. theme.use_preset(name, on_done) requires theme-write; it writes asynchronously and reports (ok, reason) to its callback.
Write a native binding
Native libraries exposebinding.Module from their otr/lua/ tree. The host passes its module list to plugins.Host.init. Use binding.Open.add(name, function, capability) to gate operations. Module.cap can gate an entire module. Duplicate module names and names colliding with built-ins are rejected.
Keep native resources in per-instance generational handle tables. Lua receives checked integer handles, not raw pointers. Implement Module.release(instance) to release subscriptions and resources on reload or unload.
Callbacks must be delivered through the host loop:
- Keep a Lua callback with
binding.keep_callback. - Register native descriptors through
binding.watchwhere needed. - Post scalar callback arguments with
binding.poston the host thread. - Let the plugin host deliver the batch in a guarded call.
defer across a LuaJIT call that can raise; Lua error unwinding skips native cleanup.
UI-script host.Ui has no plugin event loop. Although hosts can register modules with Ui.open_with, bindings that post events need plugins.Host integration.
Source references: otter-lua/otr/services.otr, caps.otr, binding.otr, and handles.otr; native exports live in each library’s otr/lua/ directory.
