Lua Plugins

cliamp uses Lua 5.1 plugins. Plugins can handle playback events, such as scrobbling, notifications, and status-bar output. They can also add visualizers. Each plugin runs in an isolated VM. A plugin crash does not affect other plugins or the player.

Store plugins in ~/.config/cliamp/plugins/. Create the directory:

mkdir -p ~/.config/cliamp/plugins

cliamp runs a plugin only after you approve its exact contents. Existing and manually copied plugins start as untrusted. Approve one with cliamp plugins trust <name>.

Plugin manager

cliamp plugins                          # show help
cliamp plugins list                     # list installed plugins
cliamp plugins install <source>         # install a plugin
cliamp plugins trust <name>             # approve installed plugin contents
cliamp plugins remove <name>            # remove a plugin

The install and trust commands show the source, SHA-256, declared permissions, and implicit file-system and network access before the prompt. In a non-interactive environment, use --yes only after you review the same content independently. cliamp stores approvals in plugins/.trust.json. Editing a plugin changes its hash and disables it until you approve it again. cliamp rejects unknown permission names.

Install sources

Format Example
GitHub user/repo
GitHub with tag user/repo@v1.0
GitLab gitlab:user/repo
GitLab with tag gitlab:user/repo@v1.0
Codeberg codeberg:user/repo
Codeberg with tag codeberg:user/repo@v1.0
Direct URL https://example.com/plugin.lua

Naming convention

Name plugin repositories cliamp-plugin-<name>. Put the <name>.lua entry point in the repository root. cliamp removes the cliamp-plugin- prefix during installation. For example, cliamp-plugin-soap-bubbles, which contains soap-bubbles.lua, installs as soap-bubbles.

cliamp plugins install bjarneo/cliamp-plugin-lastfm
cliamp plugins install bjarneo/cliamp-plugin-lastfm@v1.0
cliamp plugins install gitlab:user/my-visualizer
cliamp plugins install codeberg:user/my-plugin
cliamp plugins install https://example.com/my-plugin.lua
cliamp plugins remove lastfm

Quick start

Now-playing file for Waybar, Polybar, and similar bars

-- ~/.config/cliamp/plugins/now-playing.lua
local p = plugin.register({
    name = "now-playing",
    type = "hook",
    description = "Write now-playing to /tmp for status bars",
})

p:on("track.change", function(track)
    cliamp.fs.write("/tmp/cliamp-now-playing", track.artist .. " - " .. track.title)
end)

p:on("playback.state", function(ev)
    if ev.status == "paused" then
        cliamp.fs.write("/tmp/cliamp-now-playing", "paused")
    end
end)

p:on("app.quit", function()
    cliamp.fs.remove("/tmp/cliamp-now-playing")
end)

Desktop notification on track change

-- ~/.config/cliamp/plugins/notify.lua
local p = plugin.register({
    name = "notify",
    type = "hook",
})

p:on("track.change", function(track)
    local title = track.artist .. " - " .. track.title
    os.execute('notify-send "cliamp" "' .. title .. '"')
end)

os.execute is not available in the sandbox. Use cliamp.http for public HTTP endpoints. cliamp blocks private, loopback, link-local, multicast, and unspecified addresses. For local automation, write to an allowed file for a watcher to read. You can also declare the permission-gated exec capability.

Webhook

-- ~/.config/cliamp/plugins/webhook.lua
local p = plugin.register({
    name = "webhook",
    type = "hook",
})

local url = p:config("url")

p:on("track.change", function(track)
    if not url then return end
    cliamp.http.post(url, {
        json = { title = track.title, artist = track.artist, album = track.album }
    })
end)
# config.toml
[plugins.webhook]
url = "https://example.com/hook"

Plugin structure

Single file

~/.config/cliamp/plugins/myplugin.lua

Directory with init.lua

~/.config/cliamp/plugins/myplugin/
    init.lua
    helpers.lua

The directory name is the plugin name. cliamp loads only init.lua automatically.

Registration

Each plugin must call plugin.register(). cliamp silently skips files that do not call it.

local p = plugin.register({
    name        = "myplugin",           -- required
    type        = "hook",               -- "hook" or "visualizer"
    version     = "1.0.0",             -- optional
    description = "What it does",       -- optional
})

The returned p object provides these methods:

Method Description
p:on(event, callback) Subscribe to a playback event
p:config(key) Read a config value from [plugins.myplugin] in config.toml
p:publish(topic, payload, options) Publish a namespaced event to local IPC subscribers

Plugin event pub/sub

Plugins can publish JSON-compatible values to external programs that connect to Cliamp's owner-only IPC socket. cliamp puts each topic below the installed plugin name. For example, myplugin.lua that publishes "playback" uses the topic plugin.myplugin.playback. The name provides one topic segment. cliamp replaces each character other than a letter, digit, _, or - with _. Therefore, my.plugin.lua uses the plugin.my_plugin.* prefix. Publishing "playback" from that plugin uses plugin.my_plugin.playback. It cannot collide with topics from a plugin named my.

This conversion loses information, so namespaces are unique for a session. If my.plugin.lua and my_plugin.lua are both installed, the first loaded plugin (plugins load in name order) owns the plugin.my_plugin.* prefix. In the other plugin, p:publish() returns nil, err and names the owner. Rename one plugin to publish from both. Subscribers must specify complete topics, not prefixes.

p:publish("playback", {
    status = cliamp.player.state(),
    title = cliamp.track.title(),
}, { retain = true })

With retain = true, Cliamp keeps the latest value in memory and sends it to new subscribers immediately. Cliamp discards retained values when it exits. It does not save event data to disk. Publishing does not block. cliamp disconnects a subscriber that cannot keep up instead of blocking the player.

Open cliamp.sock and send one NDJSON request to subscribe:

{"version":2,"id":"events","method":"subscribe","topics":["plugin.myplugin.playback"]}

After {"version":2,"id":"events","ok":true}, the connection becomes a server-to-client event stream:

{"event":"plugin.myplugin.playback","seq":42,"time":1786685741,"retained":true,"data":{"status":"playing","title":"Track"}}

Subscriptions use exact topic matches and accept at most 32 topics. They are for streaming only. Use another IPC connection for normal commands. Sending more bytes on a subscription closes it. Payloads can be at most 64 KiB. cliamp converts them with the nesting and cycle rules of cliamp.json. Topic segments can contain letters, digits, ., _, and -. p:publish() needs no permission. IPC is local to the same user, and the status command already exposes playback metadata.

Events

Use p:on(event, callback) to subscribe to events. Callbacks run in goroutines and time out after 5 seconds.

Available events

Event Callback argument When
track.change {title, artist, album, genre, year, path, duration, stream} New track starts
track.scrobble Same + {played_secs} Track played >= 50% or >= 4 min
playback.state {status, title, artist, album, path, duration, stream, position} Any playback state change (play, pause, stop, seek, volume, track transition)
player.seek {position, duration} (seconds) A seek completes
player.volume {db} Volume changes
player.eq {bands, preset} An EQ band or preset changes
player.mode {shuffle, repeat} Shuffle toggled or repeat mode cycled
queue.change {count, index, queued} Playlist or play-next queue changes
app.start {} After all plugins loaded
app.quit {} Before shutdown

In playback.state, status is "playing", "paused", or "stopped". In player.mode, repeat is "Off", "All", or "One", matching cliamp.player.repeat_mode(). In player.eq, bands is an array of 10 dB values.

cliamp sends player.* and queue.change events by comparing state after each UI update. They cover every source, including a keypress, IPC, MPRIS, or another plugin.

Plugin object methods

The object from plugin.register(...) provides these methods in addition to :on() and :config():

p:bind(key, [description,] callback) - keyboard binding (requires permissions = {"keymap"})

local p = plugin.register({
    name = "my-plugin",
    type = "hook",
    permissions = {"keymap"},
})

-- Listed in the Ctrl+K overlay under "— plugins —":
p:bind("x", "Extract chapters", function(key) ... end)

-- Not listed (hidden binding):
p:bind("ctrl+e", function(key) ... end)

Returns true on success. Returns false, reason when cliamp's core UI owns the key or the plugin lacks the keymap permission. Pass a description as the middle argument to show the binding in the Ctrl+K keymap overlay. Omit it for an internal-only binding.

Use Bubbletea's msg.String() form for key strings: lowercase letters and the ctrl+, shift+, or alt+ prefixes. For example: "x", "ctrl+e", and "shift+f1". Key strings are case-insensitive.

Plugin keys work only in the main view. Overlays such as the file browser, theme picker, and keymap capture their own input. The core reserves every key in docs/keybindings.md. Trying to bind one logs a warning and returns false.

Use p:unbind(key) to release a binding.

p:command(name, callback) - shell-invokable command

p:command("run", function(args)
    -- args is an array of strings passed after the command name
    return "done: " .. args[1]
end)

The callback can return a string. The CLI client prints it. Invoke commands from the shell with cliamp plugins call <plugin-name> <command> [args...]. cliamp sends the command to the running player over IPC. Commands need no separate permission because the user starts them.

List registered commands with cliamp plugins commands. A command can run for up to 5 minutes before it times out.

Lua API

All APIs are in the global cliamp table.

cliamp.player (read-only)

cliamp.player.state()         --> "playing" | "paused" | "stopped"
cliamp.player.position()      --> number (seconds)
cliamp.player.duration()      --> number (seconds)
cliamp.player.volume()        --> number (dB, -30 to +6)
cliamp.player.speed()         --> number (ratio, 1.0 = normal)
cliamp.player.mono()          --> boolean
cliamp.player.repeat_mode()   --> "Off" | "All" | "One"
cliamp.player.shuffle()       --> boolean
cliamp.player.eq_bands()      --> table of 10 dB values

cliamp.track (read-only)

cliamp.track.title()          --> string
cliamp.track.artist()         --> string
cliamp.track.album()          --> string
cliamp.track.genre()          --> string
cliamp.track.year()           --> number
cliamp.track.track_number()   --> number
cliamp.track.path()           --> string
cliamp.track.is_stream()      --> boolean
cliamp.track.duration_secs()  --> number

cliamp.queue

You can read the playlist without permission. To change it, declare permissions = {"control"}. All indices are 0-based, as in cliamp.queue.current().

-- read (no permission)
cliamp.queue.list()        --> array of {title, artist, album, path, index, queued}
cliamp.queue.count()       --> number of tracks
cliamp.queue.current()     --> 0-based index of the current track

-- mutate (requires "control")
cliamp.queue.add(path)         -- resolve a file/dir/URL and append
cliamp.queue.jump(index)       -- make index current and play it
cliamp.queue.remove(index)     -- remove the track at index
cliamp.queue.move(from, to)    -- reorder a track

add accepts every input that the CLI accepts: a local file or directory, an HTTP stream, an M3U/PLS URL, or a YouTube/yt-dlp URL. cliamp resolves it off the UI thread. A slow URL does not block playback.

cliamp.http

-- GET
local body, status = cliamp.http.get("https://api.example.com/data", {
    headers = { Authorization = "Bearer token" }
})

-- POST with JSON
local body, status = cliamp.http.post("https://api.example.com/scrobble", {
    json = { artist = "Radiohead", track = "Everything In Its Right Place" }
})

-- POST with form body
local body, status = cliamp.http.post(url, {
    headers = { ["Content-Type"] = "application/x-www-form-urlencoded" },
    body = "key=value&foo=bar"
})

The timeout is 5 seconds. The response body limit is 1 MB.

cliamp.fs

cliamp.fs.write(path, content)    -- overwrite file
cliamp.fs.append(path, content)   -- append to file
cliamp.fs.read(path)              --> string (max 1 MB)
cliamp.fs.remove(path)            -- delete file
cliamp.fs.exists(path)            --> boolean
cliamp.fs.mkdir(path)             -- create directory (recursive)
cliamp.fs.listdir(path)           --> {names}, err

You can write only to the system temp directory (/tmp/ on Unix), ~/.config/cliamp/, ~/.local/share/cliamp/, and ~/Music/cliamp/. You can read from any path. On Windows, when HOME is unset, the config directory resolves to %APPDATA%\cliamp.

cliamp.json

local tbl = cliamp.json.decode('{"key": "value"}')
local str = cliamp.json.encode({ key = "value" })

cliamp encodes tables to a depth of 64 levels. A deeper table or a cyclic reference becomes null; it does not fail. p:publish() and cliamp.store use the same conversion.

cliamp.store

This is a persistent key/value store for each plugin. Strings, numbers, booleans, and tables survive restarts. No permission is required. Each plugin can access only its own namespace, so it cannot read another plugin's keys.

cliamp.store.set(key, value)   -- value: string|number|boolean|table
cliamp.store.get(key)          --> value or nil
cliamp.store.delete(key)
cliamp.store.keys()            --> sorted array of keys
cliamp.store.clear()

cliamp stores this data in ~/.local/share/cliamp/plugins/<name>/store.json with owner-only mode (0600). Use it for play counts, offline scrobble queues, resume positions, and saved settings. Do not use it for large data.

local counts = cliamp.store.get("counts") or {}
counts[cliamp.track.path()] = (counts[cliamp.track.path()] or 0) + 1
cliamp.store.set("counts", counts)

cliamp.crypto

cliamp.crypto.md5("hello")                  --> hex string
cliamp.crypto.sha256("hello")               --> hex string
cliamp.crypto.hmac_sha256("secret", "msg")  --> hex string

cliamp.log

cliamp.log.info("loaded successfully")
cliamp.log.warn("missing config key")
cliamp.log.error("request failed: " .. err)
cliamp.log.debug("response: " .. body)

cliamp writes logs to ~/.config/cliamp/plugins.log. Each line has a timestamp and the [plugin-name] prefix.

cliamp.player control (requires permissions)

Plugins that declare permissions = {"control"} can control the player:

local p = plugin.register({
    name = "my-controller",
    type = "hook",
    permissions = {"control"},
})

cliamp.player.next()              -- skip to next track
cliamp.player.prev()              -- go to previous track
cliamp.player.play_pause()        -- toggle play/pause
cliamp.player.stop()              -- stop playback
cliamp.player.set_volume(-5)      -- set volume in dB (-30 to +6)
cliamp.player.set_speed(1.25)     -- set playback speed (0.25 to 2.0)
cliamp.player.seek(30)            -- seek to 30 seconds
cliamp.player.toggle_mono()       -- toggle mono output
cliamp.player.set_eq_preset("Rock") -- switch to built-in preset (sets bands + UI label)
cliamp.player.set_eq_preset("Metal", {6,4,1,-1,-2,2,4,6,6,5}) -- custom preset with bands
cliamp.player.set_eq_band(1, 6)   -- set EQ band 1 to +6 dB (bands 1-10, -12 to +12)

If a plugin does not declare permissions = {"control"}, these functions log a warning and do nothing.

cliamp.notify

cliamp.notify("Song Title")                -- notification with title only
cliamp.notify("Song Title", "Artist Name") -- notification with title and body

This sends a desktop notification through notify-send. It works with mako, dunst, and other notification daemons.

cliamp.exec (requires permissions)

Plugins that declare permissions = {"exec"} can start subprocesses from a configurable binary allowlist. The default allowlist is yt-dlp, ffmpeg. Add binaries in config.toml:

[plugins]
allowed_binaries = "ffprobe, curl"   # merged with defaults
local p = plugin.register({
    name = "my-downloader",
    type = "hook",
    permissions = {"exec"},
})

local handle, err = cliamp.exec.run("yt-dlp", {"--dump-json", url}, {
    on_stdout = function(line) ... end,   -- optional, called per line
    on_stderr = function(line) ... end,   -- optional
    on_exit   = function(code) ... end,   -- optional, fires exactly once
    cwd       = "/tmp/work",              -- optional; must be in write allowlist
    timeout   = 300,                       -- optional seconds, hard cap 1800
})

handle:cancel()                           -- terminate the process
handle:alive()                            -- --> boolean

Safety rules:

Without permissions = {"exec"}, cliamp.exec.run returns nil, "exec permission required".

cliamp.message

cliamp.message("Scrobble Sent")        -- show for default duration
cliamp.message("Syncing Library", 5)   -- show for 5 seconds

This shows a temporary message in the status bar at the bottom of the UI. The duration is optional and uses seconds. Omit it to use the default TTL. cliamp limits durations above 60 seconds.

cliamp.sleep

cliamp.sleep(2.5)  -- block for 2.5 seconds (max 10)

This blocks the plugin's Lua VM. Other hooks for the same plugin wait until the sleep ends. Use cliamp.timer.after() for a non-blocking delay.

cliamp.timer

-- Run once after 5 seconds
local id = cliamp.timer.after(5.0, function()
    cliamp.log.info("timer fired")
end)

-- Run every 30 seconds
local id = cliamp.timer.every(30.0, function()
    -- periodic task
end)

-- Cancel
cliamp.timer.cancel(id)

Configuration

Put plugin-specific configuration in config.toml under [plugins.<name>]:

[plugins.lastfm]
api_key = "abc123"
api_secret = "secret"
session_key = "sk-xxx"

[plugins.webhook]
url = "https://example.com/hook"

Read it in Lua:

local api_key = p:config("api_key")   --> "abc123" or nil

Disabling plugins

Disable one plugin:

[plugins.webhook]
enabled = false

To disable several plugins:

[plugins]
disabled = webhook, discord-rpc

Visualizer plugins

Plugins with type = "visualizer" add visualizer modes to the v key cycle with the built-in modes.

-- ~/.config/cliamp/plugins/simple-bars.lua
local p = plugin.register({
    name = "simple-bars",
    type = "visualizer",
})

-- Called every frame (~20 FPS during playback).
-- bands: table of 10 numbers (0.0-1.0), indices 1-10
-- frame: monotonic counter
-- rows: available terminal rows (changes in fullscreen mode)
-- cols: available terminal columns
-- Must return a multi-line string.
function p:render(bands, frame, rows, cols)
    local lines = {}
    local chars = { " ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█" }

    for row = 5, 1, -1 do
        local line = ""
        for i = 1, 10 do
            local level = bands[i]
            local threshold = (row - 1) / 5
            if level > threshold then
                line = line .. "██████ "
            else
                line = line .. "       "
            end
        end
        table.insert(lines, line)
    end

    return table.concat(lines, "\n")
end

Visualizer callbacks

Callback Signature Required
p:render(bands, frame, rows, cols) Returns string Yes
p:init(rows, cols) Setup when selected No
p:destroy() Cleanup when deselected No

render has a 10 ms limit for each frame. If it exceeds the limit, cliamp reuses the previous frame to prevent UI delay.

Sandbox

For security, plugins have restricted access. The sandbox removes unsafe standard-library functions and limits file-system access.

Removed functions

Removed Replacement
os.execute, os.remove, os.rename, os.exit, os.setlocale, os.tmpname Use cliamp.fs, cliamp.http, or permission-gated cliamp.exec
io module (all of it) Use cliamp.fs
dofile, loadfile, load, loadstring, require, module, package, debug Not available

Kept functions

You can use os.time(), os.date(), os.clock(), and os.getenv().

File system restrictions

Reads: You can read from any path, up to 1 MB for each read.

Writes/removes/mkdir work only in these directories:

Writing outside these directories raises a Lua error. cliamp blocks directory traversal (..).

Isolation

Debugging

Check ~/.config/cliamp/plugins.log for plugin output and errors:

2025-03-29 14:30:01 [now-playing] info: Now playing: Everything In Its Right Place
2025-03-29 14:30:01 [webhook] error: track.change handler error: connection refused

Use cliamp.log.debug() during development.