↓ Skip to main content

External plugins

Documented version: v0.5.1

A plugin is an executable in ~/.config/lazyagents/plugins/ (or $XDG_CONFIG_HOME/lazyagents/plugins/). The file name, without extension, is the plugin id: it becomes the TUI tab, the lazyagents <id> subcommand and the <id>: section of config.yaml. Ids match ^[a-z0-9][a-z0-9_-]{0,31}$ and cannot repeat a built-in tab (skills, sessions, agents, providers, hooks, usage, plugins) or a CLI command.

Any language works: the contract is JSON Lines over stdin/stdout. A complete shell example is in examples/plugins/hello.

Invocation modes
#

CommandWhenContract
<bin> servethe TUI opensthe protocol below; the process stays alive until the TUI closes
<bin> <args…>lazyagents <id> <args…>pass-through: stdin/stdout/stderr inherited, exit code forwarded
<bin> doctorlazyagents doctor, only if the manifest has doctor: truewrite problems to stdout; exit ≠ 0 = problem; runs without a terminal and its output is shown once it ends; after 30 s the check fails and the plugin’s process tree is stopped

Commands requested through exec get the plugin’s environment and are cancelled when the app closes. On Windows, discovery accepts .exe executables; the shell example needs a POSIX environment.

In every mode the environment has LAZYAGENTS_HOME, LAZYAGENTS_CONFIG_DIR, LAZYAGENTS_DATA_DIR, LAZYAGENTS_LIBRARY_DIR and LAZYAGENTS_PROTOCOL.

Protocol v1 (serve)
#

One JSON message per line, UTF-8, at most 1 MiB per line. The type field discriminates. The host is lazyagents; the plugin reads stdin and writes stdout. stdout is protocol only: logs go to stderr, which the host captures (the last 4 KiB show in the tab when the plugin fails).

Host → plugin
#

typefieldswhen
initprotocol (1), id, home, configDir, dataDir, libraryDir, theme{id, colors{role: "#hex"}} (keys: tokens such as Primary/Bg/Info and roles such as ui.accent/ansi.red; see internal/tui/theme/README.md), config (the <id>: section of config.yaml, as JSON; absent if there is none), width, heightfirst line after spawn
resizewidth, heightthe tab’s usable area changed (header, tabs and margins already subtracted)
keykey, texta key press, only while the tab is active. key is the Bubble Tea name: a, enter, space, esc, ctrl+x, shift+tab
pastetextpasted text
mousemouse{kind: "wheel"|"click", x, y, button}coordinates relative to the tab body
commandnamethe user picked <id> <name> in the palette (:)
reload—:reload or the reload key
agentsagents[{id, name, installed, version, managedDir, readDirs}]after agent detection (and again after a respawn)
exec_resultexecId, code, stdout, stderr, erroran exec finished (stdout/stderr only for non-interactive ones, up to 1 MiB)

lazyagents’ global keys (q, ?, :, tab, shift+tab, 1–9) do not reach the plugin unless the last frame has capturing: true (use it while one of your inputs has focus).

Plugin → host
#

typefieldsrules
manifesttitle, help[{title, keys[[key, description]]}], commands[{name, desc}], doctorfirst line, within 3 s of init. title up to 40 characters; commands[].name matches ^[a-z0-9][a-z0-9_-]*$ (shown in the palette as <id> <name>)
frameview, count, capturingthe whole tab state, at any time and as often as you like; the last one wins. count is the number on the tab (omit it to hide)
execexecId, argv, dir, interactiveasks the host to run a command. interactive: true suspends the TUI and hands the terminal to the process (editor, agent CLI); otherwise it runs in the background and the output comes back in exec_result

view is text with \n. As in any JSON, control characters are escaped (\u001b[1m for ESC). SGR colors (ESC[…m) are kept; any other escape sequence (cursor movement, screen clearing, OSC) and control characters are removed, and the host clips to the tab size. The theme background is repainted after every ESC[0m.

Lifecycle and failures
#

  • The plugin ends when stdin closes (EOF): handle it and exit; the host sends SIGTERM and, 2 s later, SIGKILL. On Linux and macOS the plugin runs in its own process group and both signals go to the whole group; once the plugin has exited, anything left in it is killed, so a process the plugin started never outlives it. On Windows the plugin starts suspended, joins its own kill-on-close Job Object and only then runs, so nothing it starts escapes: stopping it ends the whole tree (taskkill /T while it runs), anything left in the job is ended once the plugin exits, and the job also ends if lazyagents dies. The same holds for a non-interactive exec the plugin requests; an interactive one keeps the terminal and runs as a plain child.
  • No manifest, a non-JSON line, an empty type, a line > 1 MiB or a dead process: the tab shows the error and the captured stderr; :reload restarts the plugin. None of this takes down the TUI.
  • If the plugin stops reading stdin (queue of 256 messages full), the host shuts it down.

Configuration
#

Put a section named after the plugin id in config.yaml; it arrives whole in init.config:

hello:
  greeting: hi

Testing
#

mkdir -p ~/.config/lazyagents/plugins
cp examples/plugins/hello ~/.config/lazyagents/plugins/hello
chmod +x ~/.config/lazyagents/plugins/hello
lazyagents doctor          # "plugins" section: handshake and `hello doctor`
lazyagents hello a b       # pass-through
lazyagents                 # Hello tab

To debug the protocol by hand: printf '{"type":"init","protocol":1,"width":80,"height":24}\n{"type":"key","key":"x"}\n' | ./hello serve.