Getting started
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.
Releases are numbered 0.1.N, and each release increments N. The API is not
settled: any release may change or remove exported identifiers, and the
changelog marks each break. Pin an exact version to avoid one.
The separate MCP and workspace packages are unpublished scaffolds.
Contents — Quick start · Read tmux · Create · Type and read · Neovim · Packages · Leaves out · Support · Guides · CI · MIT license
Quick start¶
Standalone scripts run on luv, the event loop. Building luv needs a C
compiler, make and CMake. Install them first:
- Debian and Ubuntu:
build-essentialandcmake. - Fedora:
gcc,makeandcmake. - macOS (not a CI platform): the Xcode command line tools
(
xcode-select --install) andcmake.
If the luv build fails, use a system package where one exists, such as
lua-luv on Debian and Ubuntu. It serves only the distribution’s own Lua,
not one built elsewhere.
Install the core rock, then luv, skipping luv if you installed the
system package:
$ 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 valueend
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 trueend))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; the
must helper turns an error into a raised one.
The program reads TMUX_BIN and TMUX_SOCKET, absolute paths to tmux and a
socket. To run it without touching a tmux you use, clone the repository:
$ git clone https://github.com/libtmux/libtmux-lua.git && cd libtmux-luaIts launcher starts a tmux server on a private socket, runs the program, and
stops the server. It needs perl to confirm the stop:
$ sh examples/api/run.sh examples/api/new_session.luasession: demoEvery Lua block on this page is quoted from a program under
examples/ that CI runs, against a real tmux wherever the block
uses one. Excerpts that talk to tmux run inside the same runtime body, with
server and must in hand.
Read what tmux has now¶
Server:snapshot: what tmux has now¶
One call reads every session, window and pane the server holds.
snapshot.sessions,
snapshot.windows and
snapshot.panes list them. This prints each pane and
its window:
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")endThe launcher’s server has one pane, so the complete
snapshot.lua prints its pane ID and window ID:
$ sh examples/api/run.sh examples/snapshot.lua%0 @0A snapshot is a record of what tmux held when it was read. It
does not change when tmux does, and it spans several tmux commands, so it is
not an atomic view. strict = true adds a verification pass. Call
Server:snapshot again to refresh.
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:
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.activeend)assert(#inactive == 2 and inactive[1].id == "%2")for _, pane in ipairs(editors) do print(pane.id, pane.current_command)endnative_query.lua defines rows and schema
and needs only Lua. It prints the panes whose command is nvim or vim:
$ lua examples/native_query.lua%1 nvim%3 vimCriteria and relationships and the
field reference list what where accepts.
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.luamatched: democomplete reports no known inconsistency. The read is still not atomic.
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.windowlocal 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.luaeditor buildCreate sessions, windows and panes¶
Session: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 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)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.luawindow: logsCreation does not wait for the program to start or report whether it succeeds. Closing the server handle leaves what you created running.
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 %3Type into a pane and read the answer¶
Pane: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:
-- 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())quote shell-quotes the tmux path and socket for the pane’s shell. The
program goes on to capture the pane and print the line the command wrote:
$ sh examples/api/run.sh examples/api/send_keys.lualua input readyServer:command runs any tmux command from literal
arguments. A nonzero exit is part of the result.
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 = falsefor line in must(capture:text()):gmatch("[^\r\n]+") do if line == "lua capture ready" then found = true endendassert(found, "completed command did not print the expected line")print("lua capture ready")$ sh examples/api/run.sh examples/api/capture.lualua 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 on
every supported tmux release 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.lualua wait readyIf the pane exits first, the wait ends with pane_died and the text seen so
far. See waiting for pane text for the read,
the hooks it runs and every end state.
Use it from Neovim¶
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 then shows the server’s pane count. It has its own libuv binding, so
the luv rock is not needed here.
The runtime closes its connections when the body finishes and leaves the tmux server running. See runtime ownership and cancellation.
Packages¶
| Package | Source | Reference | What it is |
|---|---|---|---|
libtmux¶ | lua/libtmux/ | libtmux.org | The library. Sessions, windows, panes, snapshots, queries and two event-loop adapters. |
libtmux-workspace¶ | packages/workspace/ | libtmux.org | An unpublished scaffold for workspace operations. |
libtmux-mcp¶ | packages/mcp/ | libtmux.org | An unpublished scaffold for the MCP server. |
Each heading below links to its directory on GitHub, which LuaRocks does not
show. Only libtmux is published. libtmux-workspace and libtmux-mcp are
unpublished scaffolds with no public API.
Each rock keeps its own changelog: libtmux,
libtmux-workspace and
libtmux-mcp.
libtmux: the library¶
Create sessions, windows and panes, capture pane output, query server state and watch pane output, on luv or Neovim’s event loop. Reference:
- LuaRocks and libtmux.org.
libtmux.Server,libtmux.runtime.luv,libtmux.runtime.nvimandlibtmux.query.
Importing a module starts neither tmux nor an event loop, and local queries need only Lua. To install from a checkout instead of LuaRocks:
$ luarocks --local make rockspecs/libtmux-scm-1.rockspecThe core README covers each module, with a complete program for the standalone loop, Neovim and tables without tmux.
libtmux-workspace: workspace scaffold¶
A development package for planned workspace operations. It has no loader, command or builder API yet, and its module returns only a version.
libtmux.org records that it
is unpublished. After installing libtmux, build it from the checkout:
$ luarocks --local make rockspecs/libtmux-workspace-scm-1.rockspecWith the local rocks tree on Lua’s module paths, print its version:
$ lua -e 'print(require("libtmux_workspace")._VERSION)'scmThe package README lists the prerequisites,
including LibYAML for lyaml.
libtmux-mcp: MCP scaffold¶
A development package for the planned MCP server. It has no server, executable, tools or prompts yet, so there is nothing to register with an MCP client.
libtmux.org records that it is
unpublished. After installing libtmux, build it from the checkout:
$ luarocks --local make rockspecs/libtmux-mcp-scm-1.rockspecWith the local rocks tree on Lua’s module paths, print its version:
$ lua -e 'print(require("libtmux_mcp")._VERSION)'scmThe package README has the full build steps.
What it leaves out¶
- 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.
- An MCP server or a workspace loader. Both packages are scaffolds with no public API.
Support and status¶
- Versions. Releases are numbered
0.1.N, and each release incrementsN. The API is not settled: any release may change or remove exported identifiers, and the changelog marks each break. Pin an exact version to avoid one. - Lua 5.1 to 5.5, and LuaJIT. CI runs the unit suites on each.
- tmux 3.3a or newer. Connection setup may probe the daemon’s release,
then refuse an older server with
unsupported_version. The compatibility matrix lists CI’s integration releases. - tmux release candidates are not supported. A feature can require one
only until its release ships, and a candidate such as
X.Y-rcNranks belowX.Y, so one below the floor is refused. See Release candidates. - Pane-output watches and control observation need tmux 3.7 or newer.
observereturnsunsupportedon an older server. See control. - Waiting for text polls the visible screen.
Pane:wait_for_textworks on every release. Each read runs theafter-capture-panehook and never searches scrollback. - Nested commands are not checked. A refused command inside another,
such as
if-shell -F 1 'break-pane -d', reaches tmux and can stop the server on exactly 3.7. See the limit and its advice. - Optional features. Some commands and options need a newer release than
3.3a. The optional-feature table
lists each, and
capabilities.jsonholds the same data for tools. - Neovim. CI runs the Neovim tests on 0.10.0 and 0.12.5.
- Linux, including WSL. CI runs on Ubuntu 24.04 and local checks run under WSL2. No CI job runs on macOS, so macOS is untested.
- Releases. The changelog records each one, and the release instructions cover verification.
Guides¶
- Clients: switch sessions and detach terminals.
- Read and watch: snapshots, session notifications and pane streams.
- Run and arrange: commands and batches, session/window topology, binary buffers.
- Configure: options and hooks, option reference, environment values.
- Contribute: setup and validation, writing conventions.
Live tests use private sockets and clean up their own servers. See the contributing guide for the same offline checks that run in CI.
Attribution¶
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}}License¶
MIT. See LICENSE.