HookStack
All guides

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/hooks

That 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.jsonl

That 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 install

You 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