Documentation

Typed hooks, generated safely

Author Claude Code hooks as named TypeScript exports, test them as functions, and build runtime-ready wrapper commands.

Install and build

Run the command. On first run it creates a self-contained .typed-claude-hooks/ project — package.json, hooks.config.ts, a strict tsconfig.json, and a .gitignore — installs itself into it, and builds. Your project root is never touched, so this works the same in a Python or Go repository.

$npx typed-claude-hooks

Existing files are never overwritten. Each run repins and reinstalls the sandbox dependency when its version drifts from the CLI's, leaving any dependencies you added for your own hooks alone. A file: or link: specifier is never rewritten.

To scaffold and install without building — no settings.json, no generated hooks — run init instead. It is the same setup step the command above performs on its own, stopped before the build.

$npx typed-claude-hooks init

The generated config uses the current authoring API:

import { defineHandler } from "@typed-rocks/typed-claude-hooks"

export const protectEnvFiles = defineHandler("PreToolUse", { matcher: "Write|Edit" }, async (input) => {
  if (input.tool_input.file_path.endsWith(".env")) {
    return {
      hookSpecificOutput: {
        permissionDecision: "deny" as const,
        permissionDecisionReason: "Cannot modify .env files",
      },
    }
  }
  return {}
})

Browser Playground

Open the Playground to author a hook with Monaco types and autocomplete. It compiles in your browser, previews the generated settings and file destinations, and downloads the source, Node artifacts, wrappers, and placement instructions as a ZIP.

Place the editable source at .typed-claude-hooks/hooks.config.ts and generated artifacts under .claude/hooks/typed-claude-hooks/. The downloaded settings file is a snippet: merge its hooks property into .claude/settings.json; do not replace your existing settings.

The Playground does not execute hooks. It accepts one hooks.config.ts with direct named export const handlers initialized by defineHandler(...). Multi-file configs and arbitrary or extra npm packages are not supported; imports are limited to @typed-rocks/typed-claude-hooks, @typed-rocks/typed-claude-hooks/types, and node:*. Downloads target Node only, not Bun or Deno. Source is not persisted or uploaded.

Author handlers

defineHandler is the config authoring API. Export every handler by name; the compiler discovers named exports and groups them by their event field. There is no default config export.

Use defineHandler(event, handler) or defineHandler(event, options, handler). The event selects the exact input and output types.

Events and tool inputs

All 30 Claude Code hook events are typed. Five events carry tool data and support matcher-aware narrowing: PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, and PermissionDenied.

Tool lifecycle

  • PreToolUse
  • PostToolUse
  • PostToolUseFailure
  • PostToolBatch
  • PermissionRequest
  • PermissionDenied

Session lifecycle

  • SessionStart
  • SessionEnd
  • Stop
  • StopFailure
  • Setup
  • PreCompact
  • PostCompact

Agents and tasks

  • SubagentStart
  • SubagentStop
  • TeammateIdle
  • TaskCreated
  • TaskCompleted

Interaction

  • Notification
  • UserPromptSubmit
  • UserPromptExpansion
  • Elicitation
  • ElicitationResult
  • MessageDisplay

Workspace

  • ConfigChange
  • WorktreeCreate
  • WorktreeRemove
  • InstructionsLoaded
  • CwdChanged
  • FileChanged

A matcher such as { matcher: "Bash" } narrows tool_input to BashInput. Union matchers such as Write|Edit produce a matching input union. The included SDK map covers 34 built-in tool inputs; custom matcher names remain valid with tool_input typed as unknown.

Typed output

Hook output is constrained to the selected event. For events with hookSpecificOutput, hookEventName is optional while authoring and is strongly typed if supplied. The generated runtime injects the input event name when the property is omitted.

Test handlers

testHandler invokes a handler directly without stdin, stdout, or a subprocess. It fills session_id, cwd, and transcript_path with test defaults; provide the event-specific fields.

import { testHandler } from "@typed-rocks/typed-claude-hooks/testing"
import { protectEnvFiles } from "./hooks.config"

const result = await testHandler(protectEnvFiles, {
  tool_name: "Write",
  tool_input: { file_path: ".env", content: "SECRET=123" },
  tool_use_id: "tool_1",
})

expect(result.hookSpecificOutput?.permissionDecision).toBe("deny")

CLI

Build with npx typed-claude-hooks [config]. Passing an explicit config path builds that file and skips the sandbox entirely. The command has four inputs:

InputDefaultPurpose
[config].typed-claude-hooks/hooks.config.tsConfig path
-o, --output.claude/settings.jsonTarget settings file
--hooks-dirhooks/ beside settingsGenerated hooks root
--runtimenodenode, bun, or deno

Runtime selection is CLI-only and applies to that build. It is not persisted in config or settings, so a later build without --runtime returns to Node.

Generated files

Every handler produces a self-contained .mjs bundle and a mandatory shell wrapper. Bash is the default and generates .sh; select PowerShell per handler to generate .ps1:

export const windowsHook = defineHandler(
  "PreToolUse",
  { matcher: "Bash", shell: "powershell" },
  async () => ({}),
)
.claude/
|-- settings.json
`-- hooks/
    `-- typed-claude-hooks/
        `-- PreToolUse/
            |-- protectEnvFiles.mjs
            `-- protectEnvFiles.sh

Generated settings commands always invoke the .sh or .ps1 wrapper, never the .mjs bundle directly. The wrapper checks the selected runtime and launches the adjacent bundle.

Build semantics

The build first loads and validates the config, extracts handlers, bundles all artifacts in memory, parses existing settings, and computes the merged settings. Validation errors therefore leave existing generated output untouched.

After validation succeeds, the build deletes and replaces the exact typed-claude-hooks managed directory under the selected hooks root. It writes generated files and settings directly, while preserving non-managed settings entries. Because these filesystem writes are not transactional, a disk or permission failure during this phase can leave partial generated output or settings that were not updated.