Drive tmux from C#: servers, sessions, windows, panes, clients, options, hooks and buffers, typed and asynchronous, for tmux 3.3a and newer on net8.0 and net10.0.
Run a command and read its exit status, find and filter what a server holds, type into a pane and wait for the answer, and choose how a call reaches tmux.
Add it to your project:
$ dotnet package add LibTmux --prereleaseAlpha. Releases carry an
-alphaprerelease tag, and any release may change or remove exported identifiers without a deprecation period. Pin an exact version. It is not recommended for production.
Contents — Quick start · Is this for you? · Packages · Find things · Run a command · Filter · Options · Versions · Choose a path · Testing · Workspaces · Assistants · Leaves out · Support
Connect to the tmux server that is running, make a session, a window, and a pane, and type a command into the pane. This is the whole program:
using LibTmux;
// Requires a tmux server already listening on this socket:
// ConnectAsync() discovers one, it never starts one. With nothing
// running yet, call Server.CreateOwnedAsync() instead.
Server server = await Server.ConnectAsync();
Session session = await server.CreateSessionAsync(new NewSessionRequest { Name = "build" });
Window window = await session.CreateWindowAsync(new NewWindowRequest { Name = "tests" });
Pane pane = (await window.GetPanesAsync())[0];
await pane.SendTextAsync("dotnet test");To run it, make a console project and paste the program into Program.cs, with
tmux 3.3a or newer on PATH and a server running:
$ dotnet new console --output demo && \
cd demo && \
dotnet package add LibTmux --prereleaseIt prints nothing. The new session build has a window tests, and its pane
has received dotnet test. With no server running, call
Server.CreateOwnedAsync; it starts one and
stops it when disposed.
Every csharp run block below runs against a tmux server of its own, with
server, session, window, pane, and ct already in hand.
Yes, if you want to drive a real terminal from code: build a dev environment, script a workspace, harness a TUI in tests, or give an assistant hands on a terminal.
Look elsewhere if you want a terminal emulator (this drives tmux and draws
nothing), full Windows support, or a process launcher: Process.Start is right
there. Native Windows has a bounded query-only
Psmux* preview.
What you get over a shell wrapper: typed entities with real IDs, one dispatch model per workload, a version model that names the flag a tmux lacks, and examples that run against live tmux in CI.
| Package | Add it when | |
|---|---|---|
| LibTmux | Always. The client. One dependency: logging abstractions. | |
| LibTmux.FSharp | You write F# and want curried task helpers, ordinary seq queries, and typed portable filters over the core values. |
|
| LibTmux.Query.Json | You send queries between processes and want them as JSON. | |
| LibTmux.Testing | You test code that drives tmux and want scopes that clean up after themselves. | |
| LibTmux.Extensions.DependencyInjection | Your application composes services and wants a tmux handle injected. | |
| LibTmux.Workspace | You have tmuxp YAML to build from. | |
| LibTmux.Workspace.Cli | You want tmux-workspace: load, freeze and convert tmuxp files from a shell. Installs as a tool. |
|
| LibTmux.Mcp | You want an assistant driving tmux. Installs as a tool. |
$ dotnet package add LibTmux --prereleaseThe core takes exactly one dependency. Anything that would add another ships as its own package, so a caller who does not want YAML never sees YamlDotNet.
They all carry one version, so any LibTmux.Workspace goes with the LibTmux
of the same version, without a table to consult.
LibTmux.FSharp is the F# companion built on
LibTmux. Both packages are
maintained in the libtmux organization by the same primary author.
It keeps the core handles and task I/O, then adds F#-native composition for snapshots and portable filters. Start with its runnable F# quickstart.
Session.GetWindowsAsync: windows and panes
One call reads a session's windows, and
Window.GetPanesAsync reads a window's panes. They
return IReadOnlyList<T> and run no tmux command while you enumerate them:
foreach (Window each in await session.GetWindowsAsync(ct))
{
foreach (Pane every in await each.GetPanesAsync(ct))
{
Console.WriteLine($"{each.Name} pane {every.Index} {every.Width}x{every.Height}");
}
}It prints one line per pane: the window name, the pane index, and its size.
Window.RenameAsync: a changed copy
A handle holds what tmux said when it was read. An operation that changes an
object returns a replacement, and Pane.RefreshAsync
asks tmux again:
Window renamed = await window.RenameAsync("integration", ct);
Console.WriteLine($"{window.Name} -> {renamed.Name}");It prints the old name and the new one.
A replacement is still the same window. Identity is the server generation plus the typed ID, so two handles that read different names name one window, and a handle read from a restarted server does not, even where tmux reused the number:
Window sameWindow = await window.RenameAsync("integration", ct);
Console.WriteLine($"{window == sameWindow} {window.Name} {sameWindow.Name}");
// Identifiers order the way tmux issued them, oldest first.
IEnumerable<Window> oldest = (await session.GetWindowsAsync(ct)).OrderBy(each => each.Id);
Console.WriteLine(string.Join(", ", oldest.Select(each => each.Id)));It prints True, both names, and the window IDs in the order tmux issued them.
Pane.RunAsync: a command's exit status
It runs a shell command in a pane and returns its real exit status and the lines it printed. A command that exits nonzero returns normally with that status:
PaneRunResult result = await pane.RunAsync("echo hello; exit 3", TimeSpan.FromSeconds(10), ct);
if (result.ExitStatus != 3 || !result.Output.Contains("hello"))
{
throw new InvalidOperationException("Expected exit status 3 and the line hello.");
}It prints nothing and throws if the status or the line is wrong.
TmuxWait.UntilAsync: wait for what a command prints
For input a shell command cannot carry, type it and wait. tmux accepts a command before the shell has finished it, so the example waits for the result:
await pane.SendTextAsync("echo hello-from-libtmux", cancellationToken: ct);
await pane.EnterAsync(ct);
// tmux accepts a command before the shell has finished it, so the result is
// waited for rather than assumed.
string output = await TmuxWait.UntilAsync(
async token => string.Join('\n', await pane.CaptureAsync(cancellationToken: token)),
text => text.Contains("hello-from-libtmux", StringComparison.Ordinal),
TimeSpan.FromSeconds(10),
TimeSpan.FromMilliseconds(20));It returns once the pane shows the line.
Ordinary filtering is LINQ over what you already read:
IReadOnlyList<Window> windows = await session.GetWindowsAsync(ct);
IEnumerable<Window> building = windows.Where(
each => each.Name.StartsWith("build", StringComparison.Ordinal));QueryExtensions.Matching: declarative filters over what you hold
It turns an expression into a portable document, or throws; it never quietly falls back to filtering in memory:
IReadOnlyList<Session> sessions = await server.GetSessionsAsync(ct);
IReadOnlyList<Session> building = sessions.Matching<Session>(
session => session.Name.StartsWith("build", StringComparison.Ordinal));QueryExtensions.Translate: the same filter as a document
The expression is also a document, written here and answered somewhere else. LibTmux.Query.Json puts it on the wire:
QueryDocument document = QueryExtensions.Translate<Session>(
session => session.Name.StartsWith("build", StringComparison.Ordinal)
&& session.Attached);
Console.WriteLine(document.Target); // SessionIt prints the document's target, Session.
- The document uses stable wire names:
Session.Nameissession_nameandClient.IsControlClientisclient_control_mode. The catalog carries all twelve queryable fields. - A field outside the catalog throws.
UnsafeTmuxFilteris the separate opt-in for native tmux-ffilters.
TmuxOptions and TmuxHooks: settings and callbacks
Every object reaches the option table tmux keeps for it. tmux has no types, so a value carries the text it reported alongside the readings that text supports:
await window.Options.SetAsync(new SetOptionRequest("automatic-rename", "off"), ct);
TmuxOption option = (await window.Options.GetAsync(
new GetOptionRequest("automatic-rename"), ct))[0];
Console.WriteLine($"{option.Value.Raw} flag={option.Value.Boolean} number={option.Value.Integer}");It prints the raw value, its flag reading, and its number reading.
An option the window does not hold reads as the inherited value. Hooks are arrays even with one entry:
TmuxHook hook = await server.Hooks.SetAsync(
new SetHookRequest("alert-bell", "set-option -g @rang yes"), ct);
Console.WriteLine(hook.Values[0].Command);TmuxVersionTooLowException: a command the running tmux lacks
Where a flag is missing, the request goes out without it and a warning names what was left off. Where a whole command is missing, nothing is sent and this exception names the version that would be needed:
// A handle says what it read: the version is what tmux reported when this
// server was reached, and null when it reported something unparsable.
TmuxVersion? version = server.Version;
Console.WriteLine($"tmux {version?.Raw} 3.4-or-newer={version?.IsAtLeast(TmuxVersion.Parse("3.4"))}");It prints the version tmux reported and whether it is 3.4 or newer.
A tmux below 3.3a is refused the same way.
Upgrade tmux and restart the tmux server, or use LibTmux 0.0.0-alpha.21, the last release that supports tmux 3.2a. Every measured difference between 3.3a and 3.8 is recorded with the test that proves it.
Which one a call uses is visible where the call starts, and all three work on every supported tmux.
| Mode | Flip it on | Dispatch | What one more command costs |
|---|---|---|---|
| One-shot | session.CreateWindowAsync(…) |
one command, awaited | another round trip on a shared client; a process for the few commands that cannot use it |
| Control | server.EnterControlModeAsync(ct) |
one client, streamed | another round trip — ~0.2 ms |
| Chained | server.Chain()…ExecuteAsync(ct) |
N batched, one invocation | more bytes on one command line — ~0.02 ms |
Each figure is the marginal cost of one more command, as medians of 100 samples against tmux 3.7b.
Absolute timings move with the machine, so the recorded run gives the distribution, the tmux, the host, and the date. Choosing a mode has allocations and how to rerun the table.
Session.CreateWindowAsync: one command, a typed object back
// One-shot: a command, a typed object back.
Window built = await session.CreateWindowAsync(new NewWindowRequest { Name = "build" }, ct);
Console.WriteLine(built.Name);Server.EnterControlModeAsync: one client, held open
// Control mode: one client, held open, streaming what tmux does.
await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
IReadOnlyList<string> reply = await control.SendAsync(
TmuxCommand.Create("new-window", "-d", "-n", "build"),
ct);Server.Chain: many commands, one invocation
// Chained: many commands, one invocation.
await server.Chain()
.Then("new-window", "-d", "-n", "build")
.Then("new-window", "-d", "-n", "test")
.ExecuteAsync(ct);Control mode is an order of magnitude cheaper per command, because its client is already running. A chain wins for a batch by paying one round trip for the whole sequence.
LibTmux.Testing gives a test a tmux server of its own, on its
own socket, killed deterministically:
using LibTmux.Testing;
TmuxTestFactory factory = new();
await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync();
await scope.Pane.SendTextAsync("echo hello");Disposing kills the server, so a test that fails part way through leaves nothing
behind. TmuxWait.UntilAsync, in the client package,
waits for a state that production code also needs when it reads back what a
command produced.
LibTmux.Workspace.Cli installs
tmux-workspace, which loads, captures and converts
tmuxp workspace files:
$ dotnet tool install --global LibTmux.Workspace.Cli --prereleaseWith a workspace file saved as ~/.tmuxp/myproject.yaml, such as the
example in the package README,
build it without attaching, on a tmux socket of its own:
$ tmux-workspace load -d -L demo myprojectList the workspaces it can find:
$ tmux-workspace lsLibTmux.Mcp is a Model Context Protocol server. It is a separate package that installs as a .NET tool:
$ dotnet tool install --global LibTmux.Mcp --prerelease{ "mcpServers": { "tmux": { "command": "libtmux-mcp" } } }- It exposes 45 tools from the
inspect(18),manage(14),execute(9), andteardown(4) sets, plus the statictmux://capabilitiesresource. - Existing and user-configured sockets omit teardown by default and expose 41.
- The full reference is generated from the server itself.
- Pin one socket for the process with
LIBTMUX_SOCKETorLIBTMUX_SOCKET_PATH.
It is built so an assistant never gets stuck and never wastes context:
run_shell_commandreturns the shell's real exit status.wait_for_textwakes from tmux's control-mode stream on tmux 3.7 and newer, and polls on older releases. If that stream cannot start, the wait ends unless you setLIBTMUX_MCP_ALLOW_POLLING_FALLBACK=true.- Nothing returns unbounded output.
capture_panereads visible text and scrollback, whilesnapshot_pane,search_panes, andcapture_sincegive structured and incremental reads. - Pane modes belong to attached humans. The server never enters, drives, or exits one, and input refuses a pane that is in one. Wait for the person to leave the mode; do not cancel it.
- Toolset and tool selection is frozen at startup and reported through
tmux://capabilities.
An MCP client starts the server itself and speaks to it over standard input and output. Register it with Claude Code:
$ claude mcp add tmux -- libtmux-mcpA workspace that tmux-workspace load -L demo built lives on the socket demo.
To let a client read it and change nothing, name the socket and select the
inspect toolset:
$ claude mcp add tmux \
-e LIBTMUX_SOCKET=demo \
-e LIBTMUX_TOOLSETS=inspect \
-- libtmux-mcpA client that reads a JSON configuration takes the command and the same settings
as env:
{
"mcpServers": {
"tmux": {
"command": "libtmux-mcp",
"env": { "LIBTMUX_SOCKET": "demo", "LIBTMUX_TOOLSETS": "inspect" }
}
}
}LibTmux: the client
Servers, sessions, windows, panes, and buffers, typed and asynchronous, with one dependency: the logging abstractions.
Reference: libtmux.org and NuGet.
LibTmux.FSharp: F# helpers over the core
Curried task helpers, ordinary seq queries, and typed portable filters over
the core handles. Start with its
quickstart.
Reference: libtmux.org, guides, and NuGet.
LibTmux.Query.Json: query documents as JSON
A filter written in one process can be evaluated in another; the core does not
reference System.Text.Json.
Reference: QueryJson and
NuGet.
LibTmux.Testing: a tmux server for each test
Scoped servers, sessions, and windows that clean up after themselves.
Reference: TmuxTestFactory, the
testing guide, and
NuGet.
LibTmux.Extensions.DependencyInjection: a Server for dependency injection
AddLibTmux registers a Server as a singleton and binds
ServerConnectionOptions through IOptions<T>.
Reference: Server, the
package README,
and
NuGet.
LibTmux.Workspace: sessions from tmuxp files
Parses tmuxp YAML or JSON and builds sessions, in one call or from a plan you review first.
Reference: WorkspaceBuilder, the
reference index, and
NuGet.
LibTmux.Workspace.Cli: the tmux-workspace command
Loads, captures, and converts tmuxp workspace files from a shell.
Reference: the command reference and NuGet.
LibTmux.Mcp: a tmux server for assistants
A Model Context Protocol server that gives an assistant hands on tmux; it installs as a .NET tool.
Reference: the MCP guide, the tool reference, and NuGet.
- Live objects. A handle holds what tmux said when you asked.
Pane.RefreshAsyncasks again. - Synchronous calls. Every call that reaches tmux is asynchronous.
- Silent fallbacks. Declarative filtering throws on an expression it cannot translate. A flag the running tmux lacks is left off, and a warning names it.
- YAML, JSON, and MCP in the core. Workspace files, query documents on the wire, and the MCP server are separate packages, so a caller who does not want them never sees their dependencies.
- A terminal emulator. This drives tmux and draws nothing.
- Native Windows.
PsmuxServerand its siblings read one psmux session and cannot create or change anything. See the preview.
- Alpha. Any release may change or remove exported identifiers.
- tmux. Stable 3.3a and newer. CI builds 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b,
3.7c, and 3.8. Development, release-candidate, and
next-*versions have unknown capability state. - .NET. net8.0 and net10.0.
- Systems. Linux, including WSL. macOS is best effort, with highly limited CI coverage. CI builds on Windows x64, where the types that need a real tmux are annotated unsupported.
- Windows preview. The
Psmux*preview reads a Windows psmux session natively or from WSL. It is best effort, with highly limited CI coverage; its smoke harness runs by hand. - Trimming and NativeAOT. The
LibTmuxcore is analyzer-gated, and its smoke app is published and run forlinux-x64on net8.0 and net10.0. - Trimmed callers.
CompileandMatchingresolve properties by name and warn callers to preserve the filtered types' public properties.
- Guides on libtmux.org — tutorials, topics and the workspace command reference
- Choosing a mode — the three dispatch modes, measured
- Windows psmux preview — what it reads, and what it refuses
- API reference — rendered from the doc comments
- tmux MCP tools — every tool, capability row and resource, generated
- API policy — ownership and parity rules
- Version deltas — every tmux difference, with the test that proves it
- Decisions — why the transport, object model and query catalog are shaped this way
- Examples — every example here is a test, and the C# in this README is quoted from one
- AGENTS.md — how to work in this repository
Please use the following BibTeX template to cite libtmux-dotnet in scientific discourse:
@misc{libtmux-dotnet,
author = {Tony Narlock},
year = {2026},
note = {https://libtmux.org/en/csharp/},
title = {libtmux-dotnet: C\# wrapper for tmux}
}