i wanted to automate cmux from AppleScript (to control by Better Touch Tool) things like sending commands to panes, opening browser splits, reading terminal text, workspace management, the usual.
turns out cmux doesn't expose a rich AppleScript dictionary. you can't just tell application "cmux" to do stuff like you would with iTerm2 or Safari. the actual automation stack is:
AppleScript as orchestration
do shell script → cmux CLI
raw unix socket JSON when the CLI parser doesn't cover it
once you know that, everything clicks. here's the full surface i mapped out.
also worth knowing: cmux is native macOS (Swift/AppKit) on top of Ghostty/libghostty, not Electron/Tauri. for automation-heavy workflows that usually means less UI latency and fewer "focus roulette" surprises.
the setup
check everything works
# app + cli
defaults read /Applications/cmux.app/Contents/Info CFBundleIdentifier
command -v cmux || echo "cmux CLI not on PATH"socket reachable
cmux ping
cmux capabilities --json | jq '.protocol, .version, (.methods|length)'permissions
| action | permission needed |
|---|---|
| do shell script calling cmux CLI | none |
| tell application id "com.cmuxterm.app" to activate | Automation (may prompt) |
| UI scripting via System Events | Accessibility (must grant manually) |
quick health probe from AppleScript
set cmuxCLI to "/Applications/cmux.app/Contents/Resources/bin/cmux"
set pong to do shell script quoted form of cmuxCLI & " ping"
set ws to do shell script quoted form of cmuxCLI & " current-workspace"
return "ping=" & pong & linefeed & "workspace=" & wsmodel + IDs (critical for sane targeting)
cmux's hierarchy is:
window → workspace → pane → surface → panel
for automation, the important IDs are workspace + surface. inside cmux shells you'll usually get:
CMUX_WORKSPACE_ID
CMUX_SURFACE_ID
CMUX_SOCKET_PATH
quick inspect:
echo "ws=$CMUX_WORKSPACE_ID surface=$CMUX_SURFACE_ID sock=${CMUX_SOCKET_PATH:-/tmp/cmux.sock}"
cmux identify --json | jq '.focused'socket access modes (why scripts work in one shell and fail in another)
official modes:
off → socket disabled
cmux processes only → default, only processes spawned from cmux terminals can connect
allowAll → any local process can connect (env override: CMUX_SOCKET_MODE=allowAll)
if your external automation suddenly fails while in-app commands still work, check this first in Settings → Automation.
the wrapper (use this everywhere)
this is the pattern i use in every automation script. finds the CLI, runs commands, handles errors:
property defaultCmuxCLI : "/Applications/cmux.app/Contents/Resources/bin/cmux"
on findCmuxCLI()
try
set p to do shell script "command -v cmux || true"
if p is not "" then return p
end try
return defaultCmuxCLI
end findCmuxCLI
on runCmux(argsText)
set cliPath to my findCmuxCLI()
return do shell script quoted form of cliPath & " " & argsText
end runCmux
on runCmuxSafe(argsText)
try
return my runCmux(argsText)
on error errMsg number errNum
return "ERR " & errNum & ": " & errMsg
end try
end runCmuxSafeusage:
set pong to my runCmux("ping")
set ws to my runCmux("current-workspace")
my runCmux("notify --title " & quoted form of "AppleScript" & " --body " & quoted form of "Done")quoting rule (will bite you if you skip it): always use quoted form of for user text going into shell args.
bad:
do shell script "cmux send " & userTextgood:
do shell script "cmux send " & quoted form of userTextthe CLI surface
extracted from CLI/cmux.swift. 91 command tokens, 110 usage lines with args/aliases.
global syntax
cmux [--socket PATH] [--window WINDOW] [--password PASSWORD] [--json] [--id-format refs|uuids|both] [--version] <command> [options]handles are UUIDs, short refs (window:N, workspace:N, pane:N, surface:N), or indexes.
config knobs that affect automation quality
cmux reads Ghostty config (usually ~/.config/ghostty/config or ~/Library/Application Support/com.mitchellh.ghostty/config).
the 3 knobs that matter most for scripts:
scrollback-limit (impacts read-screen --scrollback)
working-directory (where new workspaces/surfaces spawn)
unfocused-split-opacity (helps visual debugging when agents open lots of splits)
main commands
version
ping
capabilities
identify [--workspace <id>] [--surface <id>] [--no-caller]
list-windows
current-window
new-window
focus-window --window <id>
close-window --window <id>
move-workspace-to-window --workspace <id> --window <id>
reorder-workspace --workspace <id> (--index <n> | --before <id> | --after <id>)
workspace-action --action <name> [--workspace <id>] [--title <text>]
list-workspaces
new-workspace [--cwd <path>] [--target <name>] [--command <text>]
new-split <left|right|up|down> [--workspace <id>] [--surface <id>]
list-panes [--workspace <id>]
list-pane-surfaces [--workspace <id>] [--pane <id>]
tree [--all] [--workspace <id>]
focus-pane --pane <id> [--workspace <id>]
new-pane [--type <terminal|browser>] [--direction <left|right|up|down>]
new-surface [--type <terminal|browser>] [--pane <id>] [--url <url>]
close-surface [--surface <id>]
move-surface --surface <id> [--pane <id>] [--before <id>] [--after <id>]
reorder-surface --surface <id> (--index <n> | --before <id> | --after <id>)
tab-action --action <name> [--tab <id>] [--surface <id>]
rename-tab [--tab <id>] [--surface <id>] <title>
drag-surface-to-split --surface <id> <left|right|up|down>
refresh-surfaces
surface-health [--workspace <id>]
close-workspace --workspace <id>
select-workspace --workspace <id>
rename-workspace [--workspace <id>] <title>
current-workspace
read-screen [--surface <id>] [--scrollback] [--lines <n>]
send [--surface <id>] <text>
send-key [--surface <id>] <key>
notify --title <text> [--subtitle <text>] [--body <text>]
list-notifications
clear-notificationssidebar metadata
set-status <key> <value> [--icon <name>] [--color <#hex>]
clear-status <key>
list-status
set-progress <0.0-1.0> [--label <text>]
clear-progress
log [--level <level>] [--source <name>] [--] <message>
clear-log
list-log [--limit <n>]
sidebar-state
set-app-focus <active|inactive|clear>tmux compatibility
capture-pane [--scrollback] [--lines <n>]
resize-pane --pane <id> (-L|-R|-U|-D) [--amount <n>]
pipe-pane --command <shell-command>
wait-for [-S|--signal] <name> [--timeout <seconds>]
swap-pane --pane <id> --target-pane <id>
break-pane [--no-focus]
join-pane --target-pane <id> [--no-focus]
next-window | previous-window | last-window
last-pane
find-window [--content] [--select] <query>
clear-history
set-hook [--list] [--unset <event>] | <event> <command>
set-buffer [--name <name>] <text>
list-buffers
paste-buffer [--name <name>]
respawn-pane [--command <cmd>]
display-message [-p|--print] <text>browser commands
this is the big one. cmux has a full browser automation surface:
browser open [url]
browser open-split [url]
browser goto|navigate <url> [--snapshot-after]
browser back|forward|reload [--snapshot-after]
browser url|get-url
browser snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>]
browser eval <script>
browser wait [--selector <css>] [--text <text>] [--url-contains <text>] [--timeout-ms <ms>]
browser click|dblclick|hover|focus|check|uncheck|scroll-into-view <selector> [--snapshot-after]
browser type <selector> <text> [--snapshot-after]
browser fill <selector> [text] [--snapshot-after]
browser press|keydown|keyup <key> [--snapshot-after]
browser select <selector> <value> [--snapshot-after]
browser scroll [--selector <css>] [--dx <n>] [--dy <n>]
browser get <url|title|text|html|value|attr|count|box|styles> [...]
browser is <visible|enabled|checked> <selector>
browser find <role|text|label|placeholder|testid|first|last|nth> ...
browser frame <selector|main>
browser dialog <accept|dismiss> [text]
browser download [wait] [--path <path>] [--timeout-ms <ms>]
browser cookies <get|set|clear> [...]
browser storage <local|session> <get|set|clear> [...]
browser tab <new|list|switch|close|<index>> [...]
browser console <list|clear>
browser errors <list|clear>
browser highlight <selector>
browser state <save|load> <path>
browser addinitscript|addscript <script>
browser addstyle <css>
browser identifythe parser quirk (will save you time)
this is the single most important thing i found: the CLI parser and the runtime capabilities don't match.
cmux's runtime exposes 172 methods via capabilities --json. but the CLI parser only accepts a subset. so some methods that exist and work at the socket level will fail through the CLI.
example — this fails:
cmux browser console list
# -> Error: browser requires a subcommandand this also fails:
cmux browser surface:1 console list
# -> Error: Unsupported browser subcommand: console listbut browser.console.list exists in the runtime and works fine over the raw socket.
update for newer builds: many browser verbs are now exposed directly in CLI docs (console, errors, highlight, state, frame, dialog, download, etc). so treat parser mismatch as version-sensitive, not absolute.
mental model that helps: browser runtimes usually have more protocol actions than parser verbs. so "exists in capabilities but not in CLI grammar" is expected sometimes.
rule: use CLI for what the parser supports. use raw socket v2 for everything else.
also: if the first non-flag token after browser isn't a known verb and you haven't passed --surface, the parser treats it as a surface handle. that's why some errors feel random.
raw socket v2 protocol (the escape hatch)
when the CLI parser doesn't cover a method, you can hit the runtime directly over a unix socket.
transport
unix domain socket at /tmp/cmux.sock
debug builds may expose /tmp/cmux-debug.sock (or /tmp/cmux-debug-<tag>.sock)
newline-delimited JSON request/response
request
{"id":"1","method":"system.ping","params":{}}response
{"id":"1","ok":true,"result":{...}}error
{"id":"1","ok":false,"error":{"code":"invalid_params","message":"..."}}quick probe
python3 - <<'PY'
import socket, json
s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect('/tmp/cmux.sock')
s.sendall((json.dumps({"id":"1","method":"system.ping","params":{}})+"\n").encode())
print(s.recv(65535).decode().strip())
s.close()
PYcalling it from AppleScript
set py to "import socket,json; s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); s.connect('/tmp/cmux.sock'); req={'id':'1','method':'system.ping','params':{}}; s.sendall((json.dumps(req)+'\\n').encode()); print(s.recv(65535).decode().strip()); s.close()"
do shell script "/usr/bin/python3 -c " & quoted form of pywhen to reach for the socket
method exists in cmux capabilities --json → .methods
CLI parser doesn't have a command for it
you need precise method-level control
focus-steal policy (important for background agents)
recent socket/CLI behavior is explicitly "no focus steal by default":
non-focus commands should not activate/raise app windows
non-focus commands should not mutate in-app selection as a side effect
commands that are expected to change focus are the explicit ones (examples): window.focus, workspace.select, workspace.next|previous|last, surface.focus, pane.focus|last, browser.focus_webview, browser.tab.switch.
runtime inventory (172 methods, 12 families)
the full list from cmux capabilities --json:
| family | count | highlights |
|---|---|---|
| app | 2 | focus override, simulate active |
| auth | 1 | login |
| browser | 84 | the big one: navigation, DOM, cookies, storage, network, screenshots, tracing |
| debug | 29 | layout introspection, terminal focus, command palette, shortcuts |
| notification | 5 | create, list, clear (per-surface and per-target variants) |
| pane | 9 | create, focus, swap, join, break, resize |
| surface | 17 | CRUD + send text/keys + read text + drag/split |
| system | 4 | ping, identify, capabilities, tree |
| tab | 1 | tab action |
| target | 3 | list, upsert, delete |
| window | 5 | create, close, focus, list, current |
| workspace | 12 | CRUD + select, rename, reorder, navigate |
browser alone has 84 methods. that's cookies, storage, network interception, screencasting, tracing, geolocation spoofing, dialog handling — basically a Playwright-level surface accessible from your terminal.
v1 → v2 migration map
if you have old scripts using legacy command names:
| legacy | v2 method |
|---|---|
| list_windows | window.list |
| current_window | window.current |
| focus_window | window.focus |
| new_window | window.create |
| close_window | window.close |
| list_workspaces | workspace.list |
| new_workspace | workspace.create |
| select_workspace | workspace.select |
| current_workspace | workspace.current |
| close_workspace | workspace.close |
| list_surfaces | surface.list |
| focus_surface | surface.focus |
| new_split | surface.split |
| new_surface | surface.create |
| close_surface | surface.close |
| list_panes | pane.list |
| focus_pane | pane.focus |
| send / send_surface | surface.send_text |
| send_key / send_key_surface | surface.send_key |
| notify | notification.create |
| open_browser | browser.open_split |
| navigate | browser.navigate |
| get_url | browser.url.get |
| screenshot | debug.window.screenshot |
pattern: underscores → dots, verbs become family.action.
v2 wire-format gotchas that still bite scripts
legacy v1 json payloads like this are rejected:
{"command":"list_workspaces"}use v2 envelope instead:
{"id":"ws","method":"workspace.list","params":{}}also:
many current builds default command targeting to caller context via CMUX_WORKSPACE_ID
short refs are first-class (workspace:3, surface:2) and easier to read in logs
move/reorder flows should preserve stable surface_id identity (critical if you cache handles)
use --id-format refs|uuids|both when you need deterministic output shape
ghostty fork notes that matter for automators
cmux currently carries Ghostty fork patches that are directly relevant to scripting:
OSC 99 (kitty) notification parser support wired into the terminal OSC dispatcher
a macOS display-link restart fix after display-ID changes (helps avoid rare "surface looks frozen" states)
recipes
1 — health check
set pong to do shell script "cmux ping"
set ws to do shell script "cmux current-workspace"
return "pong=" & pong & ", ws=" & ws2 — notify
do shell script "cmux notify --title " & quoted form of "Build" & " --body " & quoted form of "Completed"3 — send command to terminal
do shell script "cmux send " & quoted form of "echo hello from applescript"4 — switch workspace
do shell script "cmux select-workspace --workspace workspace:2"5 — open a browser split
do shell script "cmux browser open https://example.com"6 — browser snapshot
do shell script "cmux browser --surface surface:1 snapshot --interactive"7 — read terminal text
set txt to do shell script "cmux read-screen --lines 80"
return txt8 — sidebar status + progress
do shell script "cmux set-status build running --icon hammer"
do shell script "cmux set-progress 0.42 --label " & quoted form of "Compiling"9 — raw v2 call over socket
set py to "import socket,json; s=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM); s.connect('/tmp/cmux.sock'); req={'id':'1','method':'system.identify','params':{}}; s.sendall((json.dumps(req)+'\\n').encode()); print(s.recv(65535).decode()); s.close()"
set out to do shell script "/usr/bin/python3 -c " & quoted form of py
return out10 — safe wrapper with fallback
try
do shell script "cmux notify --title " & quoted form of "cmux" & " --body " & quoted form of "Task complete"
on error
display notification "Task complete" with title "cmux"
end try11 — browser click with post-action snapshot
do shell script "cmux browser --surface surface:1 click " & quoted form of "button[type='submit']" & " --snapshot-after"12 — browser console + errors pull
set logs to do shell script "cmux browser --surface surface:1 console list"
set errs to do shell script "cmux browser --surface surface:1 errors list"
return "console=" & linefeed & logs & linefeed & linefeed & "errors=" & linefeed & errs13 — OSC notifications (no CLI dependency)
# OSC 777 (simple)
printf '\e]777;notify;Build Complete;All tests passed\a'
# OSC 99 (rich: title/subtitle/body)
printf '\e]99;i=1;e=1;d=0;p=title:Build Complete\e\\'
printf '\e]99;i=1;e=1;d=0;p=subtitle:CI\e\\'
printf '\e]99;i=1;e=1;d=1;p=body:All tests passed\e\\'notification UX shortcuts worth memorizing while debugging:
⌘⇧I → open notifications panel
⌘⇧U → jump to workspace with latest unread notification
common errors and fixes
"unsupported browser subcommand"
parser doesn't know that verb. check cmux browser --help. if the method exists in runtime, use raw socket.
"browser requires a subcommand"
first token got parsed as a surface handle. use cmux browser --surface <surface> <subcommand> explicitly.
"socket not found"
wrong path or app not running. check with cmux ping and ls -l /tmp/cmux*.sock. override with --socket if needed.
"permission denied / unauthorized socket access"
socket mode is likely off or process ancestry is blocked under cmux processes only. switch mode or run from a cmux-spawned shell.
"legacy json payload rejected"
you're sending v1 shape ({"command":"..."}). v2 needs {"id","method","params"}.
"background command stole focus"
current policy is no-focus-steal for non-focus commands. if this happens, check whether your script called an explicit focus intent (workspace.select, surface.focus, browser.focus_webview, etc).
AppleScript error -1708
app doesn't expose that Apple event. use CLI/socket bridge instead of direct tell application commands.
AppleScript error -25211 missing Accessibility permission. grant in System Settings → Privacy & Security → Accessibility.
diagnostic one-liner
cmux ping && cmux capabilities --json | jq '.version, .protocol, (.methods|length)' && cmux identify --json && cmux browser --helpbefore updating scripts after a cmux update
capture new cmux capabilities --json output
diff .methods against your previous snapshot
rerun critical workflows (notify, send, workspace select, browser open)
run one "focus safety" regression (background notify/send should not steal focus)
update any hardcoded method names / output ID expectations