Skip to content
Agentic Control Plane

Anthropic Agent SDK TypeScript hooks: interfaces reference

David Crowe David Crowe · · Updated · 21 min read
claude-agent-sdk anthropic hooks typescript reference
Share X HN LinkedIn

You add hooks in the Claude Agent SDK for TypeScript through one option on query(): hooks, typed Partial<Record<HookEvent, HookCallbackMatcher[]>>. Each callback is a HookCallback. It gets a typed HookInput and returns a HookJSONOutput that can allow, deny, rewrite or annotate the operation. The package is @anthropic-ai/claude-agent-sdk. The SDK used to be called the Claude Code SDK (Anthropic has a migration guide), and people still search for it as the “Anthropic Agent SDK”. All three names mean the same library.

The types below are copied from the official TypeScript SDK reference and the hooks guide, both read on 2026-11-07. The SDK ships often. If a field is missing from your version, those pages’ version notes say when it arrived.

The hook event list

HookEvent in the TypeScript reference is this union:

type HookEvent =
  | "PreToolUse"
  | "PostToolUse"
  | "PostToolUseFailure"
  | "PostToolBatch"
  | "Notification"
  | "UserPromptSubmit"
  | "UserPromptExpansion"
  | "SessionStart"
  | "SessionEnd"
  | "Stop"
  | "StopFailure"
  | "SubagentStart"
  | "SubagentStop"
  | "PreCompact"
  | "PostCompact"
  | "PreModelSwitch"
  | "PostModelSwitch"
  | "PermissionRequest"
  | "PermissionDenied"
  | "Setup"
  | "TeammateIdle"
  | "TaskCreated"
  | "TaskCompleted"
  | "Elicitation"
  | "ElicitationResult"
  | "ConfigChange"
  | "DirectoryAdded"
  | "WorktreeCreate"
  | "WorktreeRemove"
  | "InstructionsLoaded"
  | "CwdChanged"
  | "FileChanged"
  | "MessageDisplay";

The ones most people need, with what fires them according to the hooks guide table:

Event Fires when Can it change the outcome?
PreToolUse A tool call is requested Yes: allow, deny, ask, defer, rewrite input
PostToolUse A tool returned a result Yes: add context, replace the output
PostToolUseFailure A tool call failed Adds context
PostToolBatch A batch of tool calls resolves, before the next model call Adds context
UserPromptSubmit A prompt is submitted, including a turn Claude Code starts on its own Yes: add context or block
PermissionRequest A tool call needs a permission decision Yes: allow or deny
PermissionDenied Auto mode denies a tool call Can ask for a retry
Stop / SubagentStop The agent or a subagent stops Adds context or keeps the agent going
SubagentStart A subagent starts Adds context
SessionStart / SessionEnd Session begins / ends SessionStart adds context
PreCompact / PostCompact Conversation compaction starts / finishes No
Notification Agent status messages No

PostToolBatch, UserPromptExpansion, MessageDisplay, StopFailure, PostCompact, PermissionDenied, SessionStart, SessionEnd, Setup and the remaining events in the union are marked TypeScript-only in that table. The guide notes that Python’s HookEvent omits SessionStart and SessionEnd, so Python apps can only get them as shell command hooks from settings files.

The callback signature

type HookCallback = (
  input: HookInput, // Union of all hook input types
  toolUseID: string | undefined,
  options: { signal: AbortSignal }
) => Promise<HookJSONOutput>;

interface HookCallbackMatcher {
  matcher?: string;
  hooks: HookCallback[];
  timeout?: number; // Timeout in seconds for all hooks in this matcher
  onFailure?: "continue" | "block"; // "block" blocks the action when a callback fails
}

toolUseID correlates the PreToolUse and PostToolUse events for the same call. signal fires when the hook times out, so pass it to any fetch you make.

Input shapes

Every input extends BaseHookInput:

type BaseHookInput = {
  session_id: string;
  transcript_path: string;
  cwd: string;
  prompt_id?: string;
  permission_mode?: string;
  effort?: { level: string };
  agent_id?: string;
  agent_type?: string;
};

agent_id and agent_type are populated when the hook fires inside a subagent. The tool and prompt events add these fields:

type PreToolUseHookInput = BaseHookInput & {
  hook_event_name: "PreToolUse";
  tool_name: string;
  tool_input: unknown;
  tool_use_id: string;
  mcp_server?: McpServerProvenance;
};

type PostToolUseHookInput = BaseHookInput & {
  hook_event_name: "PostToolUse";
  tool_name: string;
  tool_input: unknown;
  tool_response: unknown;
  tool_use_id: string;
  duration_ms?: number;
  mcp_server?: McpServerProvenance;
};

type PostToolUseFailureHookInput = BaseHookInput & {
  hook_event_name: "PostToolUseFailure";
  tool_name: string;
  tool_input: unknown;
  tool_use_id: string;
  error: string;
  is_interrupt?: boolean;
  duration_ms?: number;
  mcp_server?: McpServerProvenance;
};

type UserPromptSubmitHookInput = BaseHookInput & {
  hook_event_name: "UserPromptSubmit";
  prompt: string;
  session_title?: string;
};

type PermissionRequestHookInput = BaseHookInput & {
  hook_event_name: "PermissionRequest";
  tool_name: string;
  tool_input: unknown;
  permission_suggestions?: PermissionUpdate[];
  mcp_server?: McpServerProvenance;
};

And the lifecycle events:

type SessionStartHookInput = BaseHookInput & {
  hook_event_name: "SessionStart";
  source: "startup" | "resume" | "clear" | "compact" | "fork";
  agent_type?: string;
  model?: string;
  session_title?: string;
};

type SessionEndHookInput = BaseHookInput & {
  hook_event_name: "SessionEnd";
  reason: ExitReason; // String from EXIT_REASONS array
};

type StopHookInput = BaseHookInput & {
  hook_event_name: "Stop";
  stop_hook_active: boolean;
  last_assistant_message?: string;
  background_tasks?: BackgroundTaskSummary[];
  session_crons?: SessionCronSummary[];
};

type SubagentStopHookInput = BaseHookInput & {
  hook_event_name: "SubagentStop";
  stop_hook_active: boolean;
  agent_id: string;
  agent_transcript_path: string;
  agent_type: string;
  last_assistant_message?: string;
  background_tasks?: BackgroundTaskSummary[];
  session_crons?: SessionCronSummary[];
};

type PreCompactHookInput = BaseHookInput & {
  hook_event_name: "PreCompact";
  trigger: "manual" | "auto";
  custom_instructions: string | null;
};

type NotificationHookInput = BaseHookInput & {
  hook_event_name: "Notification";
  message: string;
  title?: string;
  notification_type: string;
};

tool_input is unknown. Cast it to Record<string, unknown> (or to the per-tool input type, such as BashInput) before reading fields; every example in the official guide does the same.

Output shape: how a hook blocks, rewrites or adds context

type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput;

type AsyncHookJSONOutput = {
  async: true;
  asyncTimeout?: number;
};

SyncHookJSONOutput has top-level fields (continue, suppressOutput, stopReason, decision, systemMessage, reason, and a CLI-only terminalSequence) plus a hookSpecificOutput union keyed by hookEventName. The variants for the common events:

hookSpecificOutput?:
  | {
      hookEventName: "PreToolUse";
      permissionDecision?: "allow" | "deny" | "ask" | "defer";
      permissionDecisionReason?: string;
      updatedInput?: Record<string, unknown>;
      additionalContext?: string;
    }
  | {
      hookEventName: "UserPromptSubmit";
      additionalContext?: string;
      sessionTitle?: string;
      suppressOriginalPrompt?: boolean;
    }
  | {
      hookEventName: "PostToolUse";
      additionalContext?: string;
      classifierContext?: string;
      updatedToolOutput?: unknown;
      updatedMCPToolOutput?: unknown; // deprecated
    }
  | {
      hookEventName: "PermissionRequest";
      decision:
        | { behavior: "allow"; updatedInput?: Record<string, unknown>; updatedPermissions?: PermissionUpdate[] }
        | { behavior: "deny"; message?: string; interrupt?: boolean };
    }
  // ...one variant per event; see the TypeScript reference for the full list

The rules that matter, from the hooks guide:

  • Allow with no change: return {}.
  • Block a tool: PreToolUse with permissionDecision: "deny". permissionDecisionReason tells the model why; systemMessage tells the user.
  • Rewrite input: return updatedInput inside hookSpecificOutput, never at the top level. Pair it with "allow" to auto-approve the rewritten call or "ask" to show it to the user. Omit permissionDecision and the rewritten input still goes through normal permission evaluation. With "defer", updatedInput is ignored.
  • Add context: additionalContext reaches the model. systemMessage reaches the user, not the model.
  • Several hooks disagree: matching hooks run in parallel, and deny beats defer, which beats ask, which beats allow.
  • Async output ({ async: true }) lets the agent move on without waiting. It cannot block, modify or inject context. asyncTimeout is in milliseconds.

Registering hooks in query() and matchers

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Refactor the auth module",
  options: {
    hooks: {
      PreToolUse: [
        { matcher: "Bash", hooks: [denyDangerousBash], onFailure: "block" },
        { matcher: "Write|Edit", hooks: [rewriteWrites] },
        { matcher: "^mcp__", hooks: [mcpAudit] },
      ],
      PostToolUse: [{ hooks: [logEveryCall] }],
    },
  },
})) {
  console.log(message);
}

Matchers follow the settings-file matcher rules: a pipe-separated list such as Write|Edit is an exact list, and a pattern such as ^mcp__ is a regex. For tool events the matcher tests the tool name only, never arguments, so filter on tool_input.file_path inside the callback. MCP tools are named mcp__<server>__<action>. No matcher means every occurrence of the event. Stop ignores matchers.

Timeouts and failure semantics

From the Hook timeout section: when timeout is unset, the default is 600 seconds for most events, 30 seconds for UserPromptSubmit, PreModelSwitch and PostModelSwitch, and 10 seconds for MessageDisplay. SessionEnd runs under a shutdown budget of 1.5 seconds by default.

When a callback times out, its output is discarded and the session continues. Then:

  • PreToolUse: the tool call is blocked, and the tool result tells Claude the hook did not respond.
  • PostToolUse / PostToolUseFailure: the tool result is kept.
  • UserPromptSubmit: the prompt is blocked with a message naming the hook.
  • Stop / SubagentStop: counts as no decision.

A callback that throws is different. On most events the action goes ahead and your app receives an SDKInformationalMessage tagged hook_callback_failed. So a crashing PreToolUse guard fails open into the normal permission flow. A callback that returns nothing also counts as failing. Set onFailure: "block" on the matcher to fail closed instead (TypeScript SDK v0.3.317 or later, per the guide). Stop, SubagentStop, TaskCompleted and TeammateIdle ignore it.

Hooks vs settings.json hooks vs canUseTool

Settings-file hooks. The SDK runs your options.hooks callbacks and also the shell command hooks from settings files whose source is enabled in settingSources, which default query() options enable. Both use the same JSON output format. If you pass settingSources: [], the user, project and local settings files stop loading, hooks included; the TypeScript reference notes that endpoint-managed policy loads regardless.

canUseTool. Its type is:

type CanUseTool = (
  toolName: string,
  input: Record<string, unknown>,
  options: { signal: AbortSignal; toolUseID: string; requestId: string; decisionReason?: string; /* ... */ }
) => Promise<PermissionResult | null>;

The permissions page puts hooks first and canUseTool last: hooks, deny rules, ask rules, permission mode, allow rules, then canUseTool. A call approved by allowedTools, acceptEdits or bypassPermissions never reaches canUseTool. A PreToolUse hook runs for those calls too, and a hook deny applies even in bypassPermissions. A hook allow does not skip deny or ask rules.

Our April Agent SDK reference once said there was no post-call hook. It now carries the correction. The current hooks guide lists PostToolUse, PostToolUseFailure and PostToolBatch, so use those for checks on output.

Three working examples

Deny a command. If the check throws, the call is blocked.

import { query, HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

const denyDangerousBash: HookCallback = async (input) => {
  const pre = input as PreToolUseHookInput;
  const command = String((pre.tool_input as Record<string, unknown>)?.command ?? "");
  if (/\bgit\s+push\b.*--force/.test(command)) {
    return {
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: "Force-push is blocked in this app",
      },
    };
  }
  return {};
};

const options = {
  hooks: { PreToolUse: [{ matcher: "Bash", hooks: [denyDangerousBash], onFailure: "block" as const }] },
};

A regex on the command string is a convenience, not a boundary: git push -f or a script that pushes slips past it.

Log every call. Async, so it never slows the loop.

import { HookCallback, PostToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

const logEveryCall: HookCallback = async (input, toolUseID) => {
  const post = input as PostToolUseHookInput;
  const line = JSON.stringify({
    at: new Date().toISOString(),
    session: post.session_id,
    agent: post.agent_id ?? "main",
    tool: post.tool_name,
    toolUseID,
    ms: post.duration_ms,
  });
  appendToLog(line).catch(console.error); // your sink
  return { async: true, asyncTimeout: 30000 };
};

Rewrite input. Redirect writes into a scratch directory, mirroring the official example.

import { HookCallback, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

const rewriteWrites: HookCallback = async (input) => {
  if (input.hook_event_name !== "PreToolUse") return {};
  const pre = input as PreToolUseHookInput;
  const toolInput = pre.tool_input as Record<string, unknown>;
  if (pre.tool_name !== "Write") return {};
  return {
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "allow",
      updatedInput: { ...toolInput, file_path: `./scratch${toolInput.file_path as string}` },
    },
  };
};

Return a new object; do not mutate tool_input.

Limits to know

  • Matchers see tool names only. A Write|Edit guard does not run when Claude writes the same file with Bash. The guide says so and points to the sandbox for that case.
  • A throwing callback lets the call through unless you set onFailure: "block".
  • Hooks may not fire when the agent hits maxTurns, because the session ends first.
  • A hook allow is not absolute. Deny rules still apply, and a hook cannot approve recognized critical-path removals such as rm -rf ~.
  • Callbacks live in your Node process. They know the session, not the person behind it. Your log is only as durable as the place you write it.
  • Recursion. A UserPromptSubmit hook that spawns subagents can loop if those subagents trigger the same hook.

ACP’s Claude Code install adds PreToolUse and PostToolUse hooks through the ACP plugin. On an older Claude Code without plugin support, it writes them to ~/.claude/settings.json instead (details). An SDK app that loads the settings sources may run them like any other settings hook. We have not tested that under the SDK. If you set a custom env, pass {...process.env} so the hook keeps HOME and its credentials. For tool-use loops on the raw Anthropic SDK, the Anthropic Agent SDK integration wraps your handlers instead.

On the security side (tool boundaries, sandboxing, where a policy check sits), see Claude Agent SDK security architecture and tool integration.

Frequently asked questions

What are the Anthropic Agent SDK TypeScript hooks interfaces?

A hook is a HookCallback: (input: HookInput, toolUseID: string | undefined, options: { signal: AbortSignal }) => Promise<HookJSONOutput>. You register it inside a HookCallbackMatcher (matcher?, hooks, timeout?, onFailure?) under an event name in the hooks option of query(). The package is @anthropic-ai/claude-agent-sdk.

What are the Anthropic Agent SDK UserPromptSubmit hook details?

UserPromptSubmitHookInput adds hook_event_name: "UserPromptSubmit", prompt: string and an optional session_title to the base input. The callback can return hookSpecificOutput.additionalContext to inject context, or block the prompt. Its default timeout is 30 seconds, and a timeout blocks the prompt while the session continues.

How do Anthropic Agent SDK hooks PreToolUse and PostToolUse differ?

PreToolUse runs before the tool and can return permissionDecision (allow, deny, ask, defer) plus updatedInput. PostToolUse runs after, receives tool_response and duration_ms, and can append additionalContext or replace the result with updatedToolOutput. The toolUseID argument correlates the two.

What lifecycle hooks does the Anthropic Agent SDK have?

In TypeScript: SessionStart, SessionEnd, Setup, UserPromptSubmit, Stop, StopFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, Notification, and more. Several are TypeScript-only; Python’s HookEvent omits SessionStart and SessionEnd.

Is a hook the same as canUseTool in the Agent SDK?

No. A PreToolUse hook runs first, for every matching call, including calls that allow rules or bypassPermissions approve. canUseTool runs last and only when nothing earlier resolved the call.

Where to read more

Running coding agents? ACP records each tool call its hook sees and makes the dangerous shell commands (a force-push, destructive SQL, curl | sh) wait for you. One command, free up to 5 agents:

macOS · Linux · WSL
curl -fsSL https://agenticcontrolplane.com/install.sh | bash
Windows · PowerShell
irm https://agenticcontrolplane.com/install.ps1 | iex

What the installer writes  ·  Getting started

Share X HN LinkedIn
Get the next data drop
What agents actually cost, new tool-surface captures, and the occasional incident post-mortem — sent when we publish something worth your inbox, not on a schedule. Unsubscribe anytime.
Share: Twitter LinkedIn
Related posts

← back to blog