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()npm install claude-hook-kitRequires Node.js ≥ 18.
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.
| Event | When it fires | Can block? | Can inject context? |
|---|---|---|---|
PreToolUse |
Before any tool call | ✅ | ✅ |
PostToolUse |
After a tool call completes | ❌ | ✅ |
SessionStart |
On startup, resume, clear, compact | ❌ | ✅ |
UserPromptSubmit |
When the user submits a message | ✅ | ✅ |
Stop |
When 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"
}]
}]
}
}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' })Lets the operation proceed. Equivalent to exiting 0 with no output.
Prevents the operation. Claude sees reason as an error message and decides how to respond.
return block('This file is read-only')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.')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') // falseTest 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') // falseArray variants — returns true if any pattern matches.
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'If you use custom tools or MCP tools, extend ToolInputMap:
declare module 'claude-hook-kit' {
interface ToolInputMap {
MyCustomTool: { param: string; value: number }
}
}// 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.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.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.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()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"MIT