how to scriptize cmux: the full automation surface (CLI, AppleScript, raw sockets)


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

bash
# app + cli
defaults read /Applications/cmux.app/Contents/Info CFBundleIdentifier
command -v cmux || echo "cmux CLI not on PATH"

socket reachable

java
cmux ping
cmux capabilities --json | jq '.protocol, .version, (.methods|length)'

permissions

actionpermission needed
do shell script calling cmux CLInone
tell application id "com.cmuxterm.app" to activateAutomation (may prompt)
UI scripting via System EventsAccessibility (must grant manually)

quick health probe from AppleScript

other
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=" & ws

model + 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:

bash
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:

other
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 runCmuxSafe

usage:

other
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:

other
do shell script "cmux send " & userText

good:

other
do shell script "cmux send " & quoted form of userText

the CLI surface

extracted from CLI/cmux.swift. 91 command tokens, 110 usage lines with args/aliases.

global syntax

bash
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

other
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-notifications

sidebar metadata

other
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

other
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:

other
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 identify

the 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:

bash
cmux browser console list
# -> Error: browser requires a subcommand

and this also fails:

bash
cmux browser surface:1 console list
# -> Error: Unsupported browser subcommand: console list

but 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

json
{"id":"1","method":"system.ping","params":{}}

response

json
{"id":"1","ok":true,"result":{...}}

error

json
{"id":"1","ok":false,"error":{"code":"invalid_params","message":"..."}}

quick probe

bash
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()
PY

calling it from AppleScript

other
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 py

when 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:

familycounthighlights
app2focus override, simulate active
auth1login
browser84the big one: navigation, DOM, cookies, storage, network, screenshots, tracing
debug29layout introspection, terminal focus, command palette, shortcuts
notification5create, list, clear (per-surface and per-target variants)
pane9create, focus, swap, join, break, resize
surface17CRUD + send text/keys + read text + drag/split
system4ping, identify, capabilities, tree
tab1tab action
target3list, upsert, delete
window5create, close, focus, list, current
workspace12CRUD + 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:

legacyv2 method
list_windowswindow.list
current_windowwindow.current
focus_windowwindow.focus
new_windowwindow.create
close_windowwindow.close
list_workspacesworkspace.list
new_workspaceworkspace.create
select_workspaceworkspace.select
current_workspaceworkspace.current
close_workspaceworkspace.close
list_surfacessurface.list
focus_surfacesurface.focus
new_splitsurface.split
new_surfacesurface.create
close_surfacesurface.close
list_panespane.list
focus_panepane.focus
send / send_surfacesurface.send_text
send_key / send_key_surfacesurface.send_key
notifynotification.create
open_browserbrowser.open_split
navigatebrowser.navigate
get_urlbrowser.url.get
screenshotdebug.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:

json
{"command":"list_workspaces"}

use v2 envelope instead:

json
{"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

other
set pong to do shell script "cmux ping"
set ws to do shell script "cmux current-workspace"
return "pong=" & pong & ", ws=" & ws

2 — notify

other
do shell script "cmux notify --title " & quoted form of "Build" & " --body " & quoted form of "Completed"

3 — send command to terminal

other
do shell script "cmux send " & quoted form of "echo hello from applescript"

4 — switch workspace

other
do shell script "cmux select-workspace --workspace workspace:2"

5 — open a browser split

other
do shell script "cmux browser open https://example.com"

6 — browser snapshot

other
do shell script "cmux browser --surface surface:1 snapshot --interactive"

7 — read terminal text

other
set txt to do shell script "cmux read-screen --lines 80"
return txt

8 — sidebar status + progress

other
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

other
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 out

10 — safe wrapper with fallback

other
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 try

11 — browser click with post-action snapshot

other
do shell script "cmux browser --surface surface:1 click " & quoted form of "button[type='submit']" & " --snapshot-after"

12 — browser console + errors pull

other
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 & errs

13 — OSC notifications (no CLI dependency)

bash
# 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

bash
cmux ping && cmux capabilities --json | jq '.version, .protocol, (.methods|length)' && cmux identify --json && cmux browser --help

before 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