I build ccdeck, a local dashboard for Claude Code and Codex sessions, and its hook is the worked example; everything about it is quoted from the source at the v3.42.0 tag (hook/hook.js, src/server/installer.mjs and src/web/__tests__/hook-read-only.test.ts, with two more tests named where they come up); at 3.48.0 the hook, its tests and the quoted installer lines are unchanged. Claude Code's behaviour is quoted from Anthropic's hooks reference as I read it on 14 October 2026.
Plenty of hooks only want to watch: a logger, a notifier, a dashboard. They still run on every event they register for, and on some events Claude Code waits for them and reads what they say. A PreToolUse hook sees rm -rf before it runs, and its answer can decide whether it does.
The read-only claim needs to hold in the code. List every channel the hook has for talking back, close each one, and test that it stays closed.
A command hook gets the event as JSON on stdin. From the reference, it has these ways to affect what happens next:
<hook name> hook error notice in the transcript", on every call it happens on.UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, "Claude Code adds plain-text stdout as context that Claude can see and act on". A stray log line there is a message to the model.onFailure: "block" turns any failure (a crash, a missing script, a timeout) into a block. async: true removes the blocking, but not the voice: when an async hook exits, its additionalContext and systemMessage reach Claude on the next turn.
An HTTP hook (type: "http") has the same powers through its response body, so a hook that POSTs straight to a local service hands the decision to whatever answers on that port; and a failed connection is a non-blocking error, a notice on every call while the service is down.
For an observer, that list turns into rules: a command hook that never writes to stdout, exits 0 on every path, declares no onFailure or async, keeps a short timeout, and checks who it is talking to before it sends anything. The rest of this post is how ccdeck's hook does each of those, and how its tests pin them.
ccdeck's test file opens with the reason it exists:
// The README says the deck cannot steer your agent. This is the file that makes
// that a property of the code rather than an intention.
and names the failure it guards against:
One console.log left in from debugging, or one process.exit(1) on an error path, turns a read-only observer into a participant in every tool call on the machine — and silently, because a hook that prints unparseable text is ignored until the day it prints something parseable, and a hook that exits non-zero either blocks the call it was reporting (exit 2) or shows a hook error on it (any other code).
Every path in hook.js is written to end at exit 0. A throw is the one way out that a reader of the code cannot see, so there is a last resort:
process.on("uncaughtException", () => process.exit(0));
process.on("unhandledRejection", () => process.exit(0));
That line came from a bug: a malformed record in the deck's registry crashed the hook with exit 1 and a stack trace, and, per the comment, "Claude Code surfaces a non-zero exit as <hook> hook error plus the first stderr line", on every tool call.
Writing the rule down is not enough; the next debugging session undoes it. hook-read-only.test.ts asserts it twice.
Over the source, it bans every spelling of stdout and every exit code but one:
expect(SRC).not.toMatch(/\bprocess\s*\.\s*stdout\b/);
expect(SRC).not.toMatch(/\bconsole\s*\./);
expect(SRC).not.toMatch(/\bwriteSync\s*\(/);
// …
expect(writes).toEqual(["req.write(body);"]); // the one write: the outbound request
// …
expect([...new Set(codes)]).toEqual(["0"]); // every process.exit(…) is exit(0)
expect(SRC).not.toMatch(/process\s*\.\s*exitCode/);
console.log is stdout with another name and fs.writeSync(1, …) is stdout with no name, so all three spellings are banned. process.exitCode is banned outright rather than checked for a value.
By running the real script, it starts an "honest deck" (a local HTTP server that answers the handshake below), feeds the hook a PreToolUse for rm -rf / on stdin, and checks:
expect(deck.seen).toEqual(["/api/event"]); // it really delivered
expect(run.stdout).toBe(""); // and said nothing back
expect(run.code).toBe(0);
The delivery is asserted on purpose: silence from a hook that did nothing would prove nothing. The same silence is then checked on every early return (no deck, unparseable payload, no cwd), on payloads that throw (a cwd that is a number, JSON nested 20,000 levels deep), and on eight malformed registry records, and a healthy deck registered next to a malformed record must still receive its event. Every run points CLAUDE_CONFIG_DIR and HOME at a temp directory, so the test never reads your real ~/.claude.
On PreToolUse the user waits until every matching hook returns, and the installer's comment is blunt about the declared timeout: it "is in SECONDS and it is a kill". So the hook has a deadline per phase and a cap over all of them:
const CHALLENGE_TIMEOUT_MS = 400; // one retry, only on a timeout
const POST_TIMEOUT_MS = 1000;
const DISCOVERY_TIMEOUT_MS = 400; // a second window only if the first found nothing
const CAP_MS = 1900;
The cap is measured from when Node started, not from when main() ran:
setTimeout(() => process.exit(0), Math.max(200, CAP_MS - Math.round(process.uptime() * 1000)));
That detail came from a measurement. The old timer started inside main(), so the shell fork, Node's start-up and reading a stdin payload that can be megabytes were outside the budget. With a stalled deck, the hook came out at 1.84 s idle and 1.98–2.19 s on a loaded machine, past the timeout: 2 it declared at the time (issue #1018). Now the cap counts from process start, the declared timeout is 3 seconds as a backstop, and hook-budget.test.ts pins the two numbers against each other so they cannot drift apart again.
The deadlines can add up to more than the cap (2.6 s if the filesystem, a listener and a deck all stall on one run), and the source says which number to believe: the cap "is the backstop over all of them, and it is what a reader should believe rather than the addition". Discovery gets its second 400 ms window only when the first found no deck and reads were still out, because on a loaded machine those reads are usually slow rather than stalled (issue #1872). A dead port costs nothing: a refused loopback connection is instant.
An observer usually forwards the event somewhere, and the event is everything: in the hook's own words, "prompt text, tool inputs, tool results, cwd". Sending it to a port because a process ID looks alive is not safe. A deck killed with SIGKILL or a power cut leaves its record behind, the OS can hand the PID to another process, and the port "may by then belong to anything at all (4317, the deck's own default, is also the standard OTLP collector port)".
So each running deck writes a fresh random token into its record at start-up, and before posting, the hook challenges the listener with a nonce it has never seen:
function challengeProof(token, nonce) {
return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
}
The token itself never crosses the wire, so a wrong listener learns nothing it could replay, and the answer is compared with crypto.timingSafeEqual, "purely so a hostile listener cannot walk the expected proof out of us one byte at a time".
The command written into someone's settings outlives the environment it was written in. ccdeck builds its entries for ~/.claude/settings.json like this, one per event for up to twelve events, tagged so they can be found and removed:
function buildHookEntry(command) {
return {
[MARK_KEY]: true, // "__agent-dag"
hooks: [{ type: "command", command, timeout: 3 }],
};
}
The command used to name the Node binary directly. Then nvm uninstall took that binary away, and every tool call of every session showed a hook error from then on. On macOS and Linux the arguments now go through a fixed /bin/sh script first:
const NODE_FALLBACK = 'n=$1; shift; [ -x "$n" ] || n=$(command -v node) || exit 0; exec "$n" "$@"';
It runs the recorded Node while it is still executable, the node on Claude Code's own PATH once it is not, and nothing at all when there is no Node: exit 0, no output. The Claude Code version is part of the environment too. Until 2.1.101, an event name Claude Code did not know made it ignore the whole settings file, so ccdeck adds its two newest entries only where every Claude Code it finds knows them: StopFailure from 2.1.78, and UserPromptExpansion from 2.1.116. That second one is also an event where plain text on stdout becomes context for the model, which is Rule 1 again. The paths reach it as positional arguments, quoted once, so none of them is spliced into shell code. hook-command-node-fallback.test.ts runs it through a real /bin/sh and states the trade: "a hook error on every tool call is a worse answer than a deck that hears nothing from a machine it cannot run on."
One more case of the environment surprising you: your own tool's Claude runs fire your hooks too. ccdeck reads Claude quota in one of its ways with claude --print /usage, which is itself a Claude Code run. Without a guard, every quota poll drew itself on the dashboard as an empty session. The deck marks that run with AGENTS_DECK_INTERNAL=1, and the hook exits before reading a byte.
~/.claude can change settings.json or the hook itself. No hook design defends against that. (ccdeck writes two other things there, separate from the hook: its Telemetry Radar switches write flags into the env block when you flip one, and its Settings › Claude Code page writes the one key you change; its uninstall leaves both as you set them.)claude stop and claude --resume <id> --bg at your press. That is a program starting a Claude Code command beside the session, not the hook answering one.127.0.0.1. That is the point of a dashboard; the handshake exists so they go only to it.PostToolUse for an Edit carries the whole edited file. Over 4,900,000 the hook replaces the biggest fields with a marker saying how large they were, and flags the event as truncated; the event name, session, tool and call id are never what goes./bin/sh in front of it on macOS and Linux, bounded by the budget above.onFailure and without async. Write timeout strictly above it.
AI assistance was used to prepare this article and check it against the cited source code and documentation.