Server bindings
Drive tmux from Ruby: sessions, windows and panes as handles, snapshots you filter in plain Ruby, and control connections that report what tmux does.
API reference · Guides · RubyGems
Part of libtmux for Ruby. This gem is the core; the packages below build on it.
Create and split windows, read and filter what a server holds, and type into a pane and capture what it shows.
It suits scripts, tests that need a real tmux, and tools that drive a terminal program. Cancel a request that is waiting, and follow events as tmux emits them.
Contents — Quick start · Find things · Create · Type and read · Your own server · Cancel · Link windows · Options · Watch tmux · Groups · Packages · Leaves out · Support · Documentation
Quick start¶
Install the alpha. It needs Ruby 3.3 or newer and tmux 3.3a or newer on
PATH:
$ gem install libtmux --preThis program creates a session, adds a window, splits it, and prints each
window with its panes. Server.start closes its private tmux
server when the block exits, and the snapshot stays readable afterward:
require "libtmux"
snapshot = LibTmux::Server.start do |server| session = server.new_session(name: "work", window_name: "main", command: ["/bin/cat"]) window = session.new_window(name: "logs", command: ["/bin/cat"]) window.split(direction: :horizontal, size: "40%", command: ["/bin/cat"])
server.snapshotend
snapshot.windows.each do |window| puts "#{window.name}: #{window.panes.map(&:id).join(', ')}"endIt prints:
main: %0logs: %1, %2Requiring the gem starts no tmux process, scheduler or MCP server.
Server.start gives its tmux server a socket and an empty
configuration of its own, so it never touches a tmux you already use.
Find sessions, windows, and panes¶
Server#list_sessions: what tmux has now¶
One call asks tmux for every session and returns frozen handles in numeric ID
order. list_windows and
list_panes are its siblings, and
Session#list_windows and
Window#list_panes narrow them:
require "libtmux"
LibTmux::Server.start do |server| server.new_session(name: "work", command: ["/bin/cat"]) server.new_session(name: "logs", command: ["/bin/cat"]) sessions = server.list_sessions puts "sessions: #{sessions.map(&:id).sort.join(', ')}"endIt runs on a private server and prints:
sessions: $0, $1An empty array means tmux answered with nothing to list. A listing that fails
raises CommandError.
Server#snapshot: one read, many queries¶
Read the server once, then filter that read with
where, select,
one and one_or_nil. These queries
make no tmux calls. Continue with the snapshot from the quick start:
panes = snapshot.panesactive_ids = panes.where(active: true).map(&:id)wide_panes = panes.select { |pane| pane.width >= 40 }panes_by_window = panes.group_by { |pane| pane.window.name }
logs = snapshot.windows.one(name: "logs")missing = snapshot.windows.one_or_nil(name: "missing")one raises NoMatchError or
MultipleMatchesError unless exactly one record
matches. one_or_nil returns nil for no match and still rejects duplicates.
A selection keeps the membership it captured, so call server.snapshot again
to read later changes. The field catalog
lists every query field and wire name.
Server#search_panes: the matching panes in one call¶
search_panes captures the server and answers a selection of the panes whose
fields match where:. explain_panes shows the plan
without running tmux. This program also uses the snapshot queries above:
require "libtmux"
LibTmux::Server.start do |server| work = server.new_session(name: "work", command: ["/bin/cat"], receipt: true) server.new_session(name: "logs", command: ["/bin/cat"]) snapshot = server.snapshot session = snapshot.sessions.where(name: "work").one puts "matching session: #{session.name}" missing = snapshot.sessions.one_or_nil(name: "missing") puts "missing session: #{missing.nil?}" panes = server.search_panes(where: {id: work.pane.id}) puts "matching pane: #{panes.one.id}"endIt prints:
matching session: workmissing session: truematching pane: %0Create sessions, windows, and panes¶
Server#new_session: a session and its first window¶
new_session starts a detached session that runs command:, an argv array.
Ask for a receipt to read the IDs tmux assigned to the session, window and
pane:
require "libtmux"
LibTmux::Server.start do |server| created = server.new_session( name: "work", window_name: "main", command: ["/bin/cat"], receipt: true ) puts "session: #{created.entity.id}" puts "window: #{created.window.id}" puts "pane: #{created.pane.id}"endIt prints:
session: $0window: @0pane: %0Creation returns once tmux has assigned the IDs. It says nothing about whether the program started or exited cleanly.
Session#new_window: add a window¶
Pane#split and Window#split add panes the
same way. A new window takes a name: and the same command: as a session:
require "libtmux"
LibTmux::Server.start do |server| session = server.new_session(name: "work", command: ["/bin/cat"]) window = session.new_window(name: "logs", command: ["/bin/cat"]) puts "window: #{window.snapshot.name}" puts "session windows: #{session.list_windows.length}"endIt prints:
window: logssession windows: 2cwd:names a directory on the machine running Ruby, and is checked before the command is sent.environment:is a String-to-String map of literal values.new_window(index:)refuses a slot that is taken.Pane#splittargets that exact pane, andsize:is a cell count or a percentage string such as"40%".- New windows and splits keep focus unless you pass
focus: true.
The full contract is in Creation.
Type into a pane and read it back¶
Pane#send_text: type, then read what the pane showed¶
send_text types literal text, and send_keys sends tmux key
names such as Enter. Pane#capture reads the screen.
To wait for output, poll the pane until the line appears. A control connection reports it as an event instead, but needs tmux 3.7 or newer:
require "libtmux"
LibTmux::Server.start do |server| created = server.new_session( name: "capture", command: ["/bin/sh"], environment: {"ENV" => "/dev/null"}, receipt: true ) pane = created.pane pane.send_text("printf '\\nlibtmux capture ready\\n'") pane.send_keys("Enter") deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5 captured = pane.capture.stdout.lines(chomp: true) until captured.include?("libtmux capture ready") raise "Output did not arrive within five seconds" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline sleep 0.05 captured = pane.capture.stdout.lines(chomp: true) end raise "Expected output is absent from the pane" unless captured.include?("libtmux capture ready") puts "libtmux capture ready"endIt prints:
libtmux capture ready- Sending input does not show that the shell finished the command.
capturereturns the visible screen.start:andfinish:choose a range, andjoin:joins wrapped lines.escapes:,escape_bytes:,preserve_trailing:andtrim_trailing:change the bytes it returns.mode_screen:,alternate:andpending:read a copy-mode screen, the saved screen and an unfinished escape sequence. Pass at most one.- Binary buffers keep their trailing newlines. See
write_bufferandread_bufferin the layout and bytes recipe.
The full contract is in Pane input and capture.
Use the tmux server you already run¶
Server.new: borrow a daemon¶
Server.open and Server.new bind an endpoint
that already exists. Closing the binding retires its clients and leaves the
daemon running; Server#kill stops it.
The outer Server.start block here only supplies a daemon to
borrow:
require "libtmux"
LibTmux::Server.start do |owner| owner.new_session(name: "work", command: ["/bin/cat"]) server = LibTmux::Server.new(endpoint: owner.endpoint) begin puts "connected sessions: #{server.list_sessions.length}" ensure server.close end puts "sessions after client close: #{owner.list_sessions.length}"endIt prints:
connected sessions: 1sessions after client close: 1Endpoint.newtakes an explicit socket path or socket name.Endpoint.from_envis the opt-in for$TMUX.- A binding keeps a private link to the socket it bound, so commands never reach a different daemon that later takes the same path.
- A ref is valid on the server that issued it. Another server raises
TargetNotFoundError.
See ownership and errors.
Wait, time out, and cancel¶
Cancellation.new: wake a request that is waiting¶
Typed operations take timeout: and cancel:. Pass a
Cancellation token as cancel:, call
cancel from another thread to wake the request, join
its callers, then close the token:
require "libtmux"
LibTmux::Server.start do |server| server.new_session(name: "work", command: ["/bin/cat"]) cancellation = LibTmux::Cancellation.new waiting = Thread.new do server.run(["wait-for", "-S", "ready", ";", "wait-for", "held"], timeout: 5, cancel: cancellation) rescue LibTmux::Cancelled => error error end server.wait_for("ready", timeout: 5) cancellation.cancel failure = waiting.value puts "request: #{failure.class}" puts "delivery: #{failure.delivery}" cancellation.closeendIt prints:
request: LibTmux::Cancelleddelivery: possibly_sentCancelled reports delivery as :not_sent or :possibly_sent.
Cancelling does not undo what tmux already queued.
The plain-Ruby recipe also checks that the client was reaped. The contracts are in Cancellation tokens and ownership and errors.
Link a window into several sessions¶
Session#link_window: one window at several indexes¶
A WindowLink keeps a session, an index and a window ID
together, so a window linked twice stays one window. link_window needs an
explicit index::
require "libtmux"
LibTmux::Server.start do |server| session = server.new_session(name: "links", command: ["/bin/cat"]) window = session.list_windows.fetch(0) session.link_window(window.ref, index: 4) session.link_window(window.ref, index: 9) links = session.list_window_links puts "indexes: #{links.map(&:index).join(', ')}" puts "windows: #{links.map { |link| link.window.id }.uniq.join(', ')}"endIt prints:
indexes: 0, 4, 9windows: @0- Select, unlink, move, swap and display check the whole link identity in the same tmux queue turn as the operation.
- A hook that waits before dispatch cannot turn a stale link into its replacement.
- Rewriting command aliases during an operation is outside that guarantee, so coordinate configuration changes.
The contract is in Window links, and the linked-windows recipe runs this at three indexes.
Read and set options and hooks¶
Session#options: a value with its inheritance¶
Options#set writes an option at the scope of its accessor.
Options#get reads it back as an
OptionValue, and as converts it strictly.
Hooks#set stores a tmux command:
require "libtmux"
LibTmux::Server.start do |server| session = server.new_session(name: "work", command: ["/bin/cat"]) session.options.set("history-limit", 5000) limit = session.options.get("history-limit") puts "history-limit: #{limit.as(:integer)}" puts "inherited: #{limit.inherited?}" session.hooks.set("after-new-window", command: "display-message created") puts "hook: #{session.hooks.get('after-new-window').as(:string)}"endIt prints:
history-limit: 5000inherited: falsehook: display-message created- An
OptionValuekeeps the raw bytes, whether the value is inherited, and the array index.astakes:bytes,:string,:integeror:boolean. - A missing array index raises
NoMatchError, and a present empty value stays present. - Reading an array without an index raises
MultipleMatchesErrorwhen it has several entries. - Hook values stay tmux command strings, and indexed hooks run in index order.
The contracts are in Options and hooks and Option values.
Watch tmux change¶
Server#open_control: events as they happen¶
A ControlConnection runs a command with
exchange and streams what tmux reports through
subscribe. The subscription’s
next waits for the next
ControlEvent:
require "libtmux"
LibTmux::Server.start do |server| session = server.new_session(name: "work", command: ["/bin/cat"]) window = session.list_windows.fetch(0) server.open_control(session: session.ref) do |control| reply = control.exchange("display-message -p ready", timeout: 5) puts "reply: #{reply.blocks.last.body.strip}" events = control.subscribe(max_events: 64, max_bytes: 65_536) window.rename("renamed") event = events.next(timeout: 5) until event&.raw&.start_with?("%window-renamed") puts "event: #{event.raw.strip}" endendIt prints:
reply: readyevent: %window-renamed @0 renamedexchangeanswers aGuardedReply. It holds the blocks seen between two boundaries, and a hook can print into the same interval.- A subscription is bounded. A reliable one raises
SubscriptionOverflowwhen it falls behind, and a tail one reports the lost range as a:gapevent. pause_outputandresume_outputreturn aGuardedReply. A reply does not show that the action took effect.- To reconnect, close the old connection and pass it as
open_control(session: ref, reconnect: old_connection). The replacement has a newgeneration, and every new subscription starts with a:reconnectgap. Nothing is replayed.
The contracts are in Control connections, and the control overflow recipe runs both subscription modes.
Run several commands in order¶
Server#run_group: several commands through one client¶
A group submits several tmux commands in order through one client. The
GroupResult holds one aggregate status, and each step reports
unknown:
require "libtmux"
LibTmux::Server.start do |server| server.new_session(name: "work", command: ["/bin/cat"]) group = server.run_group([ ["set-option", "-g", "@before", "kept"], ["select-pane", "-t", "%4294967294"], ["set-option", "-g", "@after", "never run"] ]) puts "success: #{group.success?}" puts "steps: #{group.steps.map { |step| step.fetch(:outcome) }.join(', ')}" puts "@before: #{server.options(scope: :session).get('@before').as(:string)}"endIt prints:
success: falsesteps: unknown, unknown, unknown@before: keptThe first command keeps its effect when the second fails, and the third never runs. The group recipe checks the same.
Packages¶
Start with libtmux. Add the companion for your caller:
| Gem | Require | Use it for |
|---|---|---|
| libtmux¶ | libtmux | Blocking scripts, snapshots, and control connections |
| libtmux-async¶ | libtmux/async | Concurrent commands and bounded streams in Async tasks |
| libtmux-mcp¶ | libtmux/mcp | An MCP server with explicit endpoints and tool policy |
| libtmux-workspace¶ | libtmux/workspace | YAML/JSON workspace plans and a CLI to apply them |
libtmux: the core¶
This gem: blocking server, session, window and pane handles, immutable snapshots, and control connections. The sections above run against it.
API reference · Guides · RubyGems
libtmux-async: concurrent commands¶
Concurrent commands and bounded control streams inside an Async task that your
application owns. Require libtmux/async, then open a scope with
LibTmux::Async.open.
API reference · Guide · RubyGems
$ gem install libtmux-async --preCapture every pane concurrently with Scope#map. server
is an open LibTmux::Server:
Async do |parent| LibTmux::Async.open(parent: parent, server: server) do |scope| refs = scope.server.list_panes.map(&:ref) screens = scope.map(refs, concurrency: 2) { |ref| scope.server.pane(ref).capture } Example.check(screens.length == refs.length && screens.all?(&:success?), "concurrent captures failed") endend.waitlibtmux-mcp: a tmux server for MCP clients¶
An MCP server over stdio for one tmux server, with an explicit tool policy.
Installs libtmux-mcp; require libtmux/mcp to embed it through
LibTmux::MCP::Application.
API reference · Tools · Guides · RubyGems
$ gem install libtmux-mcp --preThe server borrows a tmux server that is already running. Start one on a socket of its own:
$ tmux -L agent new-session -d -s workAdd the server to Claude Code. Until you enable more tools, it serves
tmux_capabilities and
tmux_snapshot:
$ claude mcp add tmux -- libtmux-mcp --socket-name agent --endpoint devThe MCP README covers other clients, every tool and authored shell commands.
libtmux-workspace: YAML workspaces¶
YAML or JSON workspaces, validated, planned offline and applied to an open
server. Installs libtmux-workspace; require libtmux/workspace to use it
from Ruby through LibTmux::Workspace.
API reference · Command reference · Guides · RubyGems
$ gem install libtmux-workspace --preSave this workspace as workspace.yaml:
session_name: workenvironment: PROJECT_MODE: developmentwindows: - window_name: editor window_index: 1 layout: tiled panes: - shell_command: printf 'editor ready\n' - {}Check it without contacting tmux:
$ libtmux-workspace validate workspace.yamlWorkspace configuration is valid.The workspace README loads it into a running server and lists every option.
What it leaves out¶
- Live objects. A snapshot record is what tmux said when you read it, and a handle reads tmux again each time you list. See Captured records and Live handles.
- A terminal emulator.
Pane#capturereturns the bytes tmux holds for the screen, with the flags above to shape them. - An implied server.
Endpoint.newneeds an explicit socket, and no API in the recipes uses the default server. - Replay. A reconnected control connection reports a gap and never replays requests or missed output. See Control flow.
- Transactions. A group runs in order and keeps the effects of the steps before a failure.
- Whole-program typing. RBS declarations ship with the gem and selected installed calls are checked. See Signature proof.
Support and status¶
Alpha. APIs may change between releases. See the initial alpha notes.
- Ruby 3.3 or newer. The gem depends on
json,tmpdir,securerandom,fiddleandio-console. - tmux 3.3a or newer. Unpatched 3.3 is below the minimum. A server handle checks the connected daemon’s release before it sends a command, and refuses an older daemon even when its client is newer.
- Control connections need tmux 3.7 or newer. An older daemon refuses
open_controlbefore a control client attaches. Subprocess operations and snapshots do not need it. The optional feature table lists the other per-release requirements. - Linux, including WSL, and macOS. The compatibility workflow runs Ruby 3.3, 3.4 and 4.0 with tmux 3.3a through 3.8 on Ubuntu 24.04. macOS is supported on a best-effort basis, and CI coverage for it is highly limited.
- tmux master and old daemons. On Ubuntu the workflow also runs the suite against a pinned tmux master, and checks that tmux 3.0a, 3.1c, 3.2a and 3.3 daemons are refused.
- Per-revision results. Read the workflow’s results for your revision before relying on one combination.
Documentation¶
| Reference¶ | Every public class and method, one page each |
| Guides¶ | Getting started, server bindings and the Async scope |
| Recipes¶ | Complete programs with checked excerpts |
| Execution modes¶ | Blocking calls, Async tasks and control subscriptions |
| Ownership and errors¶ | What close and failure mean |
| Public behavior¶ | Defaults, ownership, blocking and failure of each call |
| Field catalog¶ | Query fields and wire names |
| Public methods¶ | Each exported method, its source and its contract |
| Contributing¶ | Local builds, checks and the test loops |
MIT. See LICENSE.