Create tmux sessions, split windows, send input, and capture pane output from
Ruby. Read server state into a snapshot, then query it with
where, select, and the rest of
Enumerable.
Quick start · Find things · Packages · Guide · API reference · Recipes · API on libtmux.org
It suits scripts, tests that need a real tmux, and tools that drive a terminal program. An MCP server, an Async adapter and a workspace loader ship beside the core: see Packages.
Contents — Quick start · Drive a pane · Find things · Several commands · Watch tmux · Your own server · Packages · Build from source · Leaves out · Support · Documentation
Install the core gem. It needs Ruby 3.3 or newer and tmux 3.3a or newer on
PATH:
$ gem install libtmuxThis program creates a session, adds a window, splits it, and prints each window with its panes:
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.snapshot
end
snapshot.windows.each do |window|
puts "#{window.name}: #{window.panes.map(&:id).join(', ')}"
endSave it as quickstart.rb and run it:
$ ruby quickstart.rbIt prints:
main: %0
logs: %1, %2
Server.start gives the program a tmux server of its own, on
its own socket with an empty configuration, and closes it when the block exits.
It never touches a tmux you already use. Pane commands take argument arrays.
Pane#split: split a pane, type into it, resize it
send_text types literal text, send_keys
sends key names such as Enter, and capture reads the screen.
Pane#resize and Window#resize set sizes in
cells:
require "libtmux"
LibTmux::Server.start do |server|
created = server.new_session(name: "work", command: ["/bin/sh"], receipt: true)
pane = created.pane
side = pane.split(direction: :horizontal, size: "40%", command: ["/bin/cat"])
pane.send_text("echo ready")
pane.send_keys("Enter")
line = nil
50.times do
line = pane.capture.stdout.lines(chomp: true).find { |text| text == "ready" }
break if line
sleep 0.1
end
puts "captured: #{line}"
created.window.resize(width: 100, height: 30)
side.resize(width: 20)
puts "side pane: #{side.snapshot.width} columns"
endIt prints captured: ready and side pane: 20 columns.
Entity#kill removes a pane, window or session, and
Server#kill stops a daemon. More:
pane input and capture.
Server#snapshot: one read, many queries
where takes criteria as data and
select takes a Ruby block. Both filter the snapshot
locally and make no tmux calls:
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.snapshot
end
panes = snapshot.panes
active_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")
puts "active: #{active_ids.join(', ')}"
puts "wide: #{wide_panes.map(&:id).join(', ')}"
puts "logs panes: #{logs.panes.length}"
puts "missing: #{missing.inspect}"It prints:
active: %0, %1
wide: %0, %1
logs panes: 2
missing: nil
oneraisesNoMatchErrororMultipleMatchesErrorunless exactly one record matches.one_or_nilreturnsnilfor none.- A selection keeps the membership it captured. Call
server.snapshotagain to see later changes.
More: the field catalog and the list and filter recipe.
Server#explain_panes: what runs in tmux, what runs in Ruby
explain_panes returns the plan for a pane query and
sends nothing to tmux. search_panes runs it. Today tmux
returns the whole server and Ruby applies every criterion:
require "libtmux"
LibTmux::Server.start do |server|
server.new_session(name: "work", command: ["/bin/cat"])
criteria = {active: true}
plan = server.explain_panes(where: criteria)
puts "tmux reads: #{plan.fetch(:capture)}"
puts "tmux filters: #{plan.fetch(:pushed) || 'nothing'}"
puts "Ruby filters: #{plan.fetch(:residual).fetch('where').keys.join(', ')}"
puts "matching panes: #{server.search_panes(where: criteria).length}"
endIt prints:
tmux reads: full_graph
tmux filters: nothing
Ruby filters: active
matching panes: 1
More: execution modes.
Window#snapshot: read one object again
A record is what tmux said when it was read. Ruby has no refresh that
updates a record in place, as Python libtmux's .refresh() does: read the
object again.
Window#snapshot returns a new record and leaves the one you
hold as it was. Session#snapshot and
Pane#snapshot do the same:
require "libtmux"
LibTmux::Server.start do |server|
session = server.new_session(name: "work", window_name: "editor", command: ["/bin/cat"])
window = session.list_windows.fetch(0)
held = window.snapshot
window.rename("build")
puts held.name
puts window.snapshot.name
endIt prints:
editor
build
Each call reads the whole server, as server.snapshot does,
and returns the one record you asked for.
Server#run_group: several commands through one client
A group sends 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: false
steps: unknown, unknown, unknown
@before: kept
The first command keeps its effect when the second fails, and the third never runs. More: the group recipe.
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. It needs tmux 3.7 or newer:
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}"
end
endIt prints:
reply: ready
event: %window-renamed @0 renamed
More: the control overflow recipe.
Server.new: borrow a daemon
Server.open and Server.new bind an endpoint
that already exists, such as Server.open(socket_path: ...). Closing the
binding leaves the daemon running. 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: 1
sessions after client close: 1
More: ownership and errors.
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 |
Requiring a gem starts no tmux process, scheduler, or protocol server. Execution modes compares blocking calls, Async tasks, and control subscriptions.
libtmux: the core
Blocking server, session, window and
pane handles, immutable snapshots, and
control connections. Require libtmux.
API reference · Guides · RubyGems
$ gem install libtmuxMore tasks, in the core README:
- Type into a pane and read it back
- Wait, time out, and cancel
- Read and set options and hooks
- Link a window into several sessions
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-asyncCapture every pane concurrently with Scope#map:
require "libtmux"
require "libtmux/async"
LibTmux::Server.start do |server|
server.new_session(name: "work", command: ["/bin/cat"])
server.new_session(name: "logs", command: ["/bin/cat"])
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 }
puts "captured panes: #{screens.length}"
puts "all succeeded: #{screens.all?(&:success?)}"
end
end.wait
endIt prints:
captured panes: 2
all succeeded: true
libtmux-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-mcpThe 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 devMore: the 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-workspaceSave this workspace as workspace.yaml:
session_name: work
environment:
PROJECT_MODE: development
windows:
- 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.
More: the workspace README loads it into a running server and lists every option.
From a checkout, install the Ruby pinned in .tool-versions, then the development bundle:
$ mise install$ mise exec -- bundle config set --local path vendor/bundle$ mise exec -- bundle installTo use the core outside the checkout, build the artifacts:
$ mise exec -- bundle exec rake buildInstall the core into the Ruby that will run your application. Its runtime dependencies must already be installed:
$ gem install \
--local \
--no-document \
pkg/libtmux-0.1.2.gemCompanion gems need their runtime dependencies too. See Contributing for the checks.
- Live objects. A snapshot record is what tmux said when you read it, and a selection keeps the membership it captured. Read again to see a change.
- A terminal emulator.
Pane#capturereturns what tmux'scapture-paneprints for the pane. - A server it starts unasked.
Server.newandServer.openbind a running server; onlyServer.startstarts one. With no socket named,Endpoint.newpicks the socket tmux would, so a program run inside tmux reaches that tmux. See Endpoints. - Whole-program typing. RBS declarations ship with each gem. See the public methods.
- Throughput claims. The benchmarks list their workloads and limits.
- Unstable API. Releases are numbered
0.1.N, and any release may change or remove exported identifiers. Pin an exact version. See the changelog, which links each gem's own. - Ruby 3.3 or newer and tmux 3.3a or newer. A server handle refuses an older daemon before it runs a command. Release candidates are not supported. See Release candidates.
- Control connections need tmux 3.7 or newer. On an older daemon
open_controlraisesUnsupportedFeatureError, andServer#supports_control?returns false. The optional-feature table andcapabilities.jsonlist the other releases. - Linux, including WSL, and macOS. CI runs on Linux; macOS is best effort. See the compatibility workflow and the CI matrix.
- Send text and capture output: split panes, wait for output events, and round-trip binary buffers.
- Work with linked windows: address one window at several session indexes.
- Capture concurrently: read panes while another request waits, then cancel it.
- Load a workspace: parse a configuration, plan, and apply it.
- All recipes: cancellation, control streams, command groups, and MCP.
- API reference: public methods, source links, and behavioral contracts.
- Benchmarks: workloads, measurements, and their limits.
See Contributing for setup and checks.
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-ruby in scientific discourse:
@misc{libtmux-ruby,
author = {Tony Narlock},
year = {2026},
note = {https://libtmux.org/en/ruby/},
title = {libtmux-ruby: Ruby wrapper for tmux}
}MIT. See LICENSE.