Why your Claude Code hooks do nothing over SSH
If you run Claude Code on a remote box, your hooks live on the wrong machine and anything they talk to is on the wrong localhost. Here is what actually breaks and the SSH config that fixes it.
You set up a Stop hook, it works, you are happy. Then you SSH into your dev box, run Claude Code there, and nothing happens. No notification, no sound, no nothing. The hook did not fail. It never ran.
Two separate things are broken, and fixing only one of them leaves you exactly where you started.
1. The hooks are on the wrong machine
Claude Code reads hooks from ~/.claude/settings.json on the machine where the claude process runs. If you are SSH'd into devbox, that is devbox:~/.claude/settings.json. The file on your Mac is never consulted.
This catches people because everything else about the session feels local. Your terminal is local. Your editor might be local. But the agent is not, and neither is its config.
So step one is obvious once you see it: put the hook on the remote host.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "~/bin/notify.sh" }
]
}
]
}
}
Now the hook runs. And you still get no notification.
2. Your hook is shouting into the wrong localhost
Most useful hooks do not just print. They talk to something: a notifier daemon, a desktop app, a local HTTP endpoint. On your Mac that is 127.0.0.1. On the remote box, 127.0.0.1 is the remote box. Whatever you were talking to is not there.
osascript has the same problem in a different shape. There is no GUI session on the remote host, so a display notification either errors or goes nowhere.
The remote hook fires perfectly and the result lands on a machine you are not looking at.
The fix: an SSH reverse tunnel
Forward a port backwards, from the remote host to your Mac. In ~/.ssh/config on your Mac:
Host devbox
HostName devbox.internal
User you
RemoteForward 4242 127.0.0.1:4242
Reconnect. Now 127.0.0.1:4242 on the remote host is your Mac's 127.0.0.1:4242.
By default sshd binds the forwarded port to the remote's loopback only, so this does not expose anything to the rest of the network. That is the behavior you want, and it is the default. You do not need GatewayPorts.
Your remote hook can now be a plain curl:
#!/bin/sh
curl -s -m 1 -X POST \
-H 'Content-Type: application/json' \
--data "$(cat)" \
"http://127.0.0.1:4242/agent/done" >/dev/null 2>&1 &
exit 0
Three things that will bite you
Hooks run in Claude's critical path. A hook that hangs hangs your session. Give curl a timeout, background it with &, and always exit 0. This matters more over SSH, because if the tunnel is down your request will sit there waiting instead of failing fast.
Do not hardcode $HOME paths for anything shared. If a hook script reads a token or a socket from a fixed path, and you also run a second copy of the same tool, both copies read the same file. Resolve those relative to the script instead:
dir="$(dirname "$0")"
token="$(cat "$dir/token" 2>/dev/null)"
We shipped exactly this bug and it cost an afternoon. The port was right, the config was right, and the request was rejected every time because the second instance was reading the first one's token.
Process ids and tmux panes do not survive the trip. If your hook sends $PPID so something on your Mac can raise the right window, that number describes a process on the remote host. Interpreted locally it points at whatever unrelated process happens to share the number. Same for $TMUX_PANE. Send a flag with the request so the receiving side knows to skip anything machine-local.
While we are here: tmux
tmux breaks the same window-focus trick even when everything is local. A pane's processes are children of the tmux server, and the server's parent is launchd:
25700 25699 -zsh <- the shell where claude runs
25699 1 tmux <- the server. parent is pid 1
Walking up from the hook's $PPID never reaches your terminal app, because the thing attached to your terminal is the tmux client, not the server. If you need the window, go pane -> session -> client and walk up from the client's pid instead.
Checking it works
From the remote host, with the tunnel up:
curl -sv -m 2 http://127.0.0.1:4242/ 2>&1 | head -5
Connection refused means the tunnel is not up or nothing is listening on your Mac. A response, even a 404, means the path is open and the problem is in your hook.
Further reading
The hook events and payload fields are documented in the Claude Code hooks reference.