Script tmux from Lua: create sessions and panes, capture terminal output, query server state, and watch pane output. Use standalone Lua with luv or Neovim's event loop.
Needs Lua 5.1 to 5.5 or LuaJIT, and tmux 3.3a or newer. Before 1.0, any release may change an API: pin an exact version and read the changelog.
Contents — Quick start · Read tmux · Create · Type and read · Resize · Run together · Watch · Neovim · Packages · Leaves out · Support · Guides · CI · MIT license
Standalone scripts run on luv, the event loop. It builds with a C compiler,
make and CMake:
- Debian and Ubuntu:
build-essentialandcmake. - Fedora:
gcc,makeandcmake. - macOS: the Xcode command line tools and
cmake.
If the build fails, install lua-luv on Debian or Ubuntu instead. It serves
only the distribution's own Lua.
Install the rock, then luv (skip it if you installed lua-luv):
$ luarocks --local install libtmux$ luarocks --local install luv 1.52.1-0Add the local rocks tree to Lua's module paths:
$ eval "$(luarocks --local path)"Create a session and read it back. This is the whole program:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(tostring(err), 0)
end
return value
end
local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")
must(adapter.run(function(runtime)
local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
local created = must(server
:new_session({
name = "demo",
window_name = "main",
argv = { "/bin/cat" },
})
:await())
assert(created.session and created.window and created.pane, "creation handles are missing")
local snapshot = must(created.session:snapshot():await())
assert(snapshot.name == "demo", "created session has the wrong name")
print("session: " .. snapshot.name)
must(server:close():await())
return true
end))Runtime:connect binds the socket you name and never
starts a server.
Server:new_session creates the session and
Session:snapshot reads it back.
Live calls return Requests. :await()
yields inside the runtime body and returns value, err; must
raises the error.
The program needs TMUX_BIN and TMUX_SOCKET, absolute paths to tmux and a
socket. The repository's launcher sets both on a private server, runs the
program, then stops the server:
$ git clone https://github.com/libtmux/libtmux-lua.git && cd libtmux-luaThe launcher needs perl to confirm the stop:
$ sh examples/api/run.sh examples/api/new_session.lua
session: demoEvery Lua block here comes from a program under examples/.
Excerpts continue inside the same runtime body, with server and must in
hand.
Server:snapshot: what tmux has now
One call reads every session, window and pane.
snapshot.sessions,
snapshot.windows and
snapshot.panes list them. This prints each pane and
its window:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(err, 0)
end
return value
end
must(adapter.run(function(runtime)
local server = must(runtime
:connect({
binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable"),
socket_path = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket"),
})
:await())
local snapshot = must(server:snapshot({ strict = true }):await())
for _, pane in ipairs(snapshot.panes) do
io.stdout:write(pane.id, "\t", pane.window_id, "\n")
end
must(server:close():await())
return true
end))The launcher's server has one pane, so it prints that pane's ID and its window's ID:
$ sh examples/api/run.sh examples/snapshot.lua
%0 @0A snapshot is what tmux held when it was read, across several
tmux commands: not live and not atomic. strict = true adds a verification
pass. Call Server:snapshot again to refresh.
More: Snapshots.
Selection:where: narrow what you read
Selection:where narrows a collection by structured
criteria, and Selection:filter by a Lua function.
Both work on snapshot.panes and on any list you give
query.select, and neither calls tmux. This program needs
only Lua:
local query = require("libtmux.query")
local schema = {
fields = {
id = { type = "string" },
active = { type = "boolean" },
current_command = { type = "string" },
},
}
local rows = {
{ id = "%1", active = true, current_command = "nvim" },
{ id = "%2", active = false, current_command = "sh" },
{ id = "%3", active = false, current_command = "vim" },
}
local panes = query.select(rows, schema)
local editors = panes:where({ current_command = { one_of = { "nvim", "vim" } } })
assert(#editors == 2 and editors[1] == rows[1] and editors[2] == rows[3])
local inactive = panes:filter(function(pane)
return not pane.active
end)
assert(#inactive == 2 and inactive[1].id == "%2")
for _, pane in ipairs(editors) do
print(pane.id, pane.current_command)
endIt prints the panes whose command is nvim or vim:
$ lua examples/native_query.lua
%1 nvim
%3 vimMore: criteria and relationships, the field reference.
Server:query: read and filter in one call
Server:query captures a snapshot and applies where to
it. kind picks the record type. The rows come back as a
Selection beside the snapshot behind them:
local result = must(server
:query({
kind = "session",
where = { name = "demo" },
snapshot = { strict = true },
})
:await())
assert(result.complete, "query observed a topology change")
assert(#result.rows == 1 and result.rows[1].name == "demo", "query selected the wrong session")
print("matched: " .. result.rows[1].name)The program creates sessions named demo and worker, so the filter matches
one:
$ sh examples/api/run.sh examples/api/query.lua
matched: democomplete reports no known inconsistency. The read is still not atomic.
More: live queries.
Server:explain_panes: see what runs in tmux
Server:explain_panes and
Server:explain return the plan for a query without
contacting tmux: its commands, the criteria sent to tmux as a filter, and the
ones kept local. pushdown picks the mode: never, auto or require:
local where = { active = true, current_command = "cat" }
for _, mode in ipairs({ "never", "auto" }) do
-- Explaining sends nothing to tmux.
local plan = must(server:explain_panes({ where = where, pushdown = mode }):await())
local filter = plan.filter or "none"
print(mode .. ": " .. #plan.commands .. " tmux commands, filter " .. filter)
for _, left in ipairs(plan.residual) do
print(" local: " .. left.path .. " (" .. left.reason .. ")")
end
endnever keeps every criterion local. auto sends active to tmux and keeps
current_command local:
$ sh examples/api/run.sh examples/api/explain.lua
never: 6 tmux commands, filter none
local: $ (pushdown_disabled)
auto: 7 tmux commands, filter #{==:#{pane_active},1}
local: $.current_command.eq (nullable_field_remains_local)auto adds a tmux-side listing; it does not make the query faster.
More: live queries.
Window:snapshot: read one object again
A record is what tmux said when it was read. Lua has no refresh that updates
one in place, as Python libtmux's .refresh() does. Call snapshot on the
handle instead: Window:snapshot returns a new record
and leaves the one you hold as it was.
Session:snapshot and
Pane:snapshot work the same way.
This reads a window, renames it with Window:rename, and
reads it again:
local window = created.window
local held = must(window:snapshot():await())
must(window:rename("build"):await())
local fresh = must(window:snapshot():await())
assert(held.name == "editor" and fresh.name == "build", "refresh did not read the rename")
print(held.name .. " " .. fresh.name)$ sh examples/api/run.sh examples/api/refresh.lua
editor buildSession:new_window: add a window
Session:new_window adds a window to a session.
Like Server:new_session, it returns a
Creation holding session,
window, pane and
window_link handles:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(tostring(err), 0)
end
return value
end
local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")
must(adapter.run(function(runtime)
local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
local created = must(server
:new_session({
name = "demo",
window_name = "main",
argv = { "/bin/cat" },
})
:await())
local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await())
local snapshot = must(logs.window:snapshot():await())
assert(snapshot.name == "logs", "created window has the wrong name")
print("window: " .. snapshot.name)
must(server:close():await())
return true
end))argv is the literal command the pane runs. Leave it out for tmux's default
shell.
$ sh examples/api/run.sh examples/api/new_window.lua
window: logsCreation does not wait for the program to start or report whether it succeeds. Closing the server handle leaves what you created running.
More: Creation.
Pane:split: divide a window
Pane:split splits a pane to the left, right, up or
down, sized by percent or size. The new pane is split.pane:
local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await())
local split =
must(logs.pane:split({ direction = "right", percent = 40, argv = { "/bin/cat" } }):await())quickstart.lua creates a session, a window and
this split, then prints their IDs. New windows and splits leave the
selection alone unless you pass select = true:
$ sh examples/api/run.sh examples/quickstart.lua
$1 @2 %3Pane:send_text: type a command
Pane:send_text types literal text and adds no Enter.
Pane:send_keys presses named keys such as Enter.
Success means tmux accepted the input while the command may still be running.
The example waits on a tmux wait-for channel with
Server:command:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(tostring(err), 0)
end
return value
end
local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")
must(adapter.run(function(runtime)
local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
local created = must(server:new_session({ name = "demo", argv = { "/bin/sh" } }):await())
local pane = created.pane
local function quote(text)
return "'" .. text:gsub("'", "'\\''") .. "'"
end
-- Signal completion on this socket; do not guess when the shell has printed.
local command = "printf '\\nlua input ready\\n'; "
.. quote(binary)
.. " -S "
.. quote(socket)
.. " wait-for -S example-ready"
must(pane:send_text(command):await())
must(pane:send_keys({ "Enter" }):await())
must(server:command({ "wait-for", "example-ready" }, { timeout = 1000 }):await())
local capture = must(pane:capture({ history_lines = 20 }):await())
local found = false
for line in must(capture:text()):gmatch("[^\r\n]+") do
if line == "lua input ready" then
found = true
end
end
assert(found, "completed command did not print the expected line")
print("lua input ready")
must(server:close():await())
return true
end))quote shell-quotes the tmux path and socket for the pane's shell. The
program captures the pane and prints the line the command wrote:
$ sh examples/api/run.sh examples/api/send_keys.lua
lua input readyServer:command runs any tmux command from literal
arguments. A nonzero exit is part of the result.
More: Pane operations.
Pane:capture: read the screen
Pane:capture returns the screen tmux drew.
history_lines = 20 adds up to 20 rows of history.
capture:text() returns the bytes as UTF-8, or an
invalid_utf8 error:
local capture = must(pane:capture({ history_lines = 20 }):await())
local found = false
for line in must(capture:text()):gmatch("[^\r\n]+") do
if line == "lua capture ready" then
found = true
end
end
assert(found, "completed command did not print the expected line")
print("lua capture ready")$ sh examples/api/run.sh examples/api/capture.lua
lua capture readyA capture holds the cells tmux rendered, so it cannot say whether a command finished. Pane operations lists the capture options, from joined lines to escape sequences.
Pane:wait_for_text: wait for text on screen
Pane:wait_for_text polls the visible screen until
it holds the literal text. The result has the matching screen and
matched_at_entry, true when the first read already matched. Pass timeout
or deadline to bound the wait:
-- The shell echoes what is typed, so print the needle from two pieces.
must(pane:send_text("printf '%s%s\\n' 'lua wait ' 'ready'"):await())
must(pane:send_keys({ "Enter" }):await())
local found = must(pane:wait_for_text("lua wait ready", { timeout = 5000 }):await())
assert(found.text:find("lua wait ready", 1, true))
print("lua wait ready")$ sh examples/api/run.sh examples/api/wait_for_text.lua
lua wait readyIf the pane exits first, the wait ends with pane_died and the text seen so
far. More: waiting for pane text.
Pane:resize: set a size
Pane:resize sets a pane's width and height, or
adjusts it by a direction and an amount.
Window:resize takes width and height. tmux can clamp
both to the layout, so read the sizes back:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(tostring(err), 0)
end
return value
end
local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")
must(adapter.run(function(runtime)
local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
local created = must(server:new_session({ name = "demo", argv = { "/bin/cat" } }):await())
local split = must(created.pane:split({ direction = "right", argv = { "/bin/cat" } }):await())
must(created.window:resize({ width = 100, height = 30 }):await())
must(split.pane:resize({ width = 30 }):await())
-- A resize can clamp to the layout, so read the sizes tmux settled on.
local window = must(created.window:snapshot():await())
local pane = must(split.pane:snapshot():await())
assert(window.width == 100 and window.height == 30, "the window kept its old size")
assert(pane.width == 30, "the pane kept its old width")
print(("window %dx%d, pane %d wide"):format(window.width, window.height, pane.width))
must(server:close():await())
return true
end))$ sh examples/api/run.sh examples/api/resize.lua
window 100x30, pane 30 wideMore: pane operations, window size.
Pane:kill: remove what you made
Pane:kill, Window:kill and
Session:kill remove the object they are called on.
Removing a window's last pane removes the window too. More:
removal.
Window:set_option: change a setting
Window:set_option and
Window:get_option write and read one option. Server,
session and pane handles have them too, and
Window:set_hook stores a command for a hook. More:
options and hooks.
Server:group: several commands, one tmux call
Server:group runs commands in order in one tmux call.
Server:batch runs independent commands and reports each
outcome. This group creates a session and two windows, then lists them:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(tostring(err), 0)
end
return value
end
local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")
must(adapter.run(function(runtime)
local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
-- One tmux process runs all four commands, in order.
local done = must(server
:group({
{ "new-session", "-d", "-s", "demo", "-n", "main", "/bin/cat" },
{ "new-window", "-d", "-t", "demo:", "-n", "build", "/bin/cat" },
{ "new-window", "-d", "-t", "demo:", "-n", "test", "/bin/cat" },
{ "list-windows", "-t", "demo", "-F", "#{window_name}" },
})
:await())
assert(done.exit_code == 0, "the group failed")
local names = {}
for name in done.stdout:gmatch("[^\n]+") do
names[#names + 1] = name
end
assert(#names == 3, "the group did not create three windows")
print("windows: " .. table.concat(names, ", "))
must(server:close():await())
return true
end))$ sh examples/api/run.sh examples/api/group.lua
windows: main, build, testA group is not a transaction: a failing command skips the rest, and commands that already ran stay done.
More: commands and batches.
Server:observe: keep a control-mode client open
Server:observe holds one control-mode client for a
session. Observation:watch_pane streams a
pane's output, and
Observation:watch_notifications
streams tmux's own events. watch:next returns the next
event. This needs tmux 3.7 or newer:
local adapter = require("libtmux.runtime.luv")
local function must(value, err)
if err ~= nil then
error(tostring(err), 0)
end
return value
end
local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")
must(adapter.run(function(runtime)
local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
local created = must(
server:new_session({ name = "demo", window_name = "main", argv = { "/bin/cat" } }):await()
)
-- Observation holds one control-mode client open; it needs tmux 3.7 or newer.
local observation = must(server:observe(created.session):await())
local watch = must(observation:watch_pane(created.pane):await())
must(created.pane:send_text("watched"):await())
local output = ""
while not output:find("watched", 1, true) do
output = output .. must(watch:next({ timeout = 5000 }):await()).data
end
print("output: watched")
must(observation:close():await())
must(server:close():await())
return true
end))$ sh examples/api/run.sh examples/api/watch_pane.lua
output: watchedEach event carries the pane's bytes and a sequence number. A slow reader loses
events: the watch ends with observation_gap instead of dropping them
silently.
More: observing a session.
runtime.nvim.start: borrow the editor's loop
start returns at once, runs the body on Neovim's loop and
calls on_done(value, err) after the work it owns has finished. Handles it
did not create stay open:
local adapter = require("libtmux.runtime.nvim")
adapter.start(function(runtime)
local server, err = runtime
:connect({
binary = vim.fn.exepath("tmux"),
socket_path = assert(os.getenv("TMUX_SOCKET")),
})
:await()
if not server then
return nil, err
end
return server:snapshot({ strict = true }):await()
end, function(snapshot, err)
if err then
vim.notify(tostring(err), vim.log.levels.ERROR)
return
end
vim.notify(("Panes: %d"):format(#snapshot.panes))
end)From the checkout, start Neovim with the library on its runtimepath and set
TMUX_SOCKET to the absolute path of a server's socket:
$ TMUX_SOCKET=/path/to/tmux.sock nvim \
--cmd 'set runtimepath+=.' \
-c 'luafile examples/nvim_snapshot.lua'Neovim shows the server's pane count. It has its own libuv binding, so the
luv rock is not needed.
More: runtime ownership and cancellation.
libtmux: the library
Sessions, windows, panes, queries and pane watches on luv or Neovim's event loop. Importing a module starts neither tmux nor an event loop, and local queries need only Lua.
Reference: LuaRocks,
libtmux.org,
libtmux.Server,
libtmux.runtime.luv,
libtmux.runtime.nvim,
libtmux.query.
Changelog: CHANGES.md. Module by module: the
core README.
To install from a checkout instead of LuaRocks:
$ luarocks --local make rockspecs/libtmux-scm-1.rockspec- Starting a server.
Runtime:connectnever starts one. Without asocket_path,LIBTMUX_SOCKET_PATHorLIBTMUX_SOCKETnames the server, and otherwise tmux picks it. - Live records. A snapshot holds what tmux said when it was read. Capture again to see a change.
- Proof that a command finished.
Pane:send_keyssucceeds when tmux accepts the input. Wait on await-forchannel, or print a token and wait for it. - An interactive terminal. The luv and Neovim adapters own no terminal, so
Session:attachreturnsunsupported_tty. - Your tmux configuration. The library does not change a borrowed server's configuration, and your aliases and hooks still apply to its commands.
- Before 1.0. Any release may change or remove an exported name. Pin an exact version; the changelog marks each break.
- Lua. 5.1 to 5.5, and LuaJIT.
- tmux. 3.3a or newer. An older server is refused with
unsupported_version. Compatibility matrix. - Optional features. Some commands and options need a newer tmux. The
table and
capabilities.jsonlist each. - Control mode.
observeneeds tmux 3.7 or newer and returnsunsupportedbelow it. Control guide. - Polling.
Pane:wait_for_textworks on every release. It reads the visible screen, never scrollback, and each read runs theafter-capture-panehook. - Nested commands are not checked.
if-shell -F 1 'break-pane -d'reaches tmux unchecked and can stop a 3.7 server. Details. - No release candidates. An
X.Y-rcNbuild ranks belowX.Y. Policy. - Neovim. 0.10 or newer.
- Linux, WSL included. macOS is not tested.
- Changelog.
CHANGES.mdrecords each release; release steps.
- Read and query: snapshots, queries, field reference.
- Create and type: creation, pane operations.
- Run and arrange: commands and batches, topology, binary buffers, clients.
- Watch: session notifications and pane streams.
- Configure: options and hooks, option reference, environment values.
- Runtime and compatibility: runtime ownership, compatibility matrix.
- Contribute: setup and validation, writing conventions.
Use these packages with AI tools as you like. When contributing, check what you submit and wait for a maintainer's invitation before preparing a pull request. See the AI policy, also kept as AI_POLICY.md.
Please use the following BibTeX template to cite libtmux-lua in scientific discourse:
@misc{libtmux-lua,
author = {Tony Narlock},
year = {2026},
note = {https://libtmux.org/en/lua/},
title = {libtmux-lua: Lua wrapper for tmux}
}MIT. See LICENSE.