Skip to content

SOPs

Working Over SSH — What Changes When Claude Is On The Other Machine

The normal shape is: Claude Code runs on the same machine I am physically sitting at. Open a Finder window, it appears on my screen. Copy to clipboard, I paste it. Speak out loud, I hear it.

There is a second shape that comes up often enough to matter: I am at the MacBook Pro, and Claude is running on the Mac Studio over SSH. That happens when the MBP is throttling under heat, when I am working in a different room, or when a long-running process already lives on the Studio and I want to stay in the same session.

In that second shape, every “output to me” path has to cross the SSH boundary to land where I actually am. Some paths already know how to do that. Most do not. This page is the audit: what breaks, what is fixed, and the pattern for fixing the rest.

ChannelExample commandStatusFix pattern
Web URLsopen https://...Fixedopen-html.sh — detects $SSH_CONNECTION and SSHes back to the client
Clipboard image readclipboard-image.sh <path>FixedDetects $SSH_CONNECTION, runs ssh <client> pngpaste - to stream the client’s clipboard PNG back to the server. Requires pngpaste installed on the client (brew install pngpaste). Surfaces install hint if missing. Added 2026-04-26 after the Copy Path README screenshot bounced through three failed grabs.
Auto-screenshot capturecapture.sh clipboard readPartialSSHes back to client and queries clipboard. Works when a screenshot actually lands in the system clipboard. Fails when the VS Code extension paste path bypasses the clipboard entirely, which is the default for images dragged or pasted directly into the chat panel.
Finder foldersopen ~/path/to/folderBrokenNeeds $SSH_CONNECTION-aware wrapper, same shape as open-html.sh
Preview / image filesopen image.pngBrokenSame wrapper, plus the file must exist on the client — scp first or rely on sync
New files Claude generateswriting to diskBrokenFiles live on the Studio. Client does not see them until commit + push + pull, or scp
Clipboard writespbcopyBrokenLands on Studio clipboard. Client cannot paste. Universal Clipboard does not help across SSH. Needs forwarding
Audio / voicesay, afplay, speak-screen.pyBrokenPlays on Studio speakers. If I am in another room I hear nothing. Needs forwarding or client-side playback
Screen capturescreencaptureBrokenCaptures the Studio screen, not what I am looking at. Needs to run on client
Dialogs / notificationsosascript -e 'display dialog', osascript -e 'display notification'BrokenPops on the Studio. Needs to route to client
Local dev serversnpm run dev (Astro, Next, Vite, etc.)BrokenMost dev servers bind to 127.0.0.1 / [::1] by default. Opening http://<studio-LAN-ip>:<port>/ on the client hits nothing. Fix: bind to 0.0.0.0 (Astro --host, Next -H 0.0.0.0, Vite --host) OR start a second dev server on the client side and point at localhost there.
Open a Terminal on the client, run a command, close ittell application "Terminal" to do script "..."Fixed~/apps/cc/bin/run-terminal-over-ssh.sh <client-alias> <local-script> — scp’s the script to /tmp on the client, runs it via Terminal do script over SSH, polls busy until the command exits, then closes the window so terminals do not pile up. Use for codesign, sudo, anything that needs the client’s UI keychain or an interactive password prompt. Do NOT use System Events to keystroke ... — needs Accessibility permission for osascript, which is rarely granted to remote sessions and fails with (1002).

The only channel that works today is the URL path, and that is the template for every other one.

~/apps/cc/open-html.sh checks $SSH_CONNECTION. If that env var is set, it means the current shell was reached over SSH, and the first field of its value is the client IP. The script SSHes back to ojhurst@<client-ip> and re-runs itself with the same arguments. The client machine has passwordless SSH set up back from the Studio, so no prompt. If the reverse SSH fails (3 second timeout), it falls back to opening locally.

Every other channel in the table above needs the same shape:

  1. Detect $SSH_CONNECTION. If absent, behave normally.
  2. If present, extract the client IP and SSH back to it.
  3. On the client side, run the equivalent local command (open, pbcopy, afplay, screencapture, osascript).
  4. If the SSH-back fails, either fall back to running locally or fail loudly — channel-by-channel choice.

Files add one extra step. The target file has to exist on the client before the open command lands. Either scp it over inside the wrapper, or require that the caller sync first.

Default: do not. Run Claude on the machine I am sitting at. The output-routing problem is real, and until every channel is fixed, SSH mode leaves holes that are easy to miss.

Switch into SSH mode when there is a concrete reason:

  • MBP is throttling. The Studio has more thermal headroom. Heavy sessions (image generation, dev servers, big builds) should not cook the MBP.
  • I am mobile and the long-running process is on the Studio. Example: a cron job, a daemon, a Chrome automation, or a conversation that has enough local context to be painful to rebuild elsewhere.
  • I need a machine that stays up. The Studio does not sleep. The MBP does.

Do not switch into SSH mode just to consolidate state. The sync pattern — gpush, pullall, post-push-sync.sh, Universal Clipboard — already handles most of the cross-machine friction. Going all-in on SSH trades one class of problem for a worse one.

Each broken channel is its own small piece of work. Rough priority order, based on how often each one bites during a normal session:

  1. Finder / open for local paths. Same script shape as open-html.sh, but for non-URL arguments. Scope it to directories first, then image files.
  2. pbcopy. Wrap the command. On SSH, pipe the stdin to ssh client pbcopy. This is the one that blocks the most day-to-day work when I am trying to paste a draft message into an app that only lives on the MBP.
  3. say / speak-screen.py. The voice pipeline already shells out to say. Route the final audio through the client’s afplay or re-run the whole script on the client.
  4. screencapture and osascript dialogs. Lowest volume, so last.

Opening A Terminal On The Client (And Closing It)

Section titled “Opening A Terminal On The Client (And Closing It)”

Some commands cannot run from Claude’s bash, even via SSH:

  • codesign with an Apple Development cert — the cert is gated by a keychain ACL that only unlocks for an interactive UI session
  • sudo commands that prompt for a login password — Claude has no way to type the password, and SSH-injected sudo without a TTY fails outright
  • Anything blocked by TCC (microphone, screen recording, automation permissions) where the only working invocation is from a Terminal James personally owns

The pattern: pop a real Terminal window on the client, run the command there, then close the window when it finishes.

Wrapper: ~/apps/cc/bin/run-terminal-over-ssh.sh <client-alias> <local-script-path>. It does four things:

  1. scp the script to /tmp/<basename> on the client
  2. ssh <client> and call Terminal’s do script to open a window and run bash /tmp/<basename>
  3. Poll busy of t in a repeat loop until the command exits
  4. close (window of t) saving no so Terminals do not pile up across many runs

Why this works when other osascript-over-SSH paths fail: do script is part of Terminal’s own scripting dictionary, which osascript can drive over SSH without Accessibility permission. tell application "System Events" to keystroke "..." is the path that requires Accessibility, and that is what fails with osascript is not allowed to send keystrokes (1002) when invoked over SSH. Reach for do script first.

Use it for:

  • Codesigning a build that needs the Apple Development cert
  • Any sudo command that needs a real password prompt
  • TCC-gated work where the fix is “James’s own Terminal, his own session”

Avoid for:

  • Long-running daemons — the wrapper closes the window the moment the command exits, so a daemon would be killed
  • Anything that needs interactive input beyond a single password prompt — the polling loop has no way to feed stdin back to the client

2026-04-24 — Studio Finder miss. I asked Claude to generate three auto-screenshot-renamer doodles. Claude was running on the Studio, I was sitting at the MBP. The generation worked. Claude ran open ~/apps/doodles/doodles/auto-screenshot-renamer/ to reveal the folder. It popped on the Studio screen, which I could not see. I had to ask “open it on the MacBook Pro,” at which point Claude scp’d the files over and ran ssh mbp 'open ...' by hand. That is the prompt that produced this page.

2026-04-25 — Codesign keychain ACL. A FreeFlow build needed re-signing with the Apple Development cert. Claude on the Studio could not unlock the cert (SSH/keychain ACL — same failure flagged in the codesign section of SSH_MODE.md). First attempt: osascript ... tell application "System Events" to keystroke ... to type the codesign command into a Terminal on the MBP. Failed with osascript is not allowed to send keystrokes (1002) because remote osascript does not have Accessibility permission. Second attempt: Terminal do script over SSH, which is a different scripting path and does not need Accessibility. That worked, codesign ran on the MBP. The pattern was wrapped into ~/apps/cc/bin/run-terminal-over-ssh.sh so future runs also auto-close the spawned Terminal window when the command exits.

  • Mosh — persistent shell sessions
  • ~/apps/cc/open-html.sh — the only already-fixed channel. Read this first when building a new wrapper.
  • Infrastructure Guide (~/apps/cc/infrastructure-guide.md) — SSH aliases, VNC, cross-machine sync
  • Global CLAUDE.md — the “Multi-Machine Sync” and “SSH” sections set the baseline policy that this SOP extends