claude-hook-kit

March 21, 2026 · View on GitHub

TypeScript SDK for building Claude Code hooks — typed, testable, composable.

Claude Code's hook system lets you intercept tool calls, inject context, and react to session events. Without this package, hooks are raw shell scripts that read/write JSON with no type safety, no testing utilities, and no shared patterns. claude-hook-kit fixes that.

import { defineHook, block, allow } from 'claude-hook-kit'

defineHook<'PreToolUse'>(async (event) => {
  if (event.tool_name === 'Bash') {
    const { command } = event.tool_input
    if (command.includes('rm -rf /')) {
      return block('Refusing to delete the filesystem')
    }
  }
  return allow()
}).run()

Installation

npm install claude-hook-kit

Requires Node.js ≥ 18.

What are Claude Code hooks?

Hooks are shell commands that Claude Code runs at specific lifecycle points. They receive a JSON payload on stdin and write a JSON response to stdout. Claude Code uses the response to decide whether to allow, block, or augment the operation.

EventWhen it firesCan block?Can inject context?
PreToolUseBefore any tool call
PostToolUseAfter a tool call completes
SessionStartOn startup, resume, clear, compact
UserPromptSubmitWhen the user submits a message
StopWhen Claude finishes a response✅ (force continue)

Register hooks in ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "node /path/to/my-hook.js"
      }]
    }]
  }
}

API

defineHook<Event>(handler)

The core function. Wraps your handler with stdin parsing, stdout serialization, and error handling.

import { defineHook, allow } from 'claude-hook-kit'

// Generic — no type parameter (untyped event)
defineHook(async (event) => allow()).run()

// Typed — full inference on the event payload
defineHook<'PreToolUse'>(async (event) => {
  console.error(event.tool_name, event.session_id)
  return allow()
}).run()

Call .run() at the bottom of your hook script. It reads stdin, calls your handler, writes stdout, and exits.

For unit testing, use .handle() instead:

const hook = defineHook<'PreToolUse'>(async (event) => {
  if (event.tool_name === 'Bash' && event.tool_input.command.includes('sudo')) {
    return block('No sudo')
  }
  return allow()
})

// Test without touching stdin/stdout
const response = await hook.handle({
  session_id: 'test',
  tool_name: 'Bash',
  tool_input: { command: 'sudo apt install vim' },
})
assert.deepEqual(response, { decision: 'block', reason: 'No sudo' })

Response builders

allow()

Lets the operation proceed. Equivalent to exiting 0 with no output.

block(reason?)

Prevents the operation. Claude sees reason as an error message and decides how to respond.

return block('This file is read-only')

inject(context)

Injects a string into Claude's context window. Claude reads this before continuing.

return inject('# Rule\nAlways use `const` instead of `let` in this codebase.')

Pattern matchers

matchesGlob(path, pattern)

Test a file path against a glob pattern. Supports *, **, ?, [...], {a,b}.

import { matchesGlob } from 'claude-hook-kit'

matchesGlob('src/components/Button.tsx', '**/*.tsx')  // true
matchesGlob('package.json', '*.json')                  // true
matchesGlob('README.md', '**/*.ts')                    // false

matchesRegex(text, pattern)

Test a string against a RegExp or regex string.

import { matchesRegex } from 'claude-hook-kit'

matchesRegex('git push origin main', /\bgit\s+push\b/)  // true
matchesRegex('npm install', 'npm\\s+install\\s+-g')      // false

matchesAnyGlob(path, patterns[])

matchesAnyRegex(text, patterns[])

Array variants — returns true if any pattern matches.


TypeScript types

All hook event payloads are fully typed. Import them directly:

import type {
  PreToolUseInput,
  PostToolUseInput,
  SessionStartInput,
  UserPromptSubmitInput,
  StopInput,
  BashToolInput,
  EditToolInput,
  WriteToolInput,
  HookResponse,
} from 'claude-hook-kit'

Extending tool types

If you use custom tools or MCP tools, extend ToolInputMap:

declare module 'claude-hook-kit' {
  interface ToolInputMap {
    MyCustomTool: { param: string; value: number }
  }
}

Examples

Bash guard — block dangerous commands

// bash-guard.ts
import { defineHook, block, allow, matchesAnyRegex } from 'claude-hook-kit'
import type { BashToolInput } from 'claude-hook-kit'

const BLOCKED = [
  /\brm\s+-[rRfF]*f[rRfF]*\s+\//,  // rm -rf /
  /\bcurl\b.*\|\s*(ba)?sh\b/,        // curl | sh
  />\s*\/etc\/passwd/,               // overwrite /etc/passwd
]

defineHook<'PreToolUse'>(async (event) => {
  if (event.tool_name !== 'Bash') return allow()
  const { command } = event.tool_input as BashToolInput
  if (matchesAnyRegex(command, BLOCKED)) {
    return block(`Blocked dangerous command: ${command.slice(0, 80)}`)
  }
  return allow()
}).run()

Skill injector — inject docs based on file patterns

// skill-injector.ts
import { defineHook, inject, allow, matchesAnyGlob } from 'claude-hook-kit'

defineHook<'PreToolUse'>(async (event) => {
  if (!['Read', 'Edit', 'Write'].includes(event.tool_name)) return allow()

  const { file_path } = event.tool_input as { file_path: string }

  if (matchesAnyGlob(file_path, ['**/*.prisma', '**/schema.prisma'])) {
    return inject('Remember: run `npx prisma generate` after every schema change.')
  }

  return allow()
}).run()

Session logger — record every tool call

// session-logger.ts
import { appendFileSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { defineHook, allow } from 'claude-hook-kit'

const LOG_DIR = join(homedir(), '.claude', 'session-logs')

defineHook<'PostToolUse'>(async (event) => {
  mkdirSync(LOG_DIR, { recursive: true })
  const line = JSON.stringify({
    ts: new Date().toISOString(),
    tool: event.tool_name,
    session: event.session_id,
  })
  appendFileSync(join(LOG_DIR, `${event.session_id}.jsonl`), line + '\n')
  return allow()
}).run()

Startup context injection

// startup-context.ts
import { defineHook, inject, allow } from 'claude-hook-kit'
import { readFileSync } from 'node:fs'

defineHook<'SessionStart'>(async (event) => {
  if (event.trigger !== 'startup') return allow()

  const rules = readFileSync('./ARCHITECTURE.md', 'utf8')
  return inject(`# Project Architecture\n\n${rules}`)
}).run()

Running hook scripts

Hook scripts need to be executable Node.js files. The simplest approach is to compile with tsc first:

# Compile
npx tsc --outDir dist examples/bash-guard.ts

# Register in settings.json
# "command": "node /path/to/dist/bash-guard.js"

Or use tsx for zero-compile TypeScript:

# "command": "npx tsx /path/to/bash-guard.ts"

License

MIT