SOPs
Working Over SSH — What Changes When Claude Is On The Other Machine
The Setup
Section titled “The Setup”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.
The Output Channels
Section titled “The Output Channels”| Channel | Example command | Status | Fix pattern |
|---|---|---|---|
| Web URLs | open https://... | Fixed | open-html.sh — detects $SSH_CONNECTION and SSHes back to the client |
| Clipboard image read | clipboard-image.sh <path> | Fixed | Detects $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 capture | capture.sh clipboard read | Partial | SSHes 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 folders | open ~/path/to/folder | Broken | Needs $SSH_CONNECTION-aware wrapper, same shape as open-html.sh |
| Preview / image files | open image.png | Broken | Same wrapper, plus the file must exist on the client — scp first or rely on sync |
| New files Claude generates | writing to disk | Broken | Files live on the Studio. Client does not see them until commit + push + pull, or scp |
| Clipboard writes | pbcopy | Broken | Lands on Studio clipboard. Client cannot paste. Universal Clipboard does not help across SSH. Needs forwarding |
| Audio / voice | say, afplay, speak-screen.py | Broken | Plays on Studio speakers. If I am in another room I hear nothing. Needs forwarding or client-side playback |
| Screen capture | screencapture | Broken | Captures the Studio screen, not what I am looking at. Needs to run on client |
| Dialogs / notifications | osascript -e 'display dialog', osascript -e 'display notification' | Broken | Pops on the Studio. Needs to route to client |
| Local dev servers | npm run dev (Astro, Next, Vite, etc.) | Broken | Most 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 it | tell 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 Fix Pattern
Section titled “The Fix Pattern”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:
- Detect
$SSH_CONNECTION. If absent, behave normally. - If present, extract the client IP and SSH back to it.
- On the client side, run the equivalent local command (
open,pbcopy,afplay,screencapture,osascript). - 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.
When To Use SSH Mode
Section titled “When To Use SSH Mode”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.
How To Fix The Rest
Section titled “How To Fix The Rest”Each broken channel is its own small piece of work. Rough priority order, based on how often each one bites during a normal session:
- Finder /
openfor local paths. Same script shape asopen-html.sh, but for non-URL arguments. Scope it to directories first, then image files. pbcopy. Wrap the command. On SSH, pipe the stdin tossh 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.say/speak-screen.py. The voice pipeline already shells out tosay. Route the final audio through the client’safplayor re-run the whole script on the client.screencaptureandosascriptdialogs. 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:
codesignwith an Apple Development cert — the cert is gated by a keychain ACL that only unlocks for an interactive UI sessionsudocommands 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:
scpthe script to/tmp/<basename>on the clientssh <client>and call Terminal’sdo scriptto open a window and runbash /tmp/<basename>- Poll
busy of tin arepeatloop until the command exits close (window of t) saving noso 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
Precedent
Section titled “Precedent”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.
Related
Section titled “Related”- 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