Sending Commands to iTerm2 Tabs with AppleScript

There is a small AppleScript in this repo at python/iterm_tab_names.applescript that solves a specific automation problem: find an iTerm2 tab by partial name, send a command into it, wait for the shell to come back to a prompt, and return what the command produced. It is useful when you want one process (a script, a Make target, a parent agent) to drive a long-lived terminal session in another window without losing the output.

This article documents what the script actually does, every argument pattern it accepts, and the limits to be aware of.

What it does

Given a needle string, the script walks every iTerm2 window, tab, and session, and matches the needle against the session name or profile name with a substring test. The first match wins. That tab is selected, iTerm is brought to the front, and the command is sent in two steps: first the text is typed into the session with write text theCommand newline no (no embedded newline), then after a 100ms settle a real Return key is dispatched via System Events keystroke return.

This two-step send is deliberate. A single write text with the default newline works for plain shells but is silently swallowed by TUI applications – in particular Claude Code – because iTerm2 delivers write text content as a paste, and the embedded \n is treated as a literal character in the input buffer rather than as a submit. Splitting the typed text from the Enter key gets around that.

After sending, the script polls is at shell prompt of targetSession (an iTerm2 shell-integration property) every 0.2 seconds, up to 30 seconds. When the prompt comes back, or the timeout expires, it snapshots the visible session buffer and diffs it against a snapshot taken before sending. The appended portion is what the command produced.

If iTerm2 shell integration is not installed in the target session, is at shell prompt errors out. The script catches that, falls back to a fixed 1.5-second delay, and continues.

The return value is a small header line followed by the diff:

-- sent 'ls -la' to 'DEV (-zsh)'
--
total 256
drwxr-xr-x  ... files ...

If shell integration was missing, the header notes that. If the 30-second timeout expired before the prompt returned, the header notes that too.

Argument patterns

The script accepts four shapes.

No args – default behavior:

osascript python/iterm_tab_names.applescript

Needle defaults to "Claude Code". Command defaults to "commit". Useful as a one-key shortcut when you have a Claude Code tab running and you want to fire commit into it.

One arg – command only:

osascript python/iterm_tab_names.applescript "/review"
osascript python/iterm_tab_names.applescript "ls -la"

Needle stays "Claude Code". The single argument is sent verbatim. This is the original calling convention; existing scripts that pass one argument keep working.

Two args – needle and command:

osascript python/iterm_tab_names.applescript "DEV" "mix test"
osascript python/iterm_tab_names.applescript "beam.smp" "i(self())"

First argument is the substring matched against session/profile names. Second argument is the command. Use this when you have multiple terminal sessions and you want to target a specific one.

--list – enumerate without sending:

osascript python/iterm_tab_names.applescript --list

Prints every tab as [wN tM] name=... | profile=.... No command is sent. Works whether --list is the only arg or the first of two. Use this to figure out a unique needle before automating against a session.

Sample output of --list:

[w1 t1] name=⠐ Claude Code (caffeinate) | profile=DEV
[w1 t2] name=beam.smp | profile=DEV
[w1 t3] name=DEV (-zsh) | profile=DEV
[w2 t1] name=ssh | profile=Default
[w3 t2] name=root@ubuntu: ~ (-zsh) | profile=Default

What gets matched

The needle is tested against two strings per session: the session name (the title bar text iTerm2 shows) and the profile name (the iTerm2 profile assigned to the session). A match in either wins. The test is a substring contains, so partial fragments work ("DEV" matches "DEV (-zsh)", "Claude Code" matches "⠐ Claude Code (caffeinate)").

The session name changes over time as the shell sets terminal titles. The profile name is stable and assigned when the tab is created. If you want a stable target, name a dedicated profile and match on that.

Selecting a unique needle

The script picks the first match. If multiple sessions could match, the one that wins depends on traversal order (window 1 tab 1 session 1, then session 2, then window 1 tab 2, and so on). To avoid surprises, choose a needle that appears in exactly one session. Run --list first if you are not sure.

No match: how it tells you

If the needle does not match anything, the script returns:

no tab matches 'foo'. candidates:
[w1 t1] name=... | profile=...
[w1 t2] name=... | profile=...
...

So you do not need a separate --list call to debug. The error already shows you what was there to match against.

Shell-integration vs fallback delay

Two timing paths:

Install iTerm2 shell integration via iTerm2 -> Install Shell Integration in the menu. The polling path captures longer commands accurately; the fallback path will truncate output from anything that takes more than 1.5 seconds.

Output diff: how the script handles edge cases

The diff is computed by checking whether the after-snapshot starts with the before-snapshot and is longer. If so, the appended portion is returned. If the buffer scrolled or shrunk during the command (long output that pushed earlier lines off the top), the script returns the full current buffer instead. This is conservative – you may see a few lines from before the command in the output, but you will not lose what the command actually produced.

Real uses

A few patterns that work well:

Driving Claude Code from a Make target. Fire commit or /review into a Claude Code tab from a make rule. The output comes back as the make rule’s output.

Running a smoke test in a long-lived BEAM session. Keep an iex running in a tab named via profile. Send MyApp.smoke_test() and capture the diff to compare against expected output.

CI dry-runs locally. Mirror the commands a CI step would run by piping them through this script to a dedicated “CI scratch” tab.

Parent agent driving child tabs. A scheduling process can dispatch commands to whichever iTerm2 tab matches a workload, in parallel with each other, without the parent needing to manage shells directly.

TUI targets and the paste-detection trap

A normal shell treats incoming \n as “execute,” so the original one-step write text worked fine for ls -la and friends. TUI applications like Claude Code, vim, htop, and similar do not. They read keystrokes through a higher-level layer that distinguishes pasted input from typed input – via bracketed paste markers (ESC[200~ and ESC[201~) when the terminal supports it, or via timing heuristics when it does not. Inside a paste, an embedded newline is a literal \n character in the input buffer, not a submit signal.

The fix is the two-step send described above: type the command with newline no, then send a real Return key via System Events keystroke return. The keystroke arrives outside any paste wrapper, so the TUI treats it as a genuine submit.

If the keystroke seems to land before the typed text is fully rendered (a fast machine, a long command), bump the delay 0.1 in the script up. 0.2s or 0.3s is usually enough.

macOS Accessibility permission

System Events keystrokes require macOS Accessibility permission for whatever process invokes osascript. When you call it from Terminal, iTerm2, a shell wrapper, or a launchd job, that process must be granted access in System Settings -> Privacy & Security -> Accessibility. The failure mode is an error like -1719 (not allowed to send keystrokes). Most setups already have this from other automation, but it is the first thing to check if the script reports the matching tab was found yet the Return key never arrived.

Limits worth knowing

Where to look in the source

The script is one file, around 175 lines, at python/iterm_tab_names.applescript. The top of the file lists the four argument patterns. The matching loop sits at the start of the second tell application "iTerm" block. The two-step send is around line 115. The before/after diff logic is at the end, just before the header is composed. Read the file directly if you want to extend it – it is short enough to grok in one pass.