| name | sdk-hooks-development |
| description | Use this skill when implementing TypeScript hook callbacks for the Claude Agent SDK — creating PreToolUse hooks to allow/deny tool calls, PostToolUse hooks to inject additionalContext, building factory functions for parameterized hooks, using HookCallback and HookJSONOutput types, applying isPreToolUseInput and isPostToolUseInput type guards, or designing a hooks strategy for an Agent SDK platform. Hooks in the TypeScript SDK are async functions, NOT JSON config files. |
| version | 1.0.0 |
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
type HookCallback = (
input: HookInput,
toolUseId: string,
context: { signal: AbortSignal }
) => Promise<HookJSONOutput>;
type HookJSONOutput = {
hookSpecificOutput?: {
hookEventName: string;
permissionDecision?: "allow" | "deny";
permissionDecisionReason?: string;
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> => {
if (signal.aborted) return {};
if (!isPreToolUseInput(input)) return {};
const filePath = getToolInputFilePath(input);
if (!filePath) return {};
if (filePath.endsWith(".env")) {
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Reading .env files is not allowed — they may contain secrets",
},
};
}
return {};
};
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;
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] },
...(lintFixHook ? [{ matcher: "Write|Edit", hooks: [lintFixHook] }] : []),
],
},
},
});
Utility Functions
Keep these in your types.ts — they centralize unsafe casts:
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 : "";
}
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 : "";
}
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,
permissionDecision: "allow",
updatedInput: {
...(input.tool_input as Record<string, unknown>),
file_path: `/sandbox${filePath}`,
},
},
};
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 (default true)
stopReason: string — message shown when continue is false
suppressOutput: boolean — hide hook stdout from transcript
systemMessage: 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 });
return {
systemMessage: `Tool "${failure.tool_name}" failed: ${failure.error}. Consider an alternative approach.`,
};
};
Design Rules
- Always check
signal.aborted first — 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
null when unavailable
- Never trust
tool_input types — 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
signal to fetch() — so HTTP requests cancel properly on hook timeout
- Use
input.hook_event_name — not hardcoded strings in hookEventName field
Advanced Patterns
For more patterns from these references:
references/pretooluse-patterns.md — path guards, filename guards, command keyword guards, extension guards
references/posttooluse-patterns.md — test reminders, TypeScript auto-fix, ESLint auto-fix, build verification
references/hook-events-reference.md — PreToolUse and PostToolUse deep dive, execution model, tool name reference
references/smart-dispatch-pattern.md — single dispatcher routing to sub-handlers by file type and tool; merge strategies; testing handlers in isolation
references/testing-hooks.md — unit test patterns with vitest, mock helpers, integration testing, mocking execSync
Examples:
examples/env-protection-hook.ts — .env file 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 writes
examples/smart-dispatch-hook.ts — single dispatcher routing to sub-handlers by file type and tool name
examples/auto-format-hook.ts — silent Prettier formatting after edits (PostToolUse, no additionalContext)
examples/input-redirect-hook.ts — updatedInput patterns: sandbox redirect, strip dangerous flags, inject env vars