From agent-sdk-pro
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/agent-sdk-pro:sdk-hooks-developmentThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**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.
examples/auto-format-hook.tsexamples/env-protection-hook.tsexamples/file-restriction-hook.tsexamples/input-redirect-hook.tsexamples/security-blocker-hook.tsexamples/smart-dispatch-hook.tsreferences/hook-events-reference.mdreferences/posttooluse-patterns.mdreferences/pretooluse-patterns.mdreferences/smart-dispatch-pattern.mdreferences/testing-hooks.mdCritical distinction: Agent SDK hooks (TypeScript HookCallback functions) are different from Claude Code plugin hooks (JSON config). This skill covers the TypeScript SDK programmatic API.
// 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;
};
};
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
};
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`,
},
};
};
}
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.",
},
};
};
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)}`,
},
};
}
};
}
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] }] : []),
],
},
},
});
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();
}
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";
}
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
},
},
};
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 falsesuppressOutput: boolean — hide hook stdout from transcriptsystemMessage: string — inject a message directly into Claude's conversationHandle 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.`,
};
};
signal.aborted first — prevents work on cancelled operationsHookInput, guard to the specific type{} for non-applicable cases — empty output = allow/no-opnull when unavailabletool_input types — always cast safely via helperssignal to fetch() — so HTTP requests cancel properly on hook timeoutinput.hook_event_name — not hardcoded strings in hookEventName fieldFor 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, mocking execSyncExamples:
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 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 — updatedInput patterns: sandbox redirect, strip dangerous flags, inject env varsnpx claudepluginhub itamarzand88/claude-code-agentic-engineering --plugin agent-sdk-proGuides authoring secure, performant hooks for Claude Code (JSON) and Claude Agent SDK (Python) for validation, logging, policy enforcement, and automation.
Guides creation of Claude Code plugin hooks with prompt-based and bash command types for PreToolUse, PostToolUse, Stop, and other events. Covers plugin hooks.json and settings.json formats.
Guides writing Claude Code hooks: event selection, hook types (command/prompt/agent), matcher patterns, blocking vs advisory, and portable paths. Use when creating hooks for quality gates, automation, or policy enforcement.