Claude Code hooks on Windows: which shell actually runs them
You wrote a Claude Code hook, tested it, shipped it to the team, and half of them report that nothing happens. No error, no output, just silence.
On Windows, the usual cause is not your script. It is that Claude Code did not run it in the shell you assumed.
Git Bash if you have it, PowerShell if you do not
A command hook is handed to a shell, and on Windows Claude Code chooses that shell for you: Git Bash when it is installed, PowerShell when it is not. You do not get a say, and nothing in the interface tells you which one you got.
That is fine right up until your command contains a character the two shells read differently. Then the same settings.json behaves one way on a developer machine with Git for Windows on it, and another way on a fresh laptop.
The characters that split them
Here is a hook that posts the event payload to a local endpoint. It is about as plain as a command gets:
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "curl.exe -s -X POST http://127.0.0.1:9000/event --data-binary @-" }] }
]
}
}
Under Git Bash it works. Under PowerShell it never runs at all:
+ ... -X POST http://127.0.0.1:9000/event --data-binary @-
+ ~
Unrecognized token in source text.
+ CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
+ FullyQualifiedErrorId : UnrecognizedToken
@ starts the splatting operator in PowerShell, and @- is not a name it will accept, so the whole line fails to parse. The hook exits non-zero having done nothing. Claude Code does not treat that as fatal, so you get no visible complaint anywhere.
Three more that catch people out, all verified against Windows PowerShell 5.1:
| You write | Git Bash | PowerShell |
|---|---|---|
--data-binary @- | argument passed through | parse error, @ starts splatting |
cmd1 && cmd2 | runs cmd2 on success | parse error in 5.1, && only arrived in PowerShell 7 |
> /dev/null 2>&1 | discards output | tries to create a file called \dev\null |
$HOME/script.sh | expands | expands to something else, $HOME is not a PowerShell variable |
The && one is worth dwelling on, because it splits by PowerShell version rather than by shell. Claude Code prefers pwsh.exe (PowerShell 7) when it is present and falls back to powershell.exe (5.1) otherwise, so a hook using && can work on one Windows machine and fail to parse on the next.
Option 1: write a command both shells accept
The most portable hooks are the ones with no shell syntax in them at all. Quote anything that starts with a sigil, and you are usually done:
{ "type": "command", "command": "curl.exe -s -X POST http://127.0.0.1:9000/event --data-binary '@-'" }
Single quotes mean a literal string in PowerShell exactly as they do in sh, so all three shells hand curl the same argument. That one change is the difference between a hook that fires everywhere and one that fires on the machines that happen to have Git for Windows.
Useful to know: ; and exit mean the same thing on both sides, so a trailing guard is portable even when redirection is not:
{ "type": "command", "command": "curl.exe -s -m 2 -X POST http://127.0.0.1:9000/event --data-binary '@-' ; exit 0" }
That matters more than it looks. A hook's exit code is not decoration: exit code 2 blocks the action on the events that support blocking, and a PreToolUse hook that fails because your listener is down would otherwise stop the tool call. Everything other than 2 is a non-blocking error, but it still shows up as noise. Ending in exit 0 says "this is telemetry, carry on".
Option 2: name the shell you want
If you would rather write real PowerShell, ask for it explicitly instead of hoping:
{
"type": "command",
"command": "$in = [Console]::In.ReadToEnd(); Write-Host $in.Length",
"shell": "powershell"
}
shell takes "bash" or "powershell". Pinning it removes the guesswork, at the cost of a hook that now requires that shell to exist. powershell is a safe bet on Windows; bash is not.
Option 3: skip the shell entirely
The cleanest answer for anything that is really just "run this program with these arguments" is the exec form. Add args, and command is resolved as an executable and spawned directly, with no shell in the picture:
{
"type": "command",
"command": "curl.exe",
"args": ["-s", "-m", "2", "-X", "POST", "http://127.0.0.1:9000/event", "--data-binary", "@-"]
}
No quoting rules, no splatting, no redirection differences, and the same JSON works on macOS and Linux if you drop the .exe. The trade is that you lose the shell: no pipes, no ;, no guard, so the program's own exit code is the hook's. Use it when the program cannot fail in a way you care about, and keep the shell form when you need the guard.
Testing which one you actually got
You do not have to guess. Point a hook at a command that only one shell understands and watch what comes back:
{ "type": "command", "command": "echo $PSVersionTable.PSVersion.ToString() > %USERPROFILE%\\hook-shell.txt" }
Simpler still, run claude --debug and start a session. The debug output shows each hook firing along with its exit code, which turns "nothing happened" into a line you can read. If the exit code is 1 and the command looks fine to you, suspect the parser before you suspect the logic.
When it is not the shell at all
Two other Windows-specific causes worth ruling out before you rewrite anything.
Claude Code reads settings.json when the session starts. Edit your hooks mid-session and the session keeps using the snapshot it took at launch, so restart before concluding your change did nothing.
And there is a known issue where the Windows native installer binary (~/.local/bin/claude.exe) does not fire user-defined hooks, while the npm-installed claude.cmd on the same machine does. If your hooks stopped working after switching installers, check which binary is first on your PATH.
One config, several machines
The reason any of this matters is that a hook is usually not for you alone. It goes in a repo, or it goes to a team, and the machine that breaks it is never the one you tested on. Writing the shell-agnostic version costs a pair of quotes and saves the afternoon someone else spends wondering why their terminal is quiet.
Blooby lives on the other side of this problem. It installs its own hooks into your settings.json so it can give every Claude Code session an animated mascot, which means it has to run correctly on your machine without ever having seen it. The command above, quotes and exit 0 included, is the one it ships, and it got that way by being measured against all three shells rather than assumed.
See it for yourself
download Get Blooby free