Skip to main content
  1. Posts/

The Claude Code hooks docs are wrong. Here's what's actually on the wire.

Nick Liu
Author
Nick Liu
Building infrastructure for Facebook Feed Ranking at Meta. Previously at Walmart, Twitter, AWS, and eBay. MS in Computer Science at Georgia Tech.
Table of Contents
I wrote a daemon to listen to Claude Code hooks. My first version read `$CLAUDE_HOOK_PAYLOAD` and logged empty bodies for two days straight. The payload was sitting on stdin the whole time.

This post is the five gotchas I hit while wiring up ClaudeDeck — a Stream Deck plugin (a small program that runs inside Elgato’s Stream Deck app on the USB grid of programmable LCD keys) that talks to Claude Code over its hooks system. Claude Code is Anthropic’s terminal CLI for Claude — claude in your shell — and its hooks are user-defined scripts it spawns at certain points in a session (before a tool call, on session start, on prompt submit). My daemon is a long-running background process the plugin and the hooks both talk to over a local socket. None of the gotchas are exotic. All of them cost me hours. Each one is a place where the docs were either silent, ambiguous, or contradicted by tribal knowledge I picked up from other people’s projects.

If you’re building anything that reacts to Claude Code events — a logger, a permission gate, a status widget — read this first.

Symptom: empty bodies, all the way down
#

The first plan was based on a design note I’d read claiming the hook payload arrives via an environment variable called $CLAUDE_HOOK_PAYLOAD. So the daemon listened on 127.0.0.1:9127/hooks/PreToolUse and the hook script was a one-liner:

#!/bin/bash
# (this is wrong)
echo "$CLAUDE_HOOK_PAYLOAD" | curl -X POST http://127.0.0.1:9127/hooks/PreToolUse -d @-

Sessions started. Hooks fired. The daemon log filled up with this:

hook PreToolUse session=undefined subs=0 body-length=0
hook UserPromptSubmit session=undefined subs=0 body-length=0

$CLAUDE_HOOK_PAYLOAD was always empty. The hook was running — exit code 0, no errors, curl was reaching the daemon — it just had nothing to send.

Gotcha 1: the payload is on stdin, not in an environment variable
#

The payload arrives on stdin (standard input — the file descriptor a program reads from by default, the same one cat reads when you pipe into it). Claude Code spawns the command with the JSON body piped to its standard input. The script in settings.local.json (Claude Code’s per-user or per-project config file at ~/.claude/settings.local.json or <project>/.claude/settings.local.json) is responsible for reading stdin and doing whatever it likes with the JSON. No environment variable is involved.

The corrected shape:

#!/bin/bash
curl -sf -X POST http://127.0.0.1:9127/hooks/PreToolUse \
  -H "Content-Type: application/json" \
  -d @-

-d @- tells curl to read the body from stdin. Claude Code pipes the JSON in, curl forwards it to the daemon, daemon parses it. Done.

How did I figure this out? Two paths converged. First, AgentDeck — an older Stream Deck integration for a related agent runtime — uses exactly the curl … -d @- pattern, no env var in sight. Second, I ran:

claude --debug hooks --debug-file /tmp/claude-hooks.log

That flag isn’t in the public docs (at least not at the time of writing) but it works, and the debug log shows the hook’s stdout, stderr, exit code, and — the part I needed — the fact that the stdin pipe was active and full while my script was reading an empty env var. The flag earned its keep ten times over in the next two days.

If you ever find yourself debugging hooks, claude --debug hooks first, everything else second.

What the JSON payload actually contains
#

Here’s a PreToolUse payload, formatted for reading:

{
  "session_id": "01J9...",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/Users/nickboy/workspace/some-project",
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/path/to/file.ts",
    "old_string": "...",
    "new_string": "..."
  }
}

The events I care about for a Stream Deck plugin: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, Notification. The fields you can rely on are session_id and hook_event_name. cwd, tool_name, tool_input show up on the events that have something to say about them. transcript_path points at the JSONL of the full conversation — useful but expensive; it grows fast.

The schema isn’t formally published, so treat anything past session_id and hook_event_name as best-effort.

Gotcha 2: HTTP beats Unix sockets for the hook listener
#

I spent an hour considering a Unix domain socket (a file-backed inter-process socket like /tmp/foo.sock — same Berkeley socket API as TCP, but no network stack) instead of HTTP. The pitch was “lower overhead, no port conflicts”. I dropped it. Reasons:

  • HTTP is debuggable. curl -v and nc -zv 127.0.0.1 9127 are already installed on every box. Unix sockets need socat and a finger memory I don’t have.
  • HTTP gives you a free WebSocket upgrade. The plugin needs a bidirectional channel to subscribe to state updates. (WebSocket is the protocol that piggybacks on an HTTP connection and then leaves it open for two-way messaging — what most web apps use for live chat.) Sharing the port between hooks (POST) and plugin (WebSocket on /ws) is one line of routing.
  • HTTP gives you a free health endpoint. GET /health for claudedeck doctor is trivial.
  • Port conflict is a non-issue. Pick a high port (9127 here), check on startup, fail loudly if taken.

The daemon ends up serving three concerns from one Bun process (Bun is a JavaScript runtime — like Node.js, but with a built-in Bun.serve() HTTP server):

POST /hooks/:event    # hook ingress from Claude Code
POST /control/:cmd    # commands from the Stream Deck plugin
GET  /health          # health check
GET  /ws              # WebSocket upgrade for state subscriptions

One Bun process, one port, three concerns. (daemon/src/server.ts)

Gotcha 3: fire-and-forget by default — but PreToolUse is different
#

The naive default for hooks is fire-and-forget: the script POSTs to the daemon, the daemon returns 200 immediately, Claude Code’s hook returns success, Claude proceeds. The daemon does its real work asynchronously, after the HTTP response is already on the wire.

This is the right default for almost every event. PostToolUse, Stop, Notification, UserPromptSubmit, SessionStart — none of them need to block Claude. Send a 200, queue the event, move on.

The exception is PreToolUse, which is where Claude asks “may I run this tool?”. The hook’s response — JSON on stdout, or an exit code — decides whether the tool runs at all:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow"
  }
}

If you want to use the hook as an out-of-process permission gate (an external program — not Claude itself — decides whether the tool call runs; in my case the user presses YES/NO on the Stream Deck), the hook needs to block until the human decides. Claude Code’s per-hook timeout defaults to 600 seconds, so the hook can hold for almost ten minutes waiting for input.

The pattern that works:

  1. PreToolUse fires.
  2. Curl POSTs to the daemon. Daemon registers a pending permission.
  3. Daemon notifies the Stream Deck plugin via WebSocket.
  4. Plugin lights up the YES/NO keys.
  5. Hook blocks waiting for the daemon’s response. Curl’s --max-time 590 caps the wait safely under Claude’s 600s ceiling.
  6. User presses a key. Plugin sends permission:respond to the daemon. Daemon completes the held HTTP response. Hook stdout gets the JSON, exits 0, Claude reads the decision.

That round trip — and the reasons holding an HTTP request open for 590 seconds turned out to be the wrong long-term shape — is its own post: Holding an HTTP request open for 590 seconds.

Gotcha 4: project-level settings override user-level (not merge)
#

You’d think hooks declared in ~/.claude/settings.local.json applies to every Claude session. It doesn’t.

When you start claude inside a project directory that has its own .claude/settings.local.json, the project file overrides the user file. Not merges — overrides. So if you wrote your hooks into the user file but the project already has a permissions block (very common — Claude auto-writes one on the first permission grant), your hooks won’t load for that project.

Symptom: claudedeck doctor is green, the daemon is running, hooks are wired up at ~/.claude/settings.local.json, but the daemon’s log shows zero hook activity for sessions started inside that one project.

Fix: merge the hook entries into the project’s .claude/settings.local.json too. ClaudeDeck has an installStatusLineForProjects() helper that scans ~/workspace/* (plus a configurable list) and patches each one, and a StatuslineAutoPatcher that does the same patch on every SessionStart for any cwd it hasn’t seen before. So opening Claude inside a new project triggers a one-time auto-patch. (hooks/install.ts)

Gotcha 5: settings are read once per session
#

Claude Code reads settings.local.json at session start. Not during the session. If you run an install script that adds hook entries while a Claude session is already open, that session keeps its pre-install settings until it exits.

Symptom: you just ran ./install.sh, doctor is green, but claudedeck status still says 0 sessions tracked.

Fix: open a fresh claude session. The next one picks up the new settings.

Trivial once you know. Easy to lose an hour to if you don’t.

What I’d tell my past self
#

  1. The payload is on stdin. Always. No environment variable, no flag. Run claude --debug hooks to confirm what’s actually being passed before you build anything else.
  2. HTTP on 127.0.0.1:<port> beats Unix sockets for a hook listener. You get free debuggability, free health endpoint, free WebSocket upgrade for a sibling protocol.
  3. Fire-and-forget by default. Block only on PreToolUse if you’re gating permissions. Cap the block at 590 seconds to stay under Claude’s 600s timeout.
  4. Project-local settings override user-level. They do not merge. When hooks aren’t firing, check <project>/.claude/settings.local.json before you check anything else.
  5. Settings are read once per session. Existing sessions don’t pick up new hooks. Open a new one.

The five gotchas above ate the better part of a week. Hopefully this saves someone else the trip.

References
#

Related

Holding HTTP open for 590 seconds so a Stream Deck key can approve a tool call

Claude Code wants to run a shell command. I want to press a physical Stream Deck key — the YES key, two inches to the left of my keyboard — to approve it. The hook gets exactly one HTTP response to decide allow vs deny. The key press might land in 200 milliseconds; it might land seven minutes later, after I've been pulled into a meeting and come back. The trick is that Claude Code's hook timeout is 600 seconds, which turns out to be just enough headroom to hold the HTTP response open the whole time and let a hardware button write the answer. (Setup, for anyone who hasn’t seen this stack before: Claude Code is Anthropic’s terminal CLI for Claude, and one of its hook events — PreToolUse — is a script Claude spawns and waits on before running a tool like Bash or Edit. The script’s stdout decides “allow” / “deny” / “ask”. Stream Deck is Elgato’s USB grid of programmable LCD keys. The plumbing I’m describing here lives in a daemon — a background process at 127.0.0.1:9127 — that the hook script POSTs to and that the Stream Deck plugin connects to over WebSocket. For the hooks docs themselves and the four other gotchas in that layer, see the hooks-reality post.)

Claude Code vs Cursor vs Copilot vs Windsurf: An Honest 2026 Comparison

The AI coding tool landscape in 2026 has finally settled into four serious players: Claude Code , Cursor , GitHub Copilot , and Windsurf . I've used all four on real work. This is the honest comparison. Forget feature checklists. What matters is how each tool feels under real engineering work — the kind I do every day as a senior software engineer at Meta. I built this site primarily with Claude Code, but I’ve put serious hours into the others.

I polled an undocumented endpoint for 18 hours. The data was on stdin.

My daemon logged 111 consecutive HTTP 429s against `https://api.anthropic.com/api/oauth/usage` over an 18-hour stretch, with zero successful responses ever in its lifetime. The poller was reading `Retry-After: 272` and ignoring it. While I was arguing with the backoff, Claude Code was pushing the same `rate_limits.five_hour` and `rate_limits.seven_day` numbers to my statusline command every turn, on stdin, for free. (Quick framing: Claude Code is Anthropic’s terminal CLI for Claude; Claude Max is the higher-tier subscription plan with weekly and 5-hour usage windows. HTTP 429 is “Too Many Requests” — the server’s polite way of saying “back off.” Retry-After is the response header that tells the client how long to wait. OAuth is the auth protocol Claude Code uses to talk to Anthropic on behalf of a logged-in user. And the statusline — the same one I covered in the statusline side-channel post — is the script Claude Code spawns every turn with a JSON blob on stdin.)