Write Your First Claude Code Hook in 5 Minutes
6 min read · Reviewed 2026-06-12
This is a hands-on tutorial. In about five minutes you will write a working Claude Code hook from scratch — one that logs every Bash command the agent runs — register it, test it, and watch it fire in a real session.
You need nothing but Node.js, which Claude Code already requires. No npm install, no dependencies. Let’s go.
Step 1 — Create the hooks folder
Hooks live in .claude/hooks/ at your project root. Create it:
mkdir -p .claude/hooksThat is also where the HookStack CLI installs hooks, so you can mix your own with catalogue hooks freely.
Step 2 — Write the hook
We will write a PostToolUse hook that logs every Bash command to a file. PostToolUse runs after the tool, so we get the command and its result. Save this as .claude/hooks/bash-logger.mjs:
// .claude/hooks/bash-logger.mjs
import { readFileSync, appendFileSync, mkdirSync } from 'fs'
import { join } from 'path'
import { fileURLToPath } from 'url'
export function run(input, {
append = appendFileSync,
mkdir = mkdirSync,
projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
now = () => new Date().toISOString(),
} = {}) {
const command = input.tool_input?.command ?? ''
if (!command) return null // not a Bash call with a command — skip
const dir = join(projectDir, '.claude', 'data')
mkdir(dir, { recursive: true })
const entry = { ts: now(), cmd: command.slice(0, 1000) }
append(join(dir, 'bash-log.jsonl'), JSON.stringify(entry) + '\n')
return entry
}
// Entry guard: only runs when executed directly, not when imported by a test.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'))
run(input)
}The logic lives in a pure run() function with its side effects (file writes, the clock) injected as defaults — that is what makes it unit-testable. The entry guard at the bottom does the real stdin reading and only runs when the file is executed directly.
Step 3 — Register it in settings.json
A script on disk does nothing until you wire it to an event. Open (or create) .claude/settings.json and add a PostToolUse hook matched to Bash:
// .claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/bash-logger.mjs"
}
]
}
]
}
}The matcher of "Bash" means this hook only fires on Bash tool calls. The $CLAUDE_PROJECT_DIR prefix makes the path resolve no matter where Claude Code launches from.
Step 4 — Test it manually
Never trust a hook you have not run. Pipe it the exact JSON Claude Code would send and confirm it behaves:
echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' \
| node .claude/hooks/bash-logger.mjs
# Then check the log was written:
cat .claude/data/bash-log.jsonl
# {"ts":"2026-06-12T…","cmd":"git status"}If you see the log line, the hook works. If you get a stack trace, fix it here before wiring it into a session — debugging a standalone script is far easier than debugging it live.
Step 5 — Watch it run
Start Claude Code in the project and ask it to do anything that runs a shell command (“show me the git log”, “list the files”). Every Bash command it runs now appends to .claude/data/bash-log.jsonl. Tail it in another terminal to watch the trail build up:
tail -f .claude/data/bash-log.jsonlThat is a complete, working hook. The same five steps — folder, script, register, test, run — apply to every hook you will ever write, blocking or not.
Want to block something instead of logging?
Switch the event to PreToolUse and return a block decision instead of writing a file. The structure is identical; only the return value changes:
export function run(input) {
const command = input.tool_input?.command ?? ''
if (/rm\s+-rf?\s+\//.test(command)) {
return { decision: 'block', reason: 'rm -rf on an absolute path is blocked.' }
}
return null
}Register it under PreToolUse instead of PostToolUse, and have the entry guard write the result to stdout: if (result) process.stdout.write(JSON.stringify(result)).
Or skip the writing entirely
Building hooks by hand is the best way to understand them — but for production guardrails you do not have to. HookStack ships ~90 hooks that are already written, tested, and dogfooded on its own repo. Install a curated set in one command:
npx hookstack-cli@latest installYou get the exact code that runs in production, plus the freedom to drop your own hooks alongside them in the same .claude/hooks/ folder.
Frequently asked questions
- Where do I put my Claude Code hook?
- Put the script in .claude/hooks/ at your project root, then register it in .claude/settings.json under the right event (PreToolUse, PostToolUse, Stop, etc.) with a command like node $CLAUDE_PROJECT_DIR/.claude/hooks/your-hook.mjs.
- Do I need to install anything to write a hook?
- No. Hooks are plain Node.js scripts using only built-in modules (fs, path, child_process). Node is already required by Claude Code, so there is nothing extra to install.
- How do I test a hook before using it?
- Pipe it a fake payload: echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | node .claude/hooks/your-hook.mjs and check the output or side effect. This isolates the script from the session.
- How do I make my hook block an action instead of just reacting?
- Use the PreToolUse event and return { decision: "block", reason: "…" } from run(), then write that JSON to stdout in the entry guard. PostToolUse runs after the action, so it cannot block.
Related hooks
Sources
Read next
- What Are Claude Code Hooks? A Practical GuideClaude Code hooks are commands that run automatically at lifecycle events. See the lifecycle, a complete working hook, and the settings.json that wires it up.
- Claude Code Hooks Not Working? A Troubleshooting GuideClaude Code hook not firing, output ignored, or breaking your session? Diagnose the common causes — bad matcher, invalid JSON, wrong exit code, paths — fix each.
- PreToolUse vs PostToolUse: Which Claude Code Hook to UsePreToolUse runs before a tool and can block it; PostToolUse runs after and reacts. See two complete hooks, the differing stdin payloads, and a one-line rule.