TypeScript SDK Hook Development
Critical distinction: Agent SDK hooks (TypeScript HookCallback functions) are different from Claude Code plugin hooks (JSON config). This skill covers the TypeScript SDK programmatic API.
Core Types
// From @anthropic-ai/claude-agent-sdk (via your types.ts re-export)
type HookCallback = (
input: HookInput,
toolUseId: string,
context: { signal: AbortSignal }
) => Promise<HookJSONOutput>;
type HookJSONOutput = {
hookSpecificOutput?: {
hookEventName: string;
// PreToolUse:
permissionDecision?: "allow" | "deny";
permissionDecisionReason?: string;
// PostToolUse:
additionalContext?: string;
};
};
Pattern: Basic Hook Structure
Every hook follows this exact pattern:
import type { HookCallback, HookJSONOutput } from "../types";
import { isPreToolUseInput, getToolInputFilePath } from "../types";
export const myHook: HookCallback = async (input, _toolUseId, { signal }): Promise<HookJSONOutput> => {
// 1. Always check abort first
if (signal.aborted) return {};
// 2. Guard: only handle the right event type
if (!isPreToolUseInput(input)) return {};
// 3. Extract data
const filePath = getToolInputFilePath(input);
if (!filePath) return {};
// 4. Apply logic
if (filePath.endsWith(".env")) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Reading .env files is not allowed — they may contain secrets",
},
};
}
return {}; // empty = allow
};
Pattern: Factory Function (Parameterized Hooks)
Use factory functions when a hook needs runtime parameters:
import path from "node:path";
import type { HookCallback, HookJSONOutput } from "../types";
import { getToolInputFilePath, isPreToolUseInput } from "../types";
export function createFileRestrictionHook(allowedFilePath: string): HookCallback {
const normalized = path.resolve(allowedFilePath);
return async (input, _toolUseId, { signal }): Promise<HookJSONOutput> => {
if (signal.aborted) return {};
if (!isPreToolUseInput(input)) return {};
const filePath = getToolInputFilePath(input);
if (!filePath) return {};
if (path.resolve(filePath) === normalized) return {};
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `Only ${allowedFilePath} can be modified`,
},
};
};
}
Pattern: PostToolUse — additionalContext
Inject feedback into tool results to guide the agent:
import type { HookCallback, HookJSONOutput } from "../types";
import { getToolInputCommand, isPostToolUseInput } from "../types";
export const testReminderHook: HookCallback = async (input, _toolUseId, { signal }): Promise<HookJSONOutput> => {
if (signal.aborted) return {};
if (!isPostToolUseInput(input)) return {};
const command = getToolInputCommand(input);
if (!isTestCommand(command)) return {};
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
additionalContext: "REMINDER: If tests pass, stop. If 10+ pass with failures, prune.",
},
};
};
Pattern: PostToolUse — Auto-Fix with Feedback
Run linters/typecheck after edits and inject remaining errors:
import { execSync } from "node:child_process";
import type { HookCallback, HookJSONOutput } from "../types";
import { getExecOutput, getToolInputFilePath, isPostToolUseInput } from "../types";
export function createLintFixHook(workingDirectory: string, targetFile: string): HookCallback | null {
const eslintBin = path.join(workingDirectory, "node_modules", ".bin", "eslint");
if (!existsSync(eslintBin)) return null; // graceful disable
return async (input, _toolUseId, { signal }): Promise<HookJSONOutput> => {
if (signal.aborted) return {};
if (!isPostToolUseInput(input)) return {};
const filePath = getToolInputFilePath(input);
if (path.resolve(filePath) !== path.resolve(targetFile)) return {};
try {
execSync(`${eslintBin} --fix "${targetFile}" 2>&1`, {
cwd: workingDirectory,
encoding: "utf8",
timeout: 30_000,
});
return {};
} catch (error: unknown) {
const output = getExecOutput(error);
if (!output) return {};
return {
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: `LINT ERRORS after auto-fix:\n${output.slice(0, 2000)}`,
},
};
}
};
}
Hook Registration
Register hooks in the query() call options:
const fileRestrictionHook = createFileRestrictionHook(params.testFilePath);
const lintFixHook = createLintFixHook(params.workingDirectory, params.testFilePath);
await query({
prompt,
options: {
// ...
hooks: {
PreToolUse: [
{ matcher: "Write|Edit", hooks: [fileRestrictionHook] },
{ matcher: "Read", hooks: [envProtectionHook] },
],
PostToolUse: [
{ matcher: "Bash", hooks: [testPruneHook] },
// Conditionally include lintFixHook if binary exists
...(lintFixHook ? [{ matcher: "Write|Edit", hooks: [lintFixHook] }] : []),
],
},
},
});
Utility Functions
Keep these in your types.ts — they centralize unsafe casts:
// Safe extraction of file_path from PreToolUse or PostToolUse input
export function getToolInputFilePath(input: PreToolUseHookInput | PostToolUseHookInput): string {
const toolInput = input.tool_input as Record<string, unknown> | undefined;
const filePath = toolInput?.file_path;
return typeof filePath === "string" ? filePath : "";
}
// Safe extraction of command from PostToolUse Bash input
export function getToolInputCommand(input: PostToolUseHookInput): string {
const toolInput = input.tool_input as Record<string, unknown> | undefined;
const command = toolInput?.command;
return typeof command === "string" ? command : "";
}
// Safe extraction of execSync error output
export function getExecOutput(error: unknown): string {
const execError = error as { stdout?: string; stderr?: string };
return ((execError.stdout ?? "") + (execError.stderr ?? "")).trim();
}
Type Guards
export function isPreToolUseInput(input: HookInput): input is PreToolUseHookInput {
return input.hook_event_name === "PreToolUse";
}
export function isPostToolUseInput(input: HookInput): input is PostToolUseHookInput {
return input.hook_event_name === "PostToolUse";
}
Pattern: Modify Tool Input (updatedInput)
Redirect or sanitize tool inputs before execution. Requires permissionDecision: "allow". Never mutate tool_input — always return a new object:
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name, // always use input.hook_event_name, not hardcoded string
permissionDecision: "allow", // required when using updatedInput
updatedInput: {
...(input.tool_input as Record<string, unknown>),
file_path: `/sandbox${filePath}`, // redirect writes to sandbox
},
},
};
Pattern: Stop the Agent
Return continue: false to halt the agent entirely (different from denying a single tool):
return {
continue: false,
stopReason: "Budget exhausted — stopping before incurring more cost.",
};
Top-level output fields (outside hookSpecificOutput):
continue: boolean— whether the agent continues (defaulttrue)stopReason: string— message shown whencontinueisfalsesuppressOutput: boolean— hide hook stdout from transcriptsystemMessage: string— inject a message directly into Claude's conversation
Pattern: PostToolUseFailure
Handle tool execution failures. TypeScript-only event. Use top-level systemMessage — hookSpecificOutput is not supported for this event type:
const failureLogger: HookCallback = async (input, toolUseID, { signal }) => {
if (signal.aborted) return {};
if (input.hook_event_name !== "PostToolUseFailure") return {};
const failure = input as PostToolUseFailureHookInput;
console.error("[TOOL FAILURE]", failure.tool_name, failure.error, { isInterrupt: failure.is_interrupt });
// systemMessage (top-level) — NOT hookSpecificOutput, which isn't supported here
return {
systemMessage: `Tool "${failure.tool_name}" failed: ${failure.error}. Consider an alternative approach.`,
};
};
Design Rules
- Always check
signal.abortedfirst — prevents work on cancelled operations - Always type-guard the input — hooks receive
HookInput, guard to the specific type - Return
{}for non-applicable cases — empty output = allow/no-op - Graceful disable — factory hooks that depend on binaries (eslint, tsc) should return
nullwhen unavailable - Never trust
tool_inputtypes — always cast safely via helpers - Keep hooks independent — hooks for the same matcher run sequentially; all are evaluated even if an earlier one denies
- Never throw from a hook — swallow errors; throwing can crash the agent
- Pass
signaltofetch()— so HTTP requests cancel properly on hook timeout - Use
input.hook_event_name— not hardcoded strings inhookEventNamefield
Advanced Patterns
For more patterns from these references:
references/pretooluse-patterns.md— path guards, filename guards, command keyword guards, extension guardsreferences/posttooluse-patterns.md— test reminders, TypeScript auto-fix, ESLint auto-fix, build verificationreferences/hook-events-reference.md— PreToolUse and PostToolUse deep dive, execution model, tool name referencereferences/smart-dispatch-pattern.md— single dispatcher routing to sub-handlers by file type and tool; merge strategies; testing handlers in isolationreferences/testing-hooks.md— unit test patterns with vitest, mock helpers, integration testing, mockingexecSync
Examples:
examples/env-protection-hook.ts—.envfile read blocker (PreToolUse)examples/file-restriction-hook.ts— single-file write restriction factory (PreToolUse)examples/security-blocker-hook.ts— comprehensive security: dangerous commands + protected files + out-of-project writesexamples/smart-dispatch-hook.ts— single dispatcher routing to sub-handlers by file type and tool nameexamples/auto-format-hook.ts— silent Prettier formatting after edits (PostToolUse, no additionalContext)examples/input-redirect-hook.ts—updatedInputpatterns: sandbox redirect, strip dangerous flags, inject env vars