Writing a Module
A module is the way you extend luvus. It’s a plain directory with one
manifest file (luvus-module.toml) that declares commands, the argv arrays
luvus runs as subprocesses. There’s no SDK, no scripting runtime, and no
language requirement: if it can be executed and read environment variables, it
can be a luvus module: a Bash script, a Python file, a Lua or Rust binary, a
Node app, anything your machine can run. A command does its work by reading the
injected LUVUS_* context and, when it needs to change the workspace, by
calling the same luvus CLI you use by hand. The luvus CLI is the module
API.
A module can reach every surface the app has:
| Surface | Declared as | What you get |
|---|---|---|
| Docks | [[docks]] | A panel in either sidebar, with clickable rows |
| Luvus Bar | [[bars]] | Compact structured status, progress, badges, and actions in the top or bottom chrome |
| Panes | [[panes]] | A real terminal pane running your program |
| Tabs | any command | Create, focus, name, and close tabs |
| Right-click | contexts on an action | Rows in the pane / workspace / agent menus |
| Settings | [[settings]] | Typed controls in Settings → Modules, no UI code |
| Events | [[events]] | Run when agents, panes, tabs, or tasks change |
| Startup | [[startup]] | Restore your state when the session comes up |
Author with your AI agent
Section titled “Author with your AI agent”If you build modules with a coding agent (Claude Code, or any agent that reads
skills), luvus ships a module-authoring skill. It teaches the agent this whole
guide: the manifest tables, the LUVUS_* environment, the callback methods, and
the common recipes. With it installed you can just say “add a sidebar dock listing
my git branches” and the agent already knows the manifest and the socket API.
Install it into your agent’s skills folder (the block below has a copy button), then start your agent inside your module’s directory:
mkdir -p ~/.claude/skills/luvus-modulecurl -fsSL https://raw.githubusercontent.com/RizRiyz/luvus/main/skills/luvus-module/SKILL.md \ -o ~/.claude/skills/luvus-module/SKILL.mdPrefer to read or copy it by hand? View the skill on GitHub (use the raw view’s copy button).
A module in five minutes
Section titled “A module in five minutes”my-module/├── luvus-module.toml└── refresh.shid = "you.hello" # required, globally unique-ishname = "Hello" # requiredversion = "0.1.0" # requiredmin_luvus_version = "0.8.3" # required
[[actions]]id = "refresh" # local id (no dots)title = "Say hello"command = ["sh", "refresh.sh"] # argv, run without a shell#!/bin/shecho "hello from $LUVUS_MODULE_ID"echo "context: $LUVUS_MODULE_CONTEXT_JSON"Register and run it:
luvus module link ./my-module # → { "id": "you.hello" }luvus module run you.hello refresh # → { "log_id": 1 }luvus module log # status + captured outputmodule run is fire-and-forget: it returns a log_id immediately and the
command runs in the background. Read stdout/stderr and the exit status back
with module log.
The manifest
Section titled “The manifest”Top level
Section titled “Top level”| Key | Required | Notes |
|---|---|---|
id | ✓ | [a-z0-9:._-], ≤120 chars. Dots allowed (e.g. you.git-status). |
name | ✓ | Human-readable, non-empty. |
version | ✓ | Your module’s version. |
min_luvus_version | ✓ | Install is refused if newer than the host. Use 0.8.3 if you use right-click menus, settings, startup hooks, or dock row values. |
description | One-line summary. | |
platforms | e.g. ["macos", "linux"]. Omit for all. [] is an error. |
The canonical filename is luvus-module.toml. During the 0.11.x migration
window, Luvus also loads bohay-module.toml and accepts min_bohay_version as
an alias. If both files exist, luvus-module.toml wins and Luvus warns when
their contents differ. New modules should use only the Luvus names.
Every entry below (build, startup, actions, events, panes) also
accepts its own platforms list, which overrides the top-level one. That’s how
one manifest ships a pbcopy action on macOS and a clip.exe action on
Windows without either showing up on the wrong machine.
[[actions]]: on-demand commands
Section titled “[[actions]]: on-demand commands”[[actions]]id = "commit" # local id: [a-z0-9:_-], ≤120, NO dotstitle = "Commit staged changes"contexts = ["pane"] # optional: also offer it on right-clickplatforms = ["macos", "linux"] # optional: narrower than the top levelcommand = ["bun", "run", "commit.ts"]Invoke with luvus module run <module-id> <action-id>. The qualified id is
{module-id}.{action-id}.
contexts: put an action in a right-click menu
Section titled “contexts: put an action in a right-click menu”An action with contexts is offered in luvus’s own context menus, below a
divider under the built-in items. This is the shortest path from a shell script
to something that feels native.
| Context | Where it appears | What the action receives |
|---|---|---|
pane | Right-click inside any pane | The clicked pane, plus its selected text |
workspace | Right-click a WORKSPACES row | The clicked node (node is a legacy alias) |
agent | Right-click a live agent in AGENTS | That agent’s pane |
tab | Reserved for a future tab menu | — |
[[actions]]id = "blame"title = "Blame this selection"contexts = ["pane"]command = ["python3", "blame.py"]The target is what you clicked, not what happened to be focused: right-click
a background node and LUVUS_WORKSPACE_CWD is that node’s folder. Actions
without contexts never appear in a menu, which is right for anything meant
only for the CLI, a dock row, or another module.
contexts adds items to luvus’s menus. To give your own dock rows a
right-click menu, see a row’s own right-click menu
further down: those rows are your data, so you declare the menu rather than
appending to one.
invocation_source in the context tells you where a run came from
(menu:pane, menu:workspace, menu:agent, dock, startup, event,
cli, api), so one script can serve several entry points.
[[settings]]: user-editable configuration
Section titled “[[settings]]: user-editable configuration”Declare settings and luvus renders them in Settings → Modules, indented under your module, and hands the resolved values to every command you run. You never write a settings UI, and you never parse a config file.
[[settings]]key = "token" # local id: [a-z0-9:_-], NO dotstitle = "API token"type = "string"secret = true # masked in the UI and in the edit prompt
[[settings]]key = "limit"title = "Rows to show"type = "number"default = 20min = 1max = 99step = 1
[[settings]]key = "mode"title = "Mode"type = "enum"options = ["fast", "thorough"]default = "fast"
[[settings]]key = "loud"title = "Play a sound"type = "bool"default = falsetype | Control | Notes |
|---|---|---|
bool | A toggle | default is false when omitted |
string | An inline prompt on ⏎ | secret = true echoes bullets |
number | ‹ › steppers | Clamped to min/max; step defaults to 1 |
enum | ‹ › through options | Wraps; defaults to the first option |
Values reach your command two ways, so no language needs a JSON parser:
echo "$LUVUS_SETTING_TOKEN" # one flat var per key, UPPER_SNAKEecho "$LUVUS_SETTING_MODE"echo "$LUVUS_MODULE_SETTINGS_JSON" # {"token":"...","limit":20,...}luvus validates on write: a number is clamped into range, an enum choice
outside options is refused, and a type that drifted between module versions
falls back to the declared default rather than erroring. Values live in
$LUVUS_MODULE_CONFIG_DIR/settings.json, so they survive a reinstall and you
can read the file directly if you prefer.
Read and write them from a script or the CLI too:
luvus module settings you.ci # list keys, types, current valuesluvus module settings you.ci limit # read oneluvus module settings you.ci limit 30 # write one[[startup]]: run once when the session is ready
Section titled “[[startup]]: run once when the session is ready”[[startup]]command = ["sh", "refresh.sh"]Startup commands run for each enabled module once the session has been restored and the API socket is listening. They also run when a module is linked or re-enabled, so a module never sits idle waiting for a restart.
This is how a module with a dock survives a restart: dock contents are cached in memory and deliberately not persisted, so the startup hook is where you repaint them. Keep hooks one-shot: restore your state, call what you need, exit. They are not supervised daemons, and a failure is logged without stopping the server.
Startup commands get the normal environment plus LUVUS_MODULE_EVENT=startup.
[[bars]]: compact chrome widgets
Section titled “[[bars]]: compact chrome widgets”A bar declaration reserves one module-owned identity. It does not draw until a
startup/event/action script publishes structured content with luvus bar push.
Users can place each declaration at Top, Bottom, or Off in
Settings → Layout → Luvus Bar. The declaration list itself stays stable
while placement changes.
For the user-facing placement, installation, width, and troubleshooting flow, see the dedicated Luvus Bar guide.
[[bars]]id = "ci"title = "CI status"region = "top-right" # top-right | bottom-rightpriority = 60 # lower priority compresses/overflows first
[[startup]]command = ["sh", "refresh-ci.sh"]
[[actions]]id = "details"title = "CI details"command = ["sh", "details.sh"]"${LUVUS_BIN_PATH:-luvus}" bar push \ --id ci --region top-right \ --content '[{"type":"text","text":"CI"},{"type":"state","state":"done","label":"passing"},{"type":"badge","text":"2","action":"details","value":"run-1842"}]' \ --compact-content '[{"type":"text","text":"CI"},{"type":"state","state":"done","action":"details","value":"run-1842"}]'Available segment types are text, symbol, state, badge, progress,
spacer, and separator. Semantic tones (normal, muted, accent,
success, warning, error) adapt to the active theme. Content is one row;
use a pane for arbitrary terminal UI or a dock for a multi-row list. Raw ANSI,
custom colors, and module-controlled rendering are rejected.
An actionable segment names an action from the same manifest. Its command gets
LUVUS_MODULE_BAR_ID, LUVUS_MODULE_BAR_SEGMENT, and
LUVUS_MODULE_BAR_VALUE. Live content is in-memory, so restore it from a
one-shot startup hook. A repeated push is an atomic replacement, not an append.
For transient status, use the bounded notification lane instead of repeatedly replacing a durable widget:
"${LUVUS_BIN_PATH:-luvus}" ui notification push \ --text "CI failed" --level error --ttl-ms 6000 --dedupe-key ci-mainSee the complete ci-bar example on GitHub
and the Socket API reference.
[[events]]: react to lifecycle events
Section titled “[[events]]: react to lifecycle events”[[events]]on = "pane.agent_status_changed"command = ["sh", "notify.sh"]Your command runs with LUVUS_MODULE_EVENT (the name) and
LUVUS_MODULE_EVENT_JSON (the payload).
| Group | Events |
|---|---|
| Workspaces | workspace.created · workspace.closed (legacy aliases node.created / node.closed) |
| Tabs | tab.created · tab.closed |
| Panes | pane.created · pane.closed · pane.agent_status_changed |
| Agents | agent.hook |
| Orchestration | task.added · task.claimed · task.started · task.updated · task.ready · task.done · task.released · task.deleted · task.merged · task.merge_conflict · task.needs_compaction · task.gate_running · task.gate_passed · task.gate_failed |
| Leases | lease.acquired · lease.released |
pane.agent_status_changed is the highest-value hook — notify when status
becomes blocked or done. Its payload is the richest one:
{ "pane": "4", "status": "blocked", "agent": "claude", "cwd": "/Users/you/code/app", "project": "app", "branch": "main" }A hook declared on node.created / node.closed still fires for the
workspace.* events, so older modules keep working.
[[panes]]: long-lived UI in a real pane
Section titled “[[panes]]: long-lived UI in a real pane”[[panes]]id = "board"title = "Git board"placement = "split" # overlay | split | tabcommand = ["bun", "run", "board.ts"]Open with luvus module pane open <module-id> board. It becomes a real luvus
pane (a TUI, a log tail, anything), runs in the module root with the full
LUVUS_* environment, and is auto-untracked on close. A module pane that’s
open when you detach is re-opened on the next launch.
[[docks]]: a panel in the sidebar
Section titled “[[docks]]: a panel in the sidebar”A dock is a section that lives in one of luvus’s two sidebars (left or right), alongside the built-in Workspaces and Agents lists. It’s the way a plugin puts an always-visible panel (a git status, a CI feed, a notifications list) into the chrome.
[[docks]]id = "you:ci" # local id: [a-z0-9:_-], ≤120, NO dotstitle = "CI" # the dock headerplacement = "sidebar.right" # default side: sidebar.left | sidebar.rightUnlike a pane, your module doesn’t draw a dock. Luvus owns the rendering
and your module just pushes rows. That keeps docks fast (nothing runs per
frame), visually consistent with the rest of the UI, and freely relocatable:
from Settings → Layout, the user can move any dock between the left sidebar,
the right sidebar, or turn it Off with [Left] [Right] [Off] buttons.
Push rows with the CLI:
"$LUVUS_BIN_PATH" ui dock push --id you:ci --title CI --rows '[ {"text": "build passing", "dot": "done", "action": "open-ci", "value": "1421"}, {"text": "3 checks", "dot": "working"}]'or pipe the JSON array on stdin (handy when you build it with jq):
printf '%s' "$rows_json" | "$LUVUS_BIN_PATH" ui dock push --id you:ci --title CIEach row is an object:
| Field | Required | Meaning |
|---|---|---|
text | ✓ | The row label. |
dot | A status dot: idle / working / blocked / done (colored like the Agents list). | |
action | A module action id run when the row is clicked. | |
value | An opaque payload for that action, so one action can back every row. | |
menu | Extra actions offered when the row is right-clicked. See below. |
The first push mounts the dock into its placement side automatically (or
wherever the user has since moved it). Later pushes just refresh the rows. Clear
a dock with an empty array: --rows '[]'.
A row with an action is clickable: clicking it runs that action and tells
it which row was hit, so a dock becomes a list of buttons rather than a static
readout.
| Variable | Meaning |
|---|---|
LUVUS_MODULE_ROW_VALUE | The row’s value (falls back to text). |
LUVUS_MODULE_ROW_TEXT | The row’s visible label. |
LUVUS_MODULE_ROW_INDEX | Its position in the dock. |
LUVUS_MODULE_DOCK_ID | Which dock it came from. |
That’s the whole trick behind a clickable branch list: push one row per branch
with "action": "checkout" and "value": "<branch>", then have checkout read
$LUVUS_MODULE_ROW_VALUE.
A row’s own right-click menu
Section titled “A row’s own right-click menu”A row can only do one thing on a left-click. Give it a menu and right-clicking
it opens a context menu of your own:
"$LUVUS_BIN_PATH" ui dock push --id you:boards --title BOARDS --rows '[ { "text": "esp32s3", "dot": "done", "action": "select", "value": "/dev/cu.usbserial-110", "menu": [ {"title": "Flash this board", "action": "flash"}, {"title": "Open monitor", "action": "monitor"}, {"title": "", "action": ""}, {"title": "Erase flash", "action": "erase", "destructive": true} ] }, {"text": "build", "action": "build"}]'| Field | Required | Meaning |
|---|---|---|
title | ✓ | The menu label. |
action | ✓ | The action id to run. Leave it empty for a divider. |
value | A payload for this entry, overriding the row’s value. | |
destructive | Tints the label red, like Close and Delete. |
An entry’s value is what lets one action back a whole menu. The row’s
value says which thing the row is; an entry’s says which variant to run:
{ "text": "build", "action": "run", "value": "build", "menu": [ {"title": "App only", "action": "run", "value": "app"}, {"title": "Bootloader only", "action": "run", "value": "bootloader"} ] }Without it you would need a separate action id per entry, which is the same
row-explosion the row-level value exists to prevent, one level down.
The menu is per row, which is the point: in the example above the board row
offers three actions while the build row below it offers none. Luvus can’t
guess a menu for a row it didn’t create, so you declare it. A row with no menu
has no context menu, exactly as before.
The action receives the same LUVUS_MODULE_ROW_* variables a left-click passes
(with LUVUS_MODULE_ROW_VALUE set to the entry’s value when it has one), plus
LUVUS_MODULE_ACTION_ID telling it which item was picked, so one script can
serve the whole menu:
case "$LUVUS_MODULE_ACTION_ID" in flash) idf.py -p "$LUVUS_MODULE_ROW_VALUE" flash ;; erase) idf.py -p "$LUVUS_MODULE_ROW_VALUE" erase-flash ;;esacThe row identity is captured when the menu opens, so a refresh that lands while the menu is up can’t make a click hit the wrong row. That matters if you push on a timer.
destructive is only a colour: luvus won’t ask “are you sure” for you. If an
action can’t be undone, open a pane that says what it’s about to do and waits for
a keypress. That’s better than a generic dialog anyway, because it can name the
port it’s about to erase.
Declared docks show up in the registry in Settings → Layout even before they push anything, so the user can place them straight away. Disabling or unlinking your module removes its docks.
A dock that stays fresh
Section titled “A dock that stays fresh”Docks are event-driven and cached, so luvus never polls your module. Push when
your data changes: from an [[events]] hook, a timer, or a long-lived
[[panes]] process. A live git panel refreshed whenever an agent’s status
changes:
[[docks]]id = "you:git"title = "Git"placement = "sidebar.right"
[[events]]on = "pane.agent_status_changed"command = ["sh", "push-git.sh"]#!/bin/sh# push-git.sh: build the rows with jq and push themcwd="$LUVUS_WORKSPACE_CWD" # no JSON parsing needed for the common idsbranch=$(git -C "$cwd" branch --show-current)dirty=$(git -C "$cwd" status --porcelain | wc -l | tr -d ' ')rows=$(jq -n --arg b "$branch" --arg d "$dirty" '[ {text: ("on " + $b), dot: "idle"}, {text: ($d + " changed"), dot: (if $d == "0" then "done" else "working" end)}]')printf '%s' "$rows" | "$LUVUS_BIN_PATH" ui dock push --id you:git --title GitThat’s a live git dock in the sidebar, kept current with zero per-frame cost.
[[build]]: install-time setup
Section titled “[[build]]: install-time setup”[[build]]command = ["bun", "install"]Runs at git-install time with a scrubbed environment (no LUVUS_*, no
socket access). For local link you run your own setup.
Validation
Section titled “Validation”A manifest is rejected if: any command is empty (or contains an empty
string) · min_luvus_version is newer than the host · an id uses characters
outside its set, or a local id contains a dot · two actions/panes/docks/settings
share an id · platforms = [] at any level · an action names a contexts value
that isn’t pane / workspace / node / agent / tab · an enum setting
has no options, or a setting’s min is above its max.
Failing loudly on a typo’d context matters: the alternative is a menu entry that silently never appears.
How a command runs
Section titled “How a command runs”- luvus builds the environment and a context snapshot of the focused workspace/tab/pane.
- Your
commandspawns as a subprocess, cwd = the module root. - stdout and stderr are captured (64 KiB each), the exit is recorded:
running→succeeded/failed.
Caps: at most 32 module commands in flight, and the 200 most recent log entries are kept. Commands are isolated processes, so a crash or hang never takes down luvus.
Environment variables
Section titled “Environment variables”Identity
| Variable | Meaning |
|---|---|
LUVUS_ENV | Always 1, meaning you’re running under luvus. |
LUVUS_MODULE_ID | Your module’s id. |
LUVUS_MODULE_VERSION | Your module’s declared version. |
LUVUS_MODULE_ROOT | The module directory (also the cwd). Read-only by convention. |
LUVUS_MODULE_CONFIG_DIR | Durable, user-owned config/secrets dir (created for you). |
LUVUS_MODULE_STATE_DIR | Durable state/cache dir (created for you). |
LUVUS_SOCKET_PATH | The control socket (the CLI finds it automatically). |
LUVUS_BIN_PATH | Absolute path to the luvus binary. Use this to call back. |
Context — the same snapshot twice: flattened for shells, whole for everyone else.
| Variable | Meaning |
|---|---|
LUVUS_MODULE_CONTEXT_JSON | The full snapshot (below). |
LUVUS_WORKSPACE_ID / LUVUS_WORKSPACE_CWD | The target node. |
LUVUS_TAB_INDEX | Its active tab, 1-based. |
LUVUS_PANE_ID / LUVUS_PANE_CWD | The target pane. |
LUVUS_PANE_AGENT / LUVUS_PANE_STATUS | What’s running there and its state. |
Per-invocation
| Variable | Set for |
|---|---|
LUVUS_MODULE_ACTION_ID | Action commands. |
LUVUS_MODULE_ENTRYPOINT_ID | Pane commands. |
LUVUS_MODULE_EVENT | Event hooks (the name) and startup hooks (startup). |
LUVUS_MODULE_EVENT_JSON | Event hooks (the payload). |
LUVUS_MODULE_ROW_* | Actions run from a clicked dock row. |
LUVUS_SETTING_<KEY> / LUVUS_MODULE_SETTINGS_JSON | Every command, when you declare [[settings]]. |
Persist data in the state/config dirs, never in the module root, because for git-installed modules it’s a managed checkout.
The context blob
Section titled “The context blob”{ "workspace": { "id": "0", "name": "sudos", "cwd": "/Users/you/code/sudos", "branch": "main" }, "node": { "id": "0", "name": "sudos", "cwd": "/Users/you/code/sudos", "branch": "main" }, "tab": { "index": "1", "name": "core" }, "pane": { "id": "4", "cwd": "/Users/you/code/sudos", "agent": "claude", "status": "working" }, "selection": "the text highlighted in the pane, when there is any", "invocation_source": "menu:pane", "correlation_id": "c7"}node is a legacy alias of workspace; both are always present. status is
idle / working / blocked / done / unknown. For a right-click the
workspace and pane describe what you clicked; everywhere else they describe
what’s focused.
Most scripts want the flat vars, which need no parser at all:
cd "$LUVUS_WORKSPACE_CWD" && git status --shortReach for the blob when you want selection, invocation_source, or the tab
name:
selected=$(printf '%s' "$LUVUS_MODULE_CONTEXT_JSON" | jq -r .selection)Calling back into luvus
Section titled “Calling back into luvus”"$LUVUS_BIN_PATH" pane run "git pull" # run a command in the focused pane"$LUVUS_BIN_PATH" pane split --down # change the layout"$LUVUS_BIN_PATH" workspace list # inspect (JSON to stdout)"$LUVUS_BIN_PATH" tab rename review # name the current tab"$LUVUS_BIN_PATH" ui toast "build passing" # flash a one-line message"$LUVUS_BIN_PATH" ui dock push --id you:ci --rows '[...]' # feed your sidebar dockAnything in luvus help all is available: panes, tabs, workspaces, worktrees, the
git tab, the orchestration board, agents, and the UI chrome. Your command reads
context from the environment and effects change through the CLI. There is no
separate module API and no restricted command set.
Complete examples
Section titled “Complete examples”Three whole modules, one per language. Each is short enough to read in full and
covers a different part of the surface. They also ship in the repo under
examples/modules/
if you’d rather link one directly:
luvus module link ./examples/modules/branch-dock| Example | Language | Shows |
|---|---|---|
| Branch dock | Bash | A dock, clickable rows, a startup hook, number + enum settings |
| Agent ping | Python | An event hook, a secret setting, an agent right-click action |
| Scratch pane | Node | A pane entrypoint, the selection, tab renaming |
Example 1: a clickable branch dock (Bash)
Section titled “Example 1: a clickable branch dock (Bash)”A sidebar dock listing the node’s git branches. Click one to check it out. It repaints on startup, so it survives a restart.
branch-dock/├── luvus-module.toml├── refresh.sh└── checkout.shid = "example.branch-dock"name = "Branch Dock"version = "0.1.0"min_luvus_version = "0.8.3"description = "The active node's git branches, in the sidebar."platforms = ["macos", "linux"]
[[docks]]id = "branches" # local id: no dotstitle = "BRANCHES"placement = "sidebar.left"
[[startup]] # repaint the dock after a restartcommand = ["sh", "refresh.sh"]
[[events]] # and keep it honest as the user moves aroundon = "workspace.created"command = ["sh", "refresh.sh"]
[[actions]] # right-click a WORKSPACES row to refreshid = "refresh"title = "Refresh branches"contexts = ["workspace"]command = ["sh", "refresh.sh"]
[[actions]] # invoked by clicking a dock rowid = "checkout"title = "Check out branch"command = ["sh", "checkout.sh"]
[[settings]]key = "limit"title = "Branches to show"type = "number"default = 8min = 1max = 30
[[settings]]key = "sort"title = "Sort by"type = "enum"options = ["recent", "name"]default = "recent"#!/bin/sh# refresh.sh -- build the rows and push them.set -euluvus="${LUVUS_BIN_PATH:-luvus}"repo="${LUVUS_WORKSPACE_CWD:-$PWD}"limit="${LUVUS_SETTING_LIMIT:-8}"sort="${LUVUS_SETTING_SORT:-recent}"
if ! git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then "$luvus" ui dock push --id branches --title BRANCHES \ --rows '[{"text":"not a git repo"}]' exit 0fi
case "$sort" in name) order='refname' ;; *) order='-committerdate' ;;esaccurrent=$(git -C "$repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')
# `value` is the payload the row hands to its action when clicked -- that is# what lets one `checkout` action serve every row.rows=$(git -C "$repo" for-each-ref --format='%(refname:short)' \ --sort="$order" --count="$limit" refs/heads/ | while IFS= read -r branch; do [ -n "$branch" ] || continue if [ "$branch" = "$current" ]; then dot=working; else dot=idle; fi esc=$(printf '%s' "$branch" | sed 's/\\/\\\\/g; s/"/\\"/g') printf '{"text":"%s","dot":"%s","action":"checkout","value":"%s"},' \ "$esc" "$dot" "$esc" done)
"$luvus" ui dock push --id branches --title BRANCHES --rows "[${rows%,}]"#!/bin/sh# checkout.sh -- the clicked row arrives in LUVUS_MODULE_ROW_VALUE.set -euluvus="${LUVUS_BIN_PATH:-luvus}"repo="${LUVUS_WORKSPACE_CWD:-$PWD}"branch="${LUVUS_MODULE_ROW_VALUE:-}"
if [ -z "$branch" ]; then "$luvus" ui toast "no branch selected" exit 1fi
if git -C "$repo" checkout "$branch" >/dev/null 2>&1; then "$luvus" ui toast "switched to $branch" sh "$(dirname "$0")/refresh.sh" # repaint so the current-branch dot moveselse "$luvus" ui toast "cannot switch to $branch (uncommitted changes?)" exit 1fiExample 2: notify when an agent needs you (Python)
Section titled “Example 2: notify when an agent needs you (Python)”Posts a webhook when an agent goes blocked or finishes, with the URL stored as a secret setting so it never renders in the clear.
id = "example.agent-ping"name = "Agent Ping"version = "0.1.0"min_luvus_version = "0.8.3"
[[events]]on = "pane.agent_status_changed"command = ["python3", "ping.py"]
[[actions]] # right-click a live agent in the AGENTS listid = "ping-now"title = "Send a ping"contexts = ["agent"]command = ["python3", "ping.py", "--force"]
[[settings]]key = "webhook"title = "Webhook URL"type = "string"secret = true # shown as bullets, never echoed
[[settings]]key = "notify-on"title = "Notify on"type = "enum"options = ["blocked", "done", "both"]default = "blocked"#!/usr/bin/env python3import json, os, subprocess, sys, urllib.request
LUVUS = os.environ.get("LUVUS_BIN_PATH", "luvus")
def luvus(*args): """Call back into luvus, ignoring failures: a module must never wedge the UI.""" try: subprocess.run([LUVUS, *args], check=False, capture_output=True, timeout=10) except (OSError, subprocess.SubprocessError): pass
forced = "--force" in sys.argvwebhook = os.environ.get("LUVUS_SETTING_WEBHOOK", "").strip()# `notify-on` becomes LUVUS_SETTING_NOTIFY_ON: upper-cased, dashes to underscores.notify_on = os.environ.get("LUVUS_SETTING_NOTIFY_ON", "blocked")
agent = os.environ.get("LUVUS_PANE_AGENT") or "agent"status = os.environ.get("LUVUS_PANE_STATUS") or "unknown"
# An event hook prefers the event payload: it describes the pane that actually# changed, not the one in focus.raw = os.environ.get("LUVUS_MODULE_EVENT_JSON")if raw: event = json.loads(raw) agent = event.get("agent") or agent status = event.get("status") or status
# A right-click always pings; an event only pings for the chosen states.if not forced: wanted = {"both": {"blocked", "done"}}.get(notify_on, {notify_on}) if status not in wanted: raise SystemExit(0)
where = os.path.basename(os.environ.get("LUVUS_WORKSPACE_CWD", "")) or "luvus"message = f"{agent} is {status} in {where}"luvus("ui", "toast", message)
if webhook: req = urllib.request.Request( webhook, data=json.dumps({"text": message}).encode(), headers={"Content-Type": "application/json"}, ) try: urllib.request.urlopen(req, timeout=10).close() except OSError as err: # stderr lands in `luvus module log`, which is where you debug a module. print(f"webhook failed: {err}", file=sys.stderr) raise SystemExit(1)Example 3: a scratch pane from a right-click (Node)
Section titled “Example 3: a scratch pane from a right-click (Node)”Opens a real pane running your own program, and stashes the pane’s selected text into a notes file.
id = "example.scratch-pane"name = "Scratch Pane"version = "0.1.0"min_luvus_version = "0.8.3"
[[panes]]id = "notes"title = "Notes"placement = "split"command = ["node", "notes.js"]
[[actions]]id = "open-notes"title = "Open notes beside this"contexts = ["pane"]command = ["node", "open.js"]
[[actions]]id = "stash-selection"title = "Send selection to notes"contexts = ["pane"]command = ["node", "stash.js"]
[[settings]]key = "name-the-tab"title = "Rename the tab to Notes"type = "bool"default = true// open.js -- open the pane entrypoint, then name the tab.const { spawnSync } = require("node:child_process");const luvus = process.env.LUVUS_BIN_PATH ?? "luvus";const run = (...args) => spawnSync(luvus, args, { encoding: "utf8" });
// A module pane is a normal luvus pane once it opens: focus, split, close,// and the layout APIs all work on it.run("module", "pane", "open", process.env.LUVUS_MODULE_ID, "notes", "--placement", "split");
if (process.env.LUVUS_SETTING_NAME_THE_TAB === "true") { const tab = process.env.LUVUS_TAB_INDEX ?? ""; run("tab", "rename", "notes", ...(tab ? ["--tab", tab] : []));}run("ui", "toast", "notes opened");// stash.js -- append the pane's selection to a notes file.const fs = require("node:fs");const path = require("node:path");const { spawnSync } = require("node:child_process");
const luvus = process.env.LUVUS_BIN_PATH ?? "luvus";const toast = (t) => spawnSync(luvus, ["ui", "toast", t], { encoding: "utf8" });
// The selection is one of the fields only the context blob carries.const context = JSON.parse(process.env.LUVUS_MODULE_CONTEXT_JSON ?? "{}");const selection = (context.selection ?? "").trim();if (!selection) { toast("select some text first"); process.exit(0);}
// Durable state goes in the state dir, never the module root: for a// git-installed module that root is a managed checkout a reinstall replaces.const stateDir = process.env.LUVUS_MODULE_STATE_DIR ?? process.cwd();fs.mkdirSync(stateDir, { recursive: true });fs.appendFileSync( path.join(stateDir, "notes.md"), `\n## from ${context.pane?.cwd ?? ""}\n\n${selection}\n`,);toast(`saved ${selection.split("\n").length} line(s) to notes`);The notes pane itself is any program that reads stdin and writes stdout —
a REPL, a log tail, a TUI. luvus closes the pane when the process exits.
Distribution & discovery
Section titled “Distribution & discovery”Share a module as a public Git repo (one repo can hold several modules in
subdirectories) and tag it with the luvus-module GitHub topic:
luvus module search # most-starred modules in the topicluvus module install owner/repo # whole repo is the moduleluvus module install owner/repo/path/to # module in a subdirectoryluvus module install owner/repo --ref v1 # pin a branch / tag / commitInstall does a shallow clone, shows every command the module declares and
asks to proceed (--yes for CI), runs [[build]] in a scrubbed
environment, verifies the manifest didn’t change during the build, and pins
the checkout to the installed commit. There’s intentionally no central
registry: publishing is pushing a public repo.
Once the topic is on your repo, it shows up in luvus module search and on the
module index with no further submission. Two things make a listing
land well, because they’re what people see before they trust you:
- The repo description is the card text on the index. One line, what it adds, not how it works.
- The README should show the thing: a screenshot of the dock or menu entry,
the tools it needs (
jq,node, …), and amin_luvus_versionyou’ve actually tested against.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Fix |
|---|---|
link → invalid manifest | Check TOML syntax. Every command must be a non-empty array of non-empty strings. |
Listed but runnable: false | Disabled, platform-gated, or a manifest load warning (see module list). |
| Action is ambiguous | Two modules expose the id, so qualify it: module run <module-id> <action>. |
| No output in the log | The entry stays running until the command exits. Output is capped at 64 KiB. |
| Callbacks do nothing | $LUVUS_BIN_PATH needs the luvus server for your session to be running. |
| Action missing from a menu | Check its contexts, that the module is enabled, and that no platforms gate excludes this OS. |
| Dock is empty after a restart | Push its rows from a [[startup]] hook. Dock contents are cached, not persisted. |
| Settings row not showing | Settings appear only under an enabled module. Disabled modules collapse. |
$LUVUS_SETTING_* is empty | The key is upper-cased with - and : turned into _: notify-on → LUVUS_SETTING_NOTIFY_ON. |