Drive tmux from Go: sessions, windows, and panes as typed values, every tmux
option and hook as a typed accessor, and errors classified by what tmux actually
refused.
Run a command in a real terminal and read what it printed, find and filter
what a tmux server holds, type into a pane and wait for its answer, and hear
about changes as tmux makes them. It suits tools that drive a terminal
program, test suites that need a real tmux, and agents that share a terminal.
Add it to your module:
$ go get github.com/libtmux/libtmux-go/tmux@latest
Alpha software. Releases carry an -alpha prerelease tag and the API is not
settled. Pin an exact version.
It needs Go 1.26 or newer and tmux 3.2a or newer, on a Unix host. A tmux
server for AI agents and a tmuxp-style workspace builder ship beside the
library, under Packages.
Run a command in tmux and print what its terminal showed. This is the whole
program:
// Command quickstart runs a command in tmux and prints what it showed.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"github.com/libtmux/libtmux-go/tmux"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) (err error) {
// A private server: a socket and an empty config of its own, so this
// neither touches nor is changed by a tmux you already use.
server, err := tmux.NewServer(tmux.ServerOptions{
SocketName: "libtmux-quickstart",
ConfigFile: os.DevNull,
})
if err != nil {
return fmt.Errorf("configure tmux: %w", err)
}
session, err := server.NewSession(ctx, tmux.NewSessionRequest{Name: "demo"})
if err != nil {
return fmt.Errorf("create session: %w", err)
}
// Cleanup runs even when ctx is the reason the program is leaving.
defer func() {
err = errors.Join(err, server.Kill(context.WithoutCancel(ctx)))
}()
// The command runs in a window of its own, with a terminal.
result, err := session.Run(ctx, "echo hello from tmux", tmux.RunOptions{})
if err != nil {
return fmt.Errorf("run command: %w", err)
}
for _, line := range result.Lines {
fmt.Println(line)
}
fmt.Println("exit status:", result.Status)
return nil
}
It prints:
hello from tmux
exit status: 0
To run it, save it as main.go in an empty directory, with tmux 3.2a or newer
on PATH:
$ go mod init demo && \
go get github.com/libtmux/libtmux-go/tmux@latest && \
go run .
The program starts a tmux server on a socket and config of its own and kills
it on the way out, so it leaves nothing behind and never touches a tmux you
have open. Drop ServerOptions to its zero value and it
drives the same server your tmux command does.
Session.Run is os/exec for a program that needs a
terminal: the command runs in a window of its own with a tty, and what comes
back is its exit status and the screen tmux rendered. A nonzero status is a
result, not an error.
examples/run-to-completion shows one, on a
tmux server of its own.
Every Go block on this page comes from a program or test that runs against a
real tmux on every supported release, and each output shown is what that run
printed.
// Given: ctx context.Context; server tmux.Server
sessions, err := server.Sessions(ctx)
if err != nil {
return fmt.Errorf("list sessions: %w", err)
}
for _, session := range sessions {
name, _ := session.Name()
fmt.Println("session:", name)
}
Each block on this page is the body of a function that returns an error, with
the values its first line names already in hand.
examples/filter-query runs this and the two blocks
below on a tmux server of its own. Like every example, it runs from a clone
of the repository, with go -C examples run ./filter-query;
examples/README.md has the commands to clone it.
This one prints session: libtmux-filter.
What comes back is a record: a Session you hold is what tmux said when you
asked, not a live handle that changes underneath you. When no tmux server is
running, a listing returns an error matching ErrNoServer
rather than an empty list, so "nothing there" and "nobody to ask" stay
different answers.
One command goes out and only the matches come back:
// Given: ctx context.Context; server tmux.Server
live := tmux.TmuxFilter("#{==:#{session_name},libtmux-filter}")
sessions, err := server.SearchSessions(ctx, &live)
if err != nil {
return err
}
fmt.Println("live matches:", len(sessions))
One session has that name, so this prints live matches: 1.
The filter is a tmux format, written the way the FORMATS section of the tmux
manual describes, and tmux evaluates it while it lists. tmuxq/ has
the filters that run in Go and the helpers that apply them.
A pane is an io.Writer through Pane.Writer, and a
newline is the Enter key. Open an observation with
Pane.OpenObservation before typing, then wait
for text the command prints:
// Given: ctx context.Context; pane tmux.Pane
// Open the observation before typing, so no output is missed.
output, err := pane.OpenObservation(ctx)
if err != nil {
return fmt.Errorf("observe pane: %w", err)
}
defer func() { err = errors.Join(err, output.Close()) }()
// A pane is an io.Writer, and a newline is the Enter key.
command := `echo "6 x 7 = $((6 * 7))"`
if _, err := fmt.Fprintln(pane.Writer(ctx), command); err != nil {
return fmt.Errorf("type command: %w", err)
}
// The terminal echoes what is typed, so wait for the answer: the typed
// line says "$((6 * 7))" and never "42".
if _, err := output.WaitFor(ctx, func(text string) bool {
return strings.Contains(text, "6 x 7 = 42")
}); err != nil {
return fmt.Errorf("wait for answer: %w", err)
}
fmt.Println("the pane answered 6 x 7 = 42")
tmux pushes each write as it happens, so nothing polls. The terminal echoes
what is typed, and that echo is output too, which is why the wait names the
answer rather than the request.
Runnable: examples/pane-io, which goes on to read the
pane as an io.Reader.
Typing is the wrong way to move a payload: the shell in the pane reads quotes,
$, and backslashes in it as shell syntax.
Server.LoadBufferFrom fills a tmux buffer from
an io.Reader instead, and Pane.CaptureTo writes a
scrollback to an io.Writer. examples/byte-streams
does both.
It returns a stream of what tmux does, such as a window opening or a session
being renamed. tmux pushes each change when it happens, so nothing has to
guess how often to ask:
// Given: ctx context.Context; session tmux.Session
stream, err := session.OpenNotifications(ctx, tmux.NotificationOptions{})
if err != nil {
return fmt.Errorf("open notification stream: %w", err)
}
defer func() { err = errors.Join(err, stream.Close()) }()
// Rename after subscribing; notifications do not include earlier changes.
if _, err := session.Rename(ctx, "control-example"); err != nil {
return fmt.Errorf("rename session: %w", err)
}
for {
notification, err := stream.Next(ctx)
if err != nil {
return fmt.Errorf("read notification: %w", err)
}
fmt.Printf("notification: %s\n", notification.Kind())
if notification.Kind() == tmux.ControlNotificationSessionRenamed {
fmt.Println("heard the rename")
break
}
}
The first notification a new stream reads is %session-changed, which is
tmux telling the client which session it joined. The rename follows it.
This prints notification: %session-changed, then
notification: %session-renamed, then heard the rename.
A subscription goes further: tmux evaluates a format for you and reports it
each time its value changes, so a program hears about a window count or a
pane's current command without asking again:
// Given: ctx context.Context; session tmux.Session;
// stream *tmux.NotificationStream
// A subscription is a format tmux evaluates for you: it reports the value
// when it first looks, about a second later, and then each time it changes.
if err := stream.Subscribe(ctx, tmux.SubscriptionRequest{
Name: "windows", Format: "#{session_windows}",
}); err != nil {
return fmt.Errorf("subscribe: %w", err)
}
if _, err := session.NewWindow(ctx, tmux.NewWindowRequest{}); err != nil {
return fmt.Errorf("open window: %w", err)
}
for {
notification, err := stream.Next(ctx)
if err != nil {
return fmt.Errorf("read notification: %w", err)
}
if change, ok := notification.Subscription(); ok && change.Value == "2" {
fmt.Println("session has", change.Value, "windows")
return nil
}
}
The session started with one window, so this prints session has 2 windows.
Start with a plain Server, which starts one tmux process per command. The
other paths keep a tmux client attached, or send several commands at once, to
start fewer:
A plan records commands instead of running them, sends the ones needing no
answer together, and hands back a reference to what a step will create.
new("echo built") is how Go 1.26 writes a pointer to a value, for a field
that may be left unset:
Plan.Run sends it, and reports on each step by itself even
where several shared one tmux process:
// Given: ctx context.Context; server tmux.Server; plan *tmux.Plan
result, err := plan.Run(ctx, server)
if err != nil {
return fmt.Errorf("run plan: %w", err)
}
if !result.OK() {
return fmt.Errorf("plan did not complete: %w", result.Err())
}
// Results remain per operation even when operations share an invocation.
for index, op := range result.Ops {
fmt.Printf("step %d %-16s %-8s created %q stdout %q\n",
index, op.Command, op.Status, op.Created, op.Stdout)
}
It prints operations: 6, tmux invocations: 4 before it runs the plan: six
tmux commands, in four process starts rather than six. The last step reads
back the title the plan set, so its line ends stdout ["editor"].
A connection appears in list-clients and counts toward session_attached,
which is why opening one is explicit. "Execution modes" in the package
documentation has the rules each path follows, and
BENCHMARKS.md is what each costs, measured on every
supported tmux. examples/fast-path runs the same reads
down each path.
A wait that runs out fails with the screen the pane last held, which is what
you would have added a print statement to see. This one was waiting for
ready:
tmuxtest: pane %1 never showed a line containing "ready"
the pane showed 3 line(s):
| tmuxtest$ printf 'loading widgets\nconnecting\n'; cat
| loading widgets
| connecting
More in tmuxtest
tmuxtest.NewServer gives a test whose subject is tmux
itself a server on a socket of its own, killed when the test ends.
tmuxtest.ScriptedTmux answers the tmux commands you
name instead, so a test of your own code needs no tmux installed.
tmux/tmuxtest shows both.
$ go install github.com/libtmux/libtmux-go/mcp/cmd/libtmux-mcp@latest
The workspace CLI:
$ go install github.com/libtmux/libtmux-go/workspace/cmd/tmux-workspace@latest
What it leaves out
Live objects. A Session or a Pane is a
record of what tmux reported when it was read, and does not change when
tmux does. Read again, or subscribe, to see a change.
A terminal emulator. A capture and Session.Run
return the screen tmux drew. PaneObservation
returns the bytes a program wrote.
Your tmux configuration file. Options and hooks are read and set on a
running server, and nothing here edits ~/.tmux.conf. tmux reads it when
it starts a server, unless
ServerOptions.ConfigFile names another.
Support and status
Any release may change or remove exported identifiers without a deprecation
period, so pin an exact version. Not recommended for production.
No runtime dependencies. The core module imports only the standard
library.
Go 1.26+, rising with Go's own support window, about twice a year.
tmux 3.2a through 3.8-rc2 across the core, workspace, and MCP modules,
tested on releases spanning that range.
Unix. Linux and macOS are tested on every change, and the BSDs, illumos,
and Plan 9 build. A Windows build compiles, so a cross-platform program
still builds, but there is no tmux there to reach.
Tagged per directory. Each module carries its own version: the tags are
mcp/vN and workspace/vN beside the core's plain vN. Pin the exact ones
you want in your own go.mod; the commands here fetch the newest.
Documentation
The package documentation is the reference, written to be read start to finish
rather than searched:
$ go doc github.com/libtmux/libtmux-go/tmux
It opens with a task index, and carries the rule mapping a tmux command to
its Go method: kill-pane is Pane.Kill, and
rename-session is Session.Rename. A command usually
leads to its method without a lookup.