Skip to content

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:

SurfaceDeclared asWhat 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
Tabsany commandCreate, focus, name, and close tabs
Right-clickcontexts on an actionRows 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

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:

Terminal window
mkdir -p ~/.claude/skills/luvus-module
curl -fsSL https://raw.githubusercontent.com/RizRiyz/luvus/main/skills/luvus-module/SKILL.md \
-o ~/.claude/skills/luvus-module/SKILL.md

Prefer to read or copy it by hand? View the skill on GitHub (use the raw view’s copy button).

my-module/
├── luvus-module.toml
└── refresh.sh
luvus-module.toml
id = "you.hello" # required, globally unique-ish
name = "Hello" # required
version = "0.1.0" # required
min_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
refresh.sh
#!/bin/sh
echo "hello from $LUVUS_MODULE_ID"
echo "context: $LUVUS_MODULE_CONTEXT_JSON"

Register and run it:

Terminal window
luvus module link ./my-module # → { "id": "you.hello" }
luvus module run you.hello refresh # → { "log_id": 1 }
luvus module log # status + captured output

module 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.

KeyRequiredNotes
id[a-z0-9:._-], ≤120 chars. Dots allowed (e.g. you.git-status).
nameHuman-readable, non-empty.
versionYour module’s version.
min_luvus_versionInstall is refused if newer than the host. Use 0.8.3 if you use right-click menus, settings, startup hooks, or dock row values.
descriptionOne-line summary.
platformse.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]]
id = "commit" # local id: [a-z0-9:_-], ≤120, NO dots
title = "Commit staged changes"
contexts = ["pane"] # optional: also offer it on right-click
platforms = ["macos", "linux"] # optional: narrower than the top level
command = ["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.

ContextWhere it appearsWhat the action receives
paneRight-click inside any paneThe clicked pane, plus its selected text
workspaceRight-click a WORKSPACES rowThe clicked node (node is a legacy alias)
agentRight-click a live agent in AGENTSThat agent’s pane
tabReserved 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.

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 dots
title = "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 = 20
min = 1
max = 99
step = 1
[[settings]]
key = "mode"
title = "Mode"
type = "enum"
options = ["fast", "thorough"]
default = "fast"
[[settings]]
key = "loud"
title = "Play a sound"
type = "bool"
default = false
typeControlNotes
boolA toggledefault is false when omitted
stringAn inline prompt on secret = true echoes bullets
number‹ › steppersClamped to min/max; step defaults to 1
enum‹ › through optionsWraps; defaults to the first option

Values reach your command two ways, so no language needs a JSON parser:

Terminal window
echo "$LUVUS_SETTING_TOKEN" # one flat var per key, UPPER_SNAKE
echo "$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:

Terminal window
luvus module settings you.ci # list keys, types, current values
luvus module settings you.ci limit # read one
luvus 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.

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-right
priority = 60 # lower priority compresses/overflows first
[[startup]]
command = ["sh", "refresh-ci.sh"]
[[actions]]
id = "details"
title = "CI details"
command = ["sh", "details.sh"]
Terminal window
"${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:

Terminal window
"${LUVUS_BIN_PATH:-luvus}" ui notification push \
--text "CI failed" --level error --ttl-ms 6000 --dedupe-key ci-main

See the complete ci-bar example on GitHub and the Socket API reference.

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

GroupEvents
Workspacesworkspace.created · workspace.closed (legacy aliases node.created / node.closed)
Tabstab.created · tab.closed
Panespane.created · pane.closed · pane.agent_status_changed
Agentsagent.hook
Orchestrationtask.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
Leaseslease.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]]
id = "board"
title = "Git board"
placement = "split" # overlay | split | tab
command = ["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.

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 dots
title = "CI" # the dock header
placement = "sidebar.right" # default side: sidebar.left | sidebar.right

Unlike 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:

Terminal window
"$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):

Terminal window
printf '%s' "$rows_json" | "$LUVUS_BIN_PATH" ui dock push --id you:ci --title CI

Each row is an object:

FieldRequiredMeaning
textThe row label.
dotA status dot: idle / working / blocked / done (colored like the Agents list).
actionA module action id run when the row is clicked.
valueAn opaque payload for that action, so one action can back every row.
menuExtra 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.

VariableMeaning
LUVUS_MODULE_ROW_VALUEThe row’s value (falls back to text).
LUVUS_MODULE_ROW_TEXTThe row’s visible label.
LUVUS_MODULE_ROW_INDEXIts position in the dock.
LUVUS_MODULE_DOCK_IDWhich 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 can only do one thing on a left-click. Give it a menu and right-clicking it opens a context menu of your own:

Terminal window
"$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"}
]'
FieldRequiredMeaning
titleThe menu label.
actionThe action id to run. Leave it empty for a divider.
valueA payload for this entry, overriding the row’s value.
destructiveTints 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:

Terminal window
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 ;;
esac

The 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.

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 them
cwd="$LUVUS_WORKSPACE_CWD" # no JSON parsing needed for the common ids
branch=$(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 Git

That’s a live git dock in the sidebar, kept current with zero per-frame cost.

[[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.

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.

  1. luvus builds the environment and a context snapshot of the focused workspace/tab/pane.
  2. Your command spawns as a subprocess, cwd = the module root.
  3. stdout and stderr are captured (64 KiB each), the exit is recorded: runningsucceeded / 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.

Identity

VariableMeaning
LUVUS_ENVAlways 1, meaning you’re running under luvus.
LUVUS_MODULE_IDYour module’s id.
LUVUS_MODULE_VERSIONYour module’s declared version.
LUVUS_MODULE_ROOTThe module directory (also the cwd). Read-only by convention.
LUVUS_MODULE_CONFIG_DIRDurable, user-owned config/secrets dir (created for you).
LUVUS_MODULE_STATE_DIRDurable state/cache dir (created for you).
LUVUS_SOCKET_PATHThe control socket (the CLI finds it automatically).
LUVUS_BIN_PATHAbsolute path to the luvus binary. Use this to call back.

Context — the same snapshot twice: flattened for shells, whole for everyone else.

VariableMeaning
LUVUS_MODULE_CONTEXT_JSONThe full snapshot (below).
LUVUS_WORKSPACE_ID / LUVUS_WORKSPACE_CWDThe target node.
LUVUS_TAB_INDEXIts active tab, 1-based.
LUVUS_PANE_ID / LUVUS_PANE_CWDThe target pane.
LUVUS_PANE_AGENT / LUVUS_PANE_STATUSWhat’s running there and its state.

Per-invocation

VariableSet for
LUVUS_MODULE_ACTION_IDAction commands.
LUVUS_MODULE_ENTRYPOINT_IDPane commands.
LUVUS_MODULE_EVENTEvent hooks (the name) and startup hooks (startup).
LUVUS_MODULE_EVENT_JSONEvent hooks (the payload).
LUVUS_MODULE_ROW_*Actions run from a clicked dock row.
LUVUS_SETTING_<KEY> / LUVUS_MODULE_SETTINGS_JSONEvery 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.

{
"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:

Terminal window
cd "$LUVUS_WORKSPACE_CWD" && git status --short

Reach for the blob when you want selection, invocation_source, or the tab name:

Terminal window
selected=$(printf '%s' "$LUVUS_MODULE_CONTEXT_JSON" | jq -r .selection)
Terminal window
"$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 dock

Anything 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.

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:

Terminal window
luvus module link ./examples/modules/branch-dock
ExampleLanguageShows
Branch dockBashA dock, clickable rows, a startup hook, number + enum settings
Agent pingPythonAn event hook, a secret setting, an agent right-click action
Scratch paneNodeA pane entrypoint, the selection, tab renaming

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.sh
luvus-module.toml
id = "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 dots
title = "BRANCHES"
placement = "sidebar.left"
[[startup]] # repaint the dock after a restart
command = ["sh", "refresh.sh"]
[[events]] # and keep it honest as the user moves around
on = "workspace.created"
command = ["sh", "refresh.sh"]
[[actions]] # right-click a WORKSPACES row to refresh
id = "refresh"
title = "Refresh branches"
contexts = ["workspace"]
command = ["sh", "refresh.sh"]
[[actions]] # invoked by clicking a dock row
id = "checkout"
title = "Check out branch"
command = ["sh", "checkout.sh"]
[[settings]]
key = "limit"
title = "Branches to show"
type = "number"
default = 8
min = 1
max = 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 -eu
luvus="${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 0
fi
case "$sort" in
name) order='refname' ;;
*) order='-committerdate' ;;
esac
current=$(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 -eu
luvus="${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 1
fi
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 moves
else
"$luvus" ui toast "cannot switch to $branch (uncommitted changes?)"
exit 1
fi

Example 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.

luvus-module.toml
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 list
id = "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 python3
import 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.argv
webhook = 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.

luvus-module.toml
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.

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:

Terminal window
luvus module search # most-starred modules in the topic
luvus module install owner/repo # whole repo is the module
luvus module install owner/repo/path/to # module in a subdirectory
luvus module install owner/repo --ref v1 # pin a branch / tag / commit

Install 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 a min_luvus_version you’ve actually tested against.
SymptomFix
linkinvalid manifestCheck TOML syntax. Every command must be a non-empty array of non-empty strings.
Listed but runnable: falseDisabled, platform-gated, or a manifest load warning (see module list).
Action is ambiguousTwo modules expose the id, so qualify it: module run <module-id> <action>.
No output in the logThe 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 menuCheck its contexts, that the module is enabled, and that no platforms gate excludes this OS.
Dock is empty after a restartPush its rows from a [[startup]] hook. Dock contents are cached, not persisted.
Settings row not showingSettings appear only under an enabled module. Disabled modules collapse.
$LUVUS_SETTING_* is emptyThe key is upper-cased with - and : turned into _: notify-onLUVUS_SETTING_NOTIFY_ON.