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.logThat 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 -vandnc -zv 127.0.0.1 9127are already installed on every box. Unix sockets needsocatand 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 /healthforclaudedeck doctoris 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 subscriptionsOne 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:
PreToolUsefires.- Curl POSTs to the daemon. Daemon registers a pending permission.
- Daemon notifies the Stream Deck plugin via WebSocket.
- Plugin lights up the YES/NO keys.
- Hook blocks waiting for the daemon’s response. Curl’s
--max-time 590caps the wait safely under Claude’s 600s ceiling. - User presses a key. Plugin sends
permission:respondto 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 #
- The payload is on stdin. Always. No environment variable, no flag. Run
claude --debug hooksto confirm what’s actually being passed before you build anything else. - 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. - Fire-and-forget by default. Block only on
PreToolUseif you’re gating permissions. Cap the block at 590 seconds to stay under Claude’s 600s timeout. - Project-local settings override user-level. They do not merge. When hooks aren’t firing, check
<project>/.claude/settings.local.jsonbefore you check anything else. - 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 #
- Claude Code hooks documentation — https://docs.claude.com/en/docs/claude-code/hooks (canonical schema for events and
hookSpecificOutput) - AgentDeck, the upstream HTTP+stdin pattern this work borrowed from — https://github.com/puritysb/AgentDeck/tree/master/bridge (see
bridge/src/for the curl-on-stdin hook handler) claude --debug hooks --debug-file <path>— undocumented but functional at the time of writing; discovered by running it and watching the stdin pipe behaviour land in the debug log. Not present in the public hooks reference.- ClaudeDeck hook ingress, including the historical 590s permission hold —
daemon/src/server.ts(POST /hooks/:eventrouting) - ClaudeDeck statusline forwarder used for per-turn context-window telemetry —
cli/src/statusline.sh - Companion post on why
PreToolUsewaits up to 590 seconds for a key press — Holding an HTTP request open for 590 seconds - Companion post on how Claude Code’s statusline doubles as a per-turn telemetry side channel — The statusline side channel