The missing TypeScript toolkit for Claude Code hooks.
Claude Code supports 27 hook events — shell scripts that fire when Claude edits a file, runs a command, finishes a response, and more. But writing them is painful: no types, no autocomplete, silent failures, and you're staring at a JSON config hoping you got the exit codes right.
@claude-toolkit/hooks fixes that.
npm install -g @claude-toolkit/hooks# Add a pre-built hook in one command
claude-hooks add notify-on-stop
claude-hooks add lint-on-edit --command "eslint ."
claude-hooks add block-main-branch
# See everything that's installed
claude-hooks list
# Test a hook locally — no Claude needed
claude-hooks run PostToolUse --input '{"tool_name":"Edit","tool_input":{"file_path":"app.ts"}}'Or build your config in TypeScript:
import { HooksBuilder } from '@claude-toolkit/hooks'
new HooksBuilder()
.on('PostToolUse', { match: 'Edit' })
.command('npm run lint', { async: true })
.on('Stop')
.command('osascript -e "beep"')
.on('UserPromptSubmit')
.http('https://myserver.com/log', {
headers: { Authorization: 'Bearer $MY_TOKEN' },
allowedEnvVars: ['MY_TOKEN'],
})
.installToProject() // writes to .claude/settings.jsonOr write your hook logic in TypeScript with full type safety:
// hooks/post-tool-use.ts
import { createHookHandler } from '@claude-toolkit/hooks/runner'
createHookHandler('PostToolUse', async (input) => {
// input is fully typed — tool_name, tool_input, tool_response...
if (input.tool_name === 'Edit') {
console.log(`File changed: ${(input.tool_input as any).file_path}`)
}
// return nothing → exit 0 (success)
// return { block: true, message: '...' } → exit 2 (block + tell the model)
// throw new Error('...') → exit 1 (show error to user)
})Claude Code's hook system is genuinely powerful. You can intercept every tool call, block dangerous operations, run linters automatically, send webhooks, and more. But the developer experience is rough:
- No TypeScript types. You write a shell command and hope the JSON input looks like what you expect.
- Exit code semantics vary per event. Exit 2 means "block" for
PreToolUsebut "show to model" forPostToolUse. Good luck remembering that at 2am. - Silent failures. A misconfigured hook just doesn't run. No error, no warning.
- No way to test hooks. You have to trigger Claude Code, wait for the event, and observe behavior.
@claude-toolkit/hooks solves all of this.
# Global install (recommended for CLI use)
npm install -g @claude-toolkit/hooks
# Or as a project dependency (for programmatic API)
npm install @claude-toolkit/hooksRequirements: Node.js 18+
Add a pre-built hook preset to your project.
claude-hooks add notify-on-stop # ring bell when Claude finishes
claude-hooks add lint-on-edit # run npm run lint after every file edit
claude-hooks add lint-on-edit --command "eslint . --fix" # custom command
claude-hooks add block-main-branch # prevent git push to main/master
claude-hooks add auto-commit # auto-commit on session end
# Install to user settings (~/.claude/settings.json) instead of project
claude-hooks add notify-on-stop --scope userAvailable presets:
| Preset | What it does |
|---|---|
notify-on-stop |
Rings the system bell when Claude finishes a response |
lint-on-edit |
Runs a lint command after every file edit (async, non-blocking) |
block-main-branch |
Blocks git push to main or master |
auto-commit |
Auto-commits all changes when the session ends |
List all installed hooks across project and user scopes.
claude-hooks list # show all
claude-hooks list --scope project
claude-hooks list --scope userExample output:
Project hooks (/your/project/.claude/settings.json):
PostToolUse:
command [match: Edit]: eslint . (async)
Stop:
command: osascript -e "beep" (async)
Test a hook event with mock input — no Claude Code required.
# Basic
claude-hooks run Stop
# With input
claude-hooks run PostToolUse --input '{"tool_name":"Edit","tool_input":{"file_path":"app.ts"}}'
# Test blocking behavior
claude-hooks run PreToolUse --input '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}'Shows the command that ran, its output, and whether it would block Claude.
Install a hook script into .claude/settings.json.
# TypeScript file (compiled automatically via esbuild)
claude-hooks install ./hooks/my-hook.ts --event PostToolUse
# Shell script
claude-hooks install ./hooks/notify.sh --event Stop
# With matcher
claude-hooks install ./hooks/lint.ts --event PostToolUse --match Edit
# Install to user scope
claude-hooks install ./hooks/my-hook.ts --event Stop --scope userThe filename is used to infer the event if --event is not provided:
post-tool-use.ts→PostToolUsesession-end.sh→SessionEnd
import { HooksBuilder } from '@claude-toolkit/hooks'
const builder = new HooksBuilder()
// Shell command hook
builder
.on('PostToolUse', { match: 'Edit' })
.command('npm run lint', {
async: true, // run in background
timeout: 30, // seconds
statusMessage: 'Linting...',
once: false,
})
// HTTP webhook
builder
.on('Stop')
.http('https://api.example.com/notify', {
headers: { 'X-API-Key': '$MY_API_KEY' },
allowedEnvVars: ['MY_API_KEY'],
})
// LLM prompt hook
builder
.on('PostToolUse')
.prompt('Did the last tool call look safe? Reply YES or NO.')
// Agent verification hook
builder
.on('Stop')
.agent('Verify that all changed files have passing tests.')
// Merge multiple builders
const a = new HooksBuilder().on('Stop').command('beep')
const b = new HooksBuilder().on('SessionStart').command('echo start')
new HooksBuilder().use(a).use(b).installToProject()
// Export as plain object
const config = builder.toJSON()
// → { PostToolUse: [{ matcher: 'Edit', hooks: [...] }], ... }
// Write to settings.json
builder.installToProject() // .claude/settings.json
builder.installToUser() // ~/.claude/settings.jsonWrite hook logic in TypeScript. Your handler receives fully typed input and returns a typed result.
// hooks/my-hook.ts
import { createHookHandler } from '@claude-toolkit/hooks/runner'
createHookHandler('PreToolUse', async (input) => {
// Block dangerous commands
const cmd = (input.tool_input as { command?: string }).command ?? ''
if (cmd.includes('rm -rf /')) {
return {
block: true,
message: 'Nice try. That command is not allowed.',
}
}
// Log to a file
const { appendFileSync } = await import('fs')
appendFileSync('/tmp/claude-log.txt', `${new Date().toISOString()} ${input.tool_name}\n`)
// Return nothing for success
})Compile and install:
claude-hooks install ./hooks/my-hook.ts --event PreToolUseYour return value maps to exit codes automatically:
| Return value | Exit code | Effect |
|---|---|---|
void (return nothing) |
0 | Success |
{ block: true, message } |
2 | Block + show message to model |
throw new Error(message) |
1 | Show error to user |
{ hookSpecificOutput: ... } |
0 | JSON output for special events |
All 27 hook event types are exported:
import type {
HookEvent,
PreToolUseInput,
PostToolUseInput,
PostToolUseFailureInput,
PermissionDeniedInput,
UserPromptSubmitInput,
SessionStartInput,
SessionEndInput,
StopInput,
StopFailureInput,
NotificationInput,
SubagentStartInput,
SubagentStopInput,
PreCompactInput,
PostCompactInput,
PermissionRequestInput,
PermissionRequestOutput,
SetupInput,
ConfigChangeInput,
InstructionsLoadedInput,
CwdChangedInput,
FileChangedInput,
ElicitationInput,
ElicitationOutput,
// ... and more
} from '@claude-toolkit/hooks'Use presets programmatically:
import { HooksBuilder } from '@claude-toolkit/hooks'
import { lintOnEdit, notifyOnStop, blockMainBranch } from '@claude-toolkit/hooks/presets'
new HooksBuilder()
.use(lintOnEdit({ command: 'prettier --write .' }))
.use(notifyOnStop())
.use(blockMainBranch())
.installToProject()Claude Code fires these events. All types are available from @claude-toolkit/hooks.
| Event | When it fires | Can block? |
|---|---|---|
PreToolUse |
Before any tool runs | ✅ exit 2 |
PostToolUse |
After tool succeeds | Shows to model |
PostToolUseFailure |
After tool fails | Shows to model |
UserPromptSubmit |
When you submit a prompt | ✅ exit 2 |
Stop |
Before Claude finishes response | Shows to model |
StopFailure |
When API error ends the turn | ❌ fire-and-forget |
SessionStart |
New session begins | — |
SessionEnd |
Session is ending | — |
PermissionRequest |
Permission dialog shown | ✅ programmatic |
PermissionDenied |
Auto-mode denies a tool | Can retry |
Notification |
Claude sends a notification | — |
SubagentStart |
Subagent spawned | — |
SubagentStop |
Subagent finishing | Can continue |
PreCompact |
Before context compaction | ✅ exit 2 |
PostCompact |
After context compaction | — |
Setup |
Repo init/maintenance | — |
ConfigChange |
Settings file changed | ✅ exit 2 |
InstructionsLoaded |
CLAUDE.md loaded | ❌ observability only |
CwdChanged |
Working directory changed | — |
FileChanged |
Watched file changed | — |
WorktreeCreate |
Create a worktree | — |
WorktreeRemove |
Remove a worktree | — |
TeammateIdle |
Teammate going idle | Can prevent |
TaskCreated |
Task being created | ✅ exit 2 |
TaskCompleted |
Task being completed | ✅ exit 2 |
Elicitation |
MCP server requests input | ✅ programmatic |
ElicitationResult |
User responds to elicitation | Can override |
MIT