Scripting: Driving tmux From Shell Scripts
Why Script tmux
If you open the same three windows in the same layout every morning, that's a script. If every new project spawns an editor, a shell, and a log tail in the same arrangement, that's a script. Scripting saves keystrokes and makes environments reproducible.
tmux is built to be scripted. Every command you can run at the <prefix> : prompt works from the shell just as well.
The Core Commands
tmux new-session -d -s work -c ~/code/work # create, detached
tmux rename-window -t work:0 editor # rename first window
tmux send-keys -t work:editor "vim ." Enter # type a command
tmux split-window -h -t work:editor -c ~/code/work # add a pane
tmux send-keys -t work:editor.1 "tail -f log" Enter # type in the new pane
tmux new-window -t work -n shell -c ~/code/work # another window
tmux select-window -t work:editor # focus the editor window
tmux attach -t work # finally, attach
That snippet starts a detached session, sets up a layout, then attaches. Paste it into a file, chmod +x, and you have a reusable starter.
A Project Starter Script
#!/usr/bin/env bash
set -euo pipefail
SESSION="api"
ROOT="$HOME/code/api"
if tmux has-session -t "$SESSION" 2>/dev/null; then
tmux attach -t "$SESSION"
exit 0
fi
tmux new-session -d -s "$SESSION" -c "$ROOT"
tmux rename-window -t "$SESSION:1" editor
tmux send-keys -t "$SESSION:editor" "vim ." Enter
tmux new-window -t "$SESSION" -n shell -c "$ROOT"
tmux new-window -t "$SESSION" -n tests -c "$ROOT"
tmux send-keys -t "$SESSION:tests" "npm test -- --watch" Enter
tmux new-window -t "$SESSION" -n logs -c "$ROOT"
tmux send-keys -t "$SESSION:logs" "tail -f /tmp/api.log" Enter
tmux select-window -t "$SESSION:editor"
tmux attach -t "$SESSION"
Save as bin/start-api. Running it either starts the session or attaches to the existing one. Once this pattern clicks, you'll write one per project.
has-session
The has-session guard is essential:
if tmux has-session -t "$SESSION" 2>/dev/null; then
tmux attach -t "$SESSION"
exit 0
fi
Without it, the script errors out if the session exists. With it, you get idempotent behaviour: run the script any time and end up inside the session, regardless of whether it was fresh or already running.
send-keys in Depth
tmux send-keys -t target "text"
Sends literal characters. Does not press Enter; the cursor sits at the end of "text" as if you typed it.
Add named keys as extra arguments:
tmux send-keys -t target "make test" Enter
tmux send-keys -t target "C-c" # interrupt
tmux send-keys -t target Escape ":wq" Enter # simulate vim :wq
Common special keys
Enter Space Tab Escape BSpace
Up Down Left Right
Home End PgUp PgDn
F1 F2 ... F12
C-a M-x (Ctrl-a, Alt-x)
Literal -l
If your text starts with a dash or contains characters tmux would misinterpret, use -l:
tmux send-keys -l "-- literal --"
-l means "send literally; don't interpret key names".
Targeting in Scripts
Be explicit. A script that assumes the current session is right will break the one time you run it from another session.
SESSION="work"
tmux new-window -t "$SESSION" ...
tmux send-keys -t "$SESSION:editor" ...
tmux send-keys -t "$SESSION:shell.1" ...
Full form: session:window.pane. Window and pane can be numeric indices or names. Names must be unique within the session for names to work reliably.
Reading tmux State
tmux list-sessions
tmux list-windows -t work
tmux list-panes -t work:editor
tmux display-message -p -t work:editor '#{pane_current_path}'
display-message -p prints a formatted string. With format variables, you can pull out pane state from scripts:
path=$(tmux display-message -p -t work:editor '#{pane_current_path}')
echo "editor pane is in: $path"
Handy for scripts that need to know where tmux thinks a shell is running, or what command is active.
Creating a Layout and Applying It
Set up the arrangement you want by hand, then capture it:
tmux display-message -p '#{window_layout}'
Save the output:
b25d,204x50,0,0{136x50,0,0,1,67x50,137,0[67x25,137,0,2,67x24,137,26,3]}
Recreate it later:
tmux select-layout 'b25d,204x50,0,0{136x50,0,0,1,67x50,137,0[67x25,137,0,2,67x24,137,26,3]}'
Not pretty, but reliable. Most session templates use scripts that add panes in order instead of saving a raw layout; layouts can break if the terminal size changes.
tmuxp and tmuxinator
If your scripts get elaborate, declarative session managers exist:
tmuxp (Python)
# ~/.tmuxp/api.yaml
session_name: api
start_directory: ~/code/api
windows:
- window_name: editor
panes:
- vim .
- window_name: shell
panes:
- shell_command: []
- window_name: tests
panes:
- npm test -- --watch
- window_name: logs
panes:
- tail -f /tmp/api.log
tmuxp load api
tmuxinator (Ruby)
Similar idea, older tool:
# ~/.tmuxinator/api.yml
name: api
root: ~/code/api
windows:
- editor: vim .
- shell: null
- tests: npm test -- --watch
- logs: tail -f /tmp/api.log
mux api
Both are fine. If you already know Python or Ruby, pick the matching one. Otherwise plain shell scripts are the simplest start.
Hooks: Scripting Reactions to Events
Hooks fire tmux commands on events.
set-hook -g session-created 'split-window -h ; send-keys "ls" Enter'
On every new session, split horizontally and run ls in the new pane. Useful for automating common setup.
Events include:
session-created,session-closedclient-attached,client-detachedwindow-linked,window-unlinkedafter-select-pane,after-kill-panepane-exited
Full list: man tmux under HOOKS.
if-shell: Conditional Commands
if-shell '[ -f ~/.tmux.local.conf ]' \
'source-file ~/.tmux.local.conf' \
'display "no local config"'
Runs the first command if the shell expression exits 0, else the second. Useful for portable configs.
if-shell -b 'test "$(uname)" = Darwin' \
'set -g default-command "reattach-to-user-namespace -l $SHELL"'
-b runs the shell check in the background so tmux startup isn't blocked.
run-shell for Output
<prefix> :run-shell "date"
Opens a scrollable popup with the output in modern tmux. Useful for quick one-off checks without leaving the status-bar flow.
From a script:
tmux run-shell "date"
Runs in the background. If you need the output, use display-message:
tmux display-message "built at $(date +%H:%M)"
Shows for a few seconds on the status line.
A Library of Useful Scripts
Toggle pane zoom with a mark
tmux resize-pane -Z
Same as <prefix> z. Useful in a binding that does several things at once.
Broadcast a command to every pane in a window
for pane in $(tmux list-panes -F '#{pane_id}'); do
tmux send-keys -t "$pane" "hostname" Enter
done
Open a URL popup
bind u run-shell "open https://github.com/$(git -C '#{pane_current_path}' config --get remote.origin.url | sed 's#.*:##;s#.git$##')"
Opens the GitHub page for the current repo. The path expansion uses #{pane_current_path}.
Common Pitfalls
"send-keys ran too fast; the shell wasn't ready." Rare, but possible when sending to a new pane. Sleep briefly:
tmux send-keys -t x "cmd" Enter
sleep 0.2
tmux send-keys -t x "next cmd" Enter
"send-keys with | or $ expanded weirdly." Shell quoting. Use single quotes for literal, double quotes when you want variable expansion. tmux passes the string straight to the shell inside the pane.
"Target string errored." Print what you think the target is:
echo "$SESSION:$WIN"
Make sure it matches tmux list-panes -a -F '#{pane_id} #{session_name}:#{window_name}.#{pane_index}'.
"My script works from the shell but not from a keybinding." Bindings run in a limited environment. Use run-shell -b and absolute paths in the script.
"Session creates but doesn't attach." You probably wrote -d (detached) but forgot the trailing tmux attach. Add it, or split the script into a "create" and "attach" piece.
Next Steps
Continue to 10-plugins.md for the short list of plugins worth installing.