Let your remote coding agent see your clipboard

A coding agent on a remote machine cannot see your screen. Four small tools and one ssh forward give it your Mac's clipboard as a Unix socket, so "look at this" works the same over ssh as it does locally. The recipe is short. The two bugs that each reported success are the interesting part.

I run Claude Code on a remote development machine, provisioned by walter, and drive it from a Mac. That split is worth it: the machine has the cores, the repositories, the credentials and the uptime, and the laptop has the screen. It also has a gap that gets annoying fast. When a layout is wrong or a terminal shows an error, the natural move is to take a screenshot and say "look at this". Locally, the agent reads the image straight from the clipboard. Over ssh, the clipboard is on the wrong computer.

The workaround everyone uses is to save the screenshot, scp it, and paste the path. It works, and it is exactly the kind of friction that makes you stop sending screenshots. This article is the setup that removes it: the agent on the server connects to a Unix socket, the bytes of whatever image is on the Mac's clipboard come back, and a skill turns that into a file it can open. Nothing runs as root, nothing listens on a TCP port, and the only new moving part on the Mac is a twelve-line launchd plist.

The shape

Three pieces. A bridge on the Mac that serves the clipboard as a Unix socket. A tunnel, which is one RemoteForward line in ~/.ssh/config that mounts that socket on the server. And a skill on the server that tells the agent the socket exists, how to read it, and how to tell the failure modes apart. The agent never learns anything about the Mac; it sees a socket in /tmp that answers with PNG bytes.

The choice of a Unix socket rather than a port is deliberate. OpenSSH has forwarded Unix sockets since 6.7, the socket file carries the user's permissions, so another account on a shared box cannot read your clipboard, and there is no port to collide with when two people do this on one server.

The bridge: pngpaste and socat under launchd

pngpaste writes the clipboard's image to a file, or to stdout with -. socat turns any command into a socket server. Put them together and you have the whole bridge in one line:

socat UNIX-LISTEN:/tmp/local-clip.sock,fork,reuseaddr,unlink-early \
      EXEC:"pngpaste -"

Every connection forks a fresh pngpaste, which writes the image and exits, which closes the connection. That is the protocol: connect, read to EOF, done. unlink-early removes a socket file left over from a previous run before binding, which matters more than it looks, as we'll see on the other end of the tunnel.

It has to run all the time, so it goes under launchd. Both binaries come from nix on my machine, which is relevant to the first bug. Here is the plist that works, at ~/Library/LaunchAgents/com.user.clipboardsocket.plist:

<key>ProgramArguments</key>
<array>
  <string>/bin/sh</string>
  <string>-c</string>
  <string>/bin/wait4path /nix/store && exec ~/.nix-profile/bin/socat \
    UNIX-LISTEN:/tmp/local-clip.sock,fork,reuseaddr,unlink-early \
    "EXEC:~/.nix-profile/bin/pngpaste -"</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>

Load it once with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.user.clipboardsocket.plist, and check it with nc -U /tmp/local-clip.sock > out.png after copying a screenshot. Expand ~ to your real home directory in the file; launchd does not.

Bug one: loaded, KeepAlive, not running

My first version had no wait4path. It worked on the day I wrote it. After the next reboot, launchctl list showed the job loaded with exit code 78, no process, no socket, and KeepAlive doing nothing. The log files were empty.

The unified log had the actual reason: Missing executable detected. Job: 'com.user.clipboardsocket' Executable: '/Users/amiorin/.nix-profile/bin/socat'. The binary was there; I could run it by hand. The timeline explained it. The Determinate Nix installer puts /nix on its own encrypted APFS volume, and with FileVault on, the daemon that unlocks it cannot run until someone logs in. So at login, three things start within seconds of each other: macOS's own "Enter a password to unlock the disk Nix Store" dialog, the Determinate daemon that unlocks the volume from the keychain, and every LaunchAgent. Mine spawned two seconds before /nix was mounted. launchd found a dangling symlink, classified it as a configuration error, and stopped retrying. Exit 78 is EX_CONFIG, and KeepAlive only restarts a process that once started.

/bin/wait4path is the fix, and it is the one macOS ships for exactly this: it blocks until the path exists, then the shell execs into socat. Determinate's own daemons use it. The dialog in the screenshot, incidentally, is harmless; it is a second, interactive unlock racing the daemon, and it goes away when the daemon wins.

The tunnel: one RemoteForward

The managed block in my ~/.ssh/config for the server gains these lines:

Host walter-vultr
    RemoteForward /tmp/clipboard.sock /tmp/local-clip.sock
    ExitOnForwardFailure yes
    ControlMaster auto
    ControlPath ~/.ssh/control-%C
    ControlPersist 10m

RemoteForward with two paths means: listen on /tmp/clipboard.sock on the server, and connect every client of it to /tmp/local-clip.sock on the Mac. The three multiplexing lines mean the first terminal tab sets up the forward and every later tab, and every scp, rides the same connection. The forward outlives the last tab by ten minutes, which is long enough to not notice it dropping between sessions.

ExitOnForwardFailure yes is the honest option, and it is what surfaced the second bug. Without it, ssh prints a warning you will never read and gives you a shell with no clipboard; with it, the connection refuses to pretend.

Bug two: the option that was on the wrong machine

The second morning, every connection died with remote port forwarding failed for listen path /tmp/clipboard.sock. On the server, /tmp/clipboard.sock was still there from the previous day's session, owned by me, with nothing listening. sshd will not bind over an existing socket file. My config had StreamLocalBindUnlink yes right under the forward, and the man page says it does exactly this: remove an existing Unix-domain socket before creating a new one.

It says that in ssh_config(5), for the client, and the client creates sockets only for LocalForward. For a RemoteForward it is sshd that creates the socket, and sshd reads sshd_config(5), which has the same option, defaulting to no. sshd -T | grep streamlocalbindunlink confirmed it. The line in my client config was not wrong, it was merely irrelevant, which is the kind of wrong that survives a code review.

The fix is a drop-in on the server, and since walter provisions the server, it belongs in desired state, not in a shell history. walter already writes a forwarding drop-in, because a development machine is what reverse tunnels terminate on, and a client's ExitOnForwardFailure is only as good as what sshd tells it. It now reads:

# /etc/ssh/sshd_config.d/00-walter-forwarding.conf
ClientAliveInterval 15
ClientAliveCountMax 3
AllowTcpForwarding yes
GatewayPorts clientspecified
StreamLocalBindUnlink yes

The first two lines close the related failure: without keepalives, sshd holds a crashed client's forward for hours, so the reconnecting client is refused the bind, or never learns its tunnel died. The change is one commit in walter, validated by sshd -t before the copy lands and checked into the golden renders for every provider, so the next machine gets it without anyone remembering.

The skill: nc, /dev/null, and four exit codes

On the server, the reading side is nc -U /tmp/clipboard.sock. The agent could type that itself. It should not, for two reasons that are easy to get wrong by hand, so they live in a small skill with a twenty-line script.

The first is that nc needs </dev/null. Without it, nc keeps the connection open waiting on stdin that never comes, and the agent's tool call hangs until its timeout. The second is that image bytes only enter a model's context by way of a file the agent reads, and the file needs the extension for its real format; file --mime-type decides between .png, .jpg and friends before the move.

timeout 10 nc -U "$SOCKET" </dev/null >"$raw"
mime="$(file -b --mime-type "$raw")"
# png → .png, jpeg → .jpg, …; anything else is exit 5

The part worth copying is the exit codes. A stale socket file from a dead bridge still passes test -S, so "nothing came back" has to be split apart. A refused connection means the bridge is gone and re-copying the screenshot cannot help. A clean connection that sent nothing means the clipboard really is empty. The script returns 3 for the first and 4 for the second, 5 for a clipboard that holds text, and 6 for a connection that opened and never finished. The SKILL.md gives the agent a different instruction for each: do not retry a 3, ask for a re-copy on a 4, report the type on a 5, retry a 6 once.

That table exists because of the way this fails otherwise. An agent that gets zero bytes and says "your clipboard is empty" sends a person off re-copying a screenshot that was there all along, while the actual problem is a socket file nobody unlinked. Which is the same bug as bug two, one layer up. The skill also asks the agent to say what it sees before acting on it, since clipboards go stale and the user cannot see which bytes arrived.

Checking it end to end

Three commands, one per piece, and they are worth running in this order so a failure points at the right layer:

# mac: the bridge
nc -U /tmp/local-clip.sock > out.png && file out.png

# mac: the tunnel bound (an empty result here means ExitOnForwardFailure fired)
ssh walter-vultr 'ls -l /tmp/clipboard.sock'

# server: the whole path
ssh walter-vultr 'nc -U /tmp/clipboard.sock </dev/null > /tmp/t.png; file /tmp/t.png'

With a screenshot copied, the last line says PNG image data, 1376 x 768 or whatever you captured. With text copied, the Mac side logs pngpaste: No image data found on the clipboard and the server gets zero bytes, which is the correct answer, and the skill's exit 4.

What you give up

  • Images only. pngpaste is the bridge, so text on the clipboard returns nothing. Text you can paste into the chat anyway.
  • The socket lives as long as the session. An agent running in tmux after you close the laptop gets exit 3. That is honest, and it is also a reminder that the clipboard is on the laptop.
  • Anything with your uid on the server can read your clipboard. The socket is srw-------, so it is you, not other users. On a machine you share with an agent that is you, that is the point, and it is still worth knowing.
  • One more thing that can silently not run. Both bugs in this article were of the kind where a status command said fine. launchctl list said loaded. The client config had the right-sounding line. Verify the thing you actually want, which is bytes arriving on the server.

The point

None of this is clever. pngpaste, socat, launchd, a -R forward and nc are all older than the agents using them. The useful part is the wiring, and the two places where a reasonable configuration reported success while doing nothing: a LaunchAgent that lost a two-second race to an encrypted volume, and an ssh option that was correct in the wrong file. Both fixes are now in desired state, one in a plist and one in walter, which is the only place a fix like this stays fixed.

The skill is in getcolors/skills; npx skills use getcolors/skills@clipboard-screenshot hands it to your agent. The server side of the recipe is what every machine provisioned by walter gets by default. The Mac side is the plist above, and the one line that matters in it is wait4path.