Extracted from the prise(5) manual page. This documents the plug protocol, Lua API, CLI, and includes complete working examples.
Plugs are external processes that extend prise with capabilities that would block the single-threaded UI event loop — file watching, database queries, network listeners, etc. Plugs communicate with the server via msgpack-RPC over the same Unix socket used by TUI clients.
- Plugs are server-side: they persist across client reconnects
- The server is the sole manager: plugs are spawned via
spawn_plug, not by connecting externally - Plugs are language-agnostic: any process that speaks msgpack-RPC over Unix sockets can be a plug
- Plugs can block freely: they run in their own process, isolated from the UI event loop
- Plug crashes are non-fatal: the server can auto-restart them
- Plugs are isolated: they cannot see each other's events or notifications. Cross-plug coordination happens through Lua, which receives all notifications and can call any plug.
- Plugs use token-based authentication: the server generates a unique token
per spawn, passed via environment variable. The plug must present this token
when registering. This decouples authentication from the spawned PID, allowing
wrapper commands (e.g.
npx,uv run) that fork a child process.
- Lua config calls
prise.spawn_plug({name="myplugin", cmd={"python3", "plugin.py"}}, callback?) - Server generates a random token and forks a child process with
PRISE_SOCKETandPRISE_PLUG_TOKENenvironment variables - Child (or a grandchild forked by a wrapper) connects to the Unix socket at
PRISE_SOCKET - Child sends
register_plugRPC with its name, token, and event subscriptions - Server validates the token (timing-safe comparison) and registers the plug
- Plug is now live: it can receive events, handle calls, and send notifications
- On exit, server broadcasts
plug_disconnectedand optionally restarts
Plug liveness is determined by two independent signals: the process (PID) and
the socket connection (registration). When a wrapper command like npx or
uv run spawns a child that owns the socket, the wrapper's PID may exit
before the child disconnects. The server handles this gracefully:
- If the process exits while the plug is still registered (socket connected), cleanup and restart are deferred until the socket closes.
- If the process exits and the plug never registered, cleanup and restart proceed immediately.
- This means
prise plug listmay show a plug asconnectedwith no PID — the wrapper exited but the child is still serving.
| Resource | Limit | Constant |
|---|---|---|
| Maximum plugs | 16 | PLUGS_MAX |
| Plug name length | 64 bytes | PLUG_NAME_MAX |
| Plug token length | 32 hex chars | PLUG_TOKEN_HEX_LEN |
| Call forward timeout | 30 seconds | CALL_FORWARD_TIMEOUT_MS |
| Max pending call forwards | 256 | PENDING_FORWARDS_MAX |
Plugs communicate using standard msgpack-RPC (same as TUI clients):
- Request:
[0, msgid, method, params] - Response:
[1, msgid, error, result] - Notification:
[2, method, params]
Register this connection as a named plug. Must be the first RPC call.
Only connections presenting a valid token (received via PRISE_PLUG_TOKEN
environment variable) are accepted.
Params:
{
"name": "watchdog",
"token": "a1b2c3d4e5f6...",
"subscribe": ["pty_exited", "cwd_changed"]
}name(string, required): Unique plug identifier. Max 64 bytes.token(string, required): The 32-character hex token fromPRISE_PLUG_TOKEN. Validated with timing-safe comparison against the server's stored token.subscribe(array of strings, optional): Server-side event names to receive. Use"*"to subscribe to all server events. Note: plugs can only subscribe to server events (pty_exited,pty_spawned,cwd_changed,rename_tab), not to other plugs' notifications.
Result: "ok" on success.
Errors: DuplicatePlugName, InvalidPlugName, MissingPlugName,
MissingPlugToken, PlugLimitReached, PermissionDenied, AlreadyRegistered
Spawn a managed plug process. Returns proper RPC errors on failure.
Params:
{
"name": "watchdog",
"cmd": ["python3", "watchdog.py"],
"restart": true,
"restart_delay_ms": 2000
}Result: "ok" on success.
Errors: InvalidParams, InvalidPlugName, PlugConfigConflict, PlugLimitReached
Idempotent: spawning a plug with the same name and identical config returns
"ok" without creating a duplicate. Mismatched config returns PlugConfigConflict.
A plug in the deferred state (wrapper exited, child still connected) is treated
as active for idempotency checks.
| Event | Params | Description |
|---|---|---|
pty_exited |
[pty_id, exit_status] |
A PTY process exited |
pty_spawned |
{id, cwd, session?, tab?, title?, focus?} |
A new PTY was created |
cwd_changed |
{pty_id, cwd} |
A PTY's working directory changed |
rename_tab |
{pty_id, title} |
A tab was renamed |
These events are delivered to plugs that subscribe to them. Plug-to-plug
notifications (plug_notification, plug_connected, plug_disconnected)
are delivered only to TUI clients, not to plugs.
Plugs send notifications with a plug. method prefix:
[2, "plug.watchdog.file_changed", {"file": "/tmp/x", "event": "modify"}]
The server repackages this as a plug_notification for TUI clients:
[2, "plug_notification", {"plug": "watchdog", "method": "watchdog.file_changed", "params": {"file": "/tmp/x", "event": "modify"}}]
In Lua, this arrives as an event in update():
{type = "plug_notification", data = {plug = "watchdog", method = "watchdog.file_changed", params = {file = "/tmp/x", event = "modify"}}}Plugs receive standard RPC requests forwarded by the server:
[0, forward_msgid, "ping", {}]
The plug responds with a standard RPC response:
[1, forward_msgid, nil, "pong"]
The server routes this back to the originating Lua callback.
All functions are available on the prise module.
Spawn a managed plug process. An optional callback receives (err, result)
when the server responds, allowing Lua to detect spawn failures.
-- Fire-and-forget (no callback)
prise.spawn_plug({
name = "watchdog",
cmd = {"python3", "watchdog.py"},
restart = true,
restart_delay_ms = 2000,
})
-- With error handling (callback)
prise.spawn_plug({
name = "watchdog",
cmd = {"python3", "watchdog.py"},
restart = true,
}, function(err, result)
if err then
prise.log.error("spawn_plug failed: " .. tostring(err))
return
end
prise.log.info("spawn_plug ok: " .. tostring(result))
end)Options:
name(string, required): Unique plug name. Max 64 bytes.cmd(table, required): Command to execute as{program, arg1, arg2, ...}.restart(bool, optional): Auto-restart on exit. Default:false.restart_delay_ms(number, optional): Milliseconds before restart. Default:1000.
Callback errors: InvalidParams, InvalidPlugName, PlugConfigConflict, PlugLimitReached
The child process receives PRISE_SOCKET and PRISE_PLUG_TOKEN environment
variables. Spawning is asynchronous — wait for a plug_connected event before
calling the plug.
If arg 2 is provided, it must be a function (or nil). Passing a non-function value raises a Lua type error.
Send an RPC request to a plug and receive the response via callback.
prise.call_plug("echo", "ping", {timestamp = os.time()}, function(err, result)
if err then
prise.log.error("call failed: " .. tostring(err))
return
end
prise.log.info("got response: " .. tostring(result))
end)name(string): Target plug namemethod(string): RPC method nameparams(table): Parameters (sequential/empty tables become msgpack arrays, keyed tables become msgpack maps)callback(function): Called with(err, result)when the plug responds
Timeout: 30 seconds. On timeout, callback receives err = "CallForwardTimeout".
Send a fire-and-forget notification to a plug (no response).
prise.notify_plug("watchdog", "set_watch_paths", {"/tmp", "/var/log"})List all managed plugs and their status.
prise.list_plugs(function(result)
for _, plug in ipairs(result.plugs) do
prise.log.info(plug.name .. ": " .. (plug.registered and "connected" or "disconnected"))
end
end)Each plug entry contains: name, registered (bool), pid (number or nil),
restart_count (number), restart (bool).
The tiling window manager handles plug_notification, plug_connected, and
plug_disconnected events automatically — each triggers a redraw via
prise.request_frame(). Multiple events within 8ms are coalesced into a
single render pass.
To act on these events, wrap ui.update in your config:
function M.update(event)
if event.type == "plug_notification" then
local plug = event.data.plug -- plug name
local method = event.data.method -- notification method
local params = event.data.params -- notification payload
-- Reduce into your own state, trigger side effects
elseif event.type == "plug_connected" then
local plug = event.data.plug -- plug name
-- Handle connect (plug registered and ready for calls)
elseif event.type == "plug_disconnected" then
local plug = event.data.plug -- plug name
-- Handle disconnect
end
endState management is a config-level concern — the tiling module provides event forwarding and redraws, your config owns the state and reduction logic. This keeps plug state out of serialization and gives you full control over how data from multiple plugs is composed.
List all managed plugs and their status.
$ prise plug list
NAME STATUS PID RESTARTS AUTO-RESTART
---- ------ --- -------- ------------
watchdog connected 12345 0 yes
socket-listener connected - 1 no
Status values:
connected: Plug has registered via socket (may or may not have a running PID — wrapper commands can exit while the child keeps the connection)starting: Plug process is running but hasn't registered yetstopped: Plug process has exited and no socket connection remains
A minimal plug that registers, handles calls, and sends periodic notifications:
#!/usr/bin/env python3
"""Echo plug for prise — responds to ping, sends periodic heartbeats."""
import os
import socket
import msgpack
import time
import threading
lock = threading.Lock()
def connect():
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(os.environ["PRISE_SOCKET"])
return sock
def send_rpc(sock, msg):
with lock:
sock.sendall(msgpack.packb(msg))
def recv_rpc(sock):
unpacker = msgpack.Unpacker(raw=False)
while True:
data = sock.recv(4096)
if not data:
break
unpacker.feed(data)
for msg in unpacker:
yield msg
def heartbeat(sock):
"""Send periodic notifications to the Lua config."""
while True:
time.sleep(10)
send_rpc(sock, [2, "plug.echo.heartbeat", {"time": int(time.time())}])
def main():
sock = connect()
reader = recv_rpc(sock)
# Register with token from environment
send_rpc(sock, [0, 1, "register_plug", {
"name": "echo",
"token": os.environ["PRISE_PLUG_TOKEN"],
"subscribe": ["pty_exited"],
}])
# Wait for registration response
response = next(reader)
assert response[0] == 1 and response[2] is None, f"register failed: {response}"
# Start heartbeat thread
threading.Thread(target=heartbeat, args=(sock,), daemon=True).start()
# Handle messages
for msg in reader:
if msg[0] == 0: # Request
msgid, method, params = msg[1], msg[2], msg[3]
if method == "ping":
send_rpc(sock, [1, msgid, None, "pong"])
else:
send_rpc(sock, [1, msgid, f"unknown method: {method}", None])
elif msg[0] == 2: # Notification (subscribed event)
method, params = msg[1], msg[2]
print(f"Event: {method} {params}", flush=True)
if __name__ == "__main__":
main()local prise = require("prise")
local ui = prise.tiling()
-- Config-level state for plug data. The tiling module handles redraws;
-- your config owns the state and decides how to reduce notifications.
local plug_state = {}
ui.setup({
keybinds = {
-- Ctrl-P: ping the echo plug
["<C-p>"] = function()
prise.call_plug("echo", "ping", {key = "value"}, function(err, result)
if err then
prise.log.error("ping failed: " .. tostring(err))
else
prise.log.info("ping response: " .. tostring(result))
end
end)
end,
},
})
local orig_update = ui.update
function ui.update(event)
if event.type == "init" then
-- Spawn the echo plug with error handling
prise.spawn_plug({
name = "echo",
cmd = {"python3", os.getenv("HOME") .. "/plugs/echo_plug.py"},
restart = true,
}, function(err, result)
if err then
prise.log.error("Failed to spawn echo plug: " .. tostring(err))
end
end)
elseif event.type == "plug_notification" then
-- Reduce notification data into config-level state
if event.data.method == "echo.heartbeat" then
plug_state.last_heartbeat = event.data.params.time
end
elseif event.type == "plug_connected" then
prise.log.info("Plug connected: " .. event.data.plug)
elseif event.type == "plug_disconnected" then
prise.log.warn("Plug disconnected: " .. event.data.plug)
end
return orig_update(event)
end
return ui