
What Anthropic Agent SDK Hooks Are (And Why You Need the TypeScript Reference)
Anthropic Agent SDK hooks are typed async TypeScript callbacks registered inside query() that intercept 12 lifecycle events in the @anthropic-ai/claude-agent-sdk package (official overview). They are the programmatic counterpart to the declarative settings.json hooks documented for Claude Code, sharing the same decision shape but executing as functions in your own Node.js process.
The official Anthropic docs publish individual event definitions. What is missing is a single reference that lists every event, its TypeScript input shape, every output channel, and a runnable example for each. This post is that reference.
TL;DR
- The Anthropic Agent SDK exposes 12 hook events: PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SubagentStart, SubagentStop, PreCompact, SessionStart, SessionEnd, Notification, PermissionRequest.
- Every hook is an async TypeScript callback typed as HookCallback receiving a typed input that extends BaseHookInput and a context with an AbortSignal.
- PreToolUse is the only event that can permission-gate a tool call. Set hookSpecificOutput.permissionDecision to allow, deny, or ask, with optional updatedInput to modify the tool arguments before execution.
- Stop and SubagentStop carry stop_hook_active. Always early-return when true or the hook re-triggers itself in an infinite loop.
- UserPromptSubmit can append systemMessage or additionalContext to steer the agent before any tool call, without blocking the prompt.
- Hooks register inside query() options.hooks as a record keyed by event name, each value an array of matchers with a regex filter and the callback list.
Programmatic hooks turn the Claude agent into a controllable surface inside your TypeScript app. Type the input, return the decision, and the agent obeys.
How Anthropic Agent SDK Hooks Work in TypeScript
Every hook is an async callback registered against an event name inside the options.hooks object passed to query(). Each event has its own typed input that extends a shared BaseHookInput and carries event-specific fields. The callback returns a HookJSONOutput object that controls the agent's next move. The canonical specification for the hook surface lives in the official hooks guide and the TypeScript reference.
The three concepts you need:
- HookEvent is a TypeScript union of all 12 event names. It drives the keys of
options.hooks. - HookCallback is the async function signature shared by every hook:
(input, toolUseID, { signal }) => Promise<HookJSONOutput>. - HookJSONOutput is the return shape with four channels:
hookSpecificOutput(event-specific decisions likepermissionDecision),systemMessage(append guidance to the conversation),additionalContext(inject context for certain events), anddecision: 'block'(Stop-family only).
A minimal registration looks like this:
import { query, HookCallback } from '@anthropic-ai/claude-agent-sdk'
const audit: HookCallback = async (input, toolUseID, { signal }) => {
console.log(`[${input.hook_event_name}]`, toolUseID)
return {}
}
for await (const message of query({
prompt: 'Refactor the auth middleware',
options: {
hooks: {
PreToolUse: [{ matcher: 'Edit|Write', hooks: [audit] }],
PostToolUse: [{ matcher: 'Edit|Write', hooks: [audit] }],
},
},
})) {
console.log(message)
}
Each matcher carries an optional matcher regex that filters by tool name, and a hooks array of callbacks that run in order when the matcher matches. Returning {} from a callback is the explicit "allow" signal.
The 12 Hook Events at a Glance
The HookEvent union published by the SDK enumerates every supported event. Memorize this table once and the rest of the reference is just typing.
| Event | When it fires | Can permission-gate? | Primary use |
|---|---|---|---|
| PreToolUse | Before a tool call executes | Yes | Security gates, deny dangerous actions, rewrite tool input |
| PostToolUse | After a tool call returns successfully | No | Audit, post-process results, inject context |
| PostToolUseFailure | After a tool call throws or fails | No | Error logging, retry instrumentation, alerting |
| UserPromptSubmit | When the user submits a prompt | Yes | Validate input, append system guidance, inject context |
| SessionStart | At the start of an agent session | No | Logging, telemetry init, conversation context priming |
| SessionEnd | When the agent session ends | No | Telemetry flush, summary generation, cleanup |
| Stop | When the main agent finishes responding | Block-style only | Final quality checks, mandatory verification, completion gating |
| SubagentStart | When a subagent is invoked | No | Subagent context priming, telemetry |
| SubagentStop | When a subagent finishes | Block-style only | Subagent output validation, coordination |
| PreCompact | Before message compaction runs | No | Preserve specific messages, log compaction events |
| Notification | When the agent sends a notification | No | Slack alerts, email triggers, observability |
| PermissionRequest | When the agent requests permission | No | Custom permission UX, audit trail |
Read this table as the API surface. Every event name is a literal key you can put in options.hooks. Every cell maps to a TypeScript shape you can import from the SDK.
PreToolUse: The One That Can Say No
PreToolUse is the most powerful event in the system because it is the only one that can permission-gate the tool call before it executes. Set permissionDecision to 'deny' and the tool never runs.
The input shape:
type PreToolUseHookInput = BaseHookInput & {
hook_event_name: 'PreToolUse'
tool_name: string
tool_input: unknown
}
The output channels you care about:
hookSpecificOutput.permissionDecision:'allow' | 'deny' | 'ask'hookSpecificOutput.permissionDecisionReason: string shown to ClaudehookSpecificOutput.updatedInput: replacementtool_input(only valid whenpermissionDecisionis'allow')hookSpecificOutput.hookEventName: must echoinput.hook_event_nameso the decision binds to the right event
Worked example: deny edits to .env
import {
query,
HookCallback,
PreToolUseHookInput,
} from '@anthropic-ai/claude-agent-sdk'
const protectEnvFiles: HookCallback = async (input, toolUseID, { signal }) => {
const preInput = input as PreToolUseHookInput
const filePath = preInput.tool_input?.file_path as string
const fileName = filePath?.split('/').pop()
if (fileName === '.env') {
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
permissionDecision: 'deny',
permissionDecisionReason: 'Cannot modify .env files',
},
}
}
return {}
}
for await (const message of query({
prompt: 'Update the database configuration',
options: {
hooks: {
PreToolUse: [{ matcher: 'Write|Edit', hooks: [protectEnvFiles] }],
},
},
})) {
console.log(message)
}
Worked example: rewrite tool input instead of denying
You do not have to block. You can rewrite. Return permissionDecision: 'allow' with an updatedInput to modify the tool call before execution. This pattern is useful for force-applying conventions, sanitizing arguments, or routing tool calls through a wrapper:
const enforcePrettierWrite: HookCallback = async input => {
const preInput = input as PreToolUseHookInput
if (preInput.tool_name !== 'Write') return {}
const original = preInput.tool_input as { file_path: string; content: string }
const formatted = await runPrettier(original.content, original.file_path)
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
permissionDecision: 'allow',
updatedInput: { ...original, content: formatted },
},
}
}
The agent never sees the unformatted content. The diff is applied silently.
PostToolUse: Audit and Annotate After the Fact
PostToolUse runs after a tool call returns. It cannot stop or rewind the action, but it can inject context for Claude's next turn through additionalContext.
type PostToolUseHookInput = BaseHookInput & {
hook_event_name: 'PostToolUse'
tool_name: string
tool_input: unknown
tool_response: unknown
}
Worked example: type-check after every Edit
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
const execAsync = promisify(exec)
const typeCheckAfterEdit: HookCallback = async input => {
if (input.hook_event_name !== 'PostToolUse') return {}
if ((input as any).tool_name !== 'Edit') return {}
try {
await execAsync('npx tsc --noEmit')
return {}
} catch (err) {
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
additionalContext: `TypeScript errors introduced by the last edit:\n${(err as Error).message}`,
},
}
}
}
When tsc fails, the error message is fed back into the conversation as additionalContext so Claude sees it on the next turn and can fix the regression itself. This is the SDK equivalent of running a type-check in a CI pipeline, except the agent self-corrects in-loop.
UserPromptSubmit: Guard or Steer the User's Input
UserPromptSubmit fires when the user submits a prompt, before Claude starts planning. The input carries the raw prompt:
type UserPromptSubmitHookInput = BaseHookInput & {
hook_event_name: 'UserPromptSubmit'
prompt: string
}
You can block submissions outright with permissionDecision: 'deny', or steer the agent quietly by returning systemMessage (added to the conversation as guidance) or additionalContext (injected information Claude can use).
Worked example: block prompts that ask for production deletes
const blockProdDeletes: HookCallback = async input => {
if (input.hook_event_name !== 'UserPromptSubmit') return {}
const prompt = (input as { prompt: string }).prompt.toLowerCase()
const looksDangerous =
prompt.includes('drop table') ||
prompt.includes('delete from production') ||
prompt.includes('truncate')
if (looksDangerous) {
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
permissionDecision: 'deny',
permissionDecisionReason:
'Destructive database operations require a separate approval flow.',
},
}
}
return {}
}
Worked example: inject relevant context based on the prompt
const injectRoadmapContext: HookCallback = async input => {
if (input.hook_event_name !== 'UserPromptSubmit') return {}
const prompt = (input as { prompt: string }).prompt.toLowerCase()
if (!prompt.includes('roadmap') && !prompt.includes('what should we ship')) {
return {}
}
const roadmap = await loadRoadmapFromDb()
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
additionalContext: `Current Q3 priorities:\n${roadmap}`,
},
}
}
The user does not see the injection. Claude just knows more than the prompt suggests.
Stop and SubagentStop: The Loop-Safe Final Guard
Stop fires when the main agent has finished its turn. SubagentStop fires when a subagent finishes. Both share a critical safety field: stop_hook_active.
type StopHookInput = BaseHookInput & {
hook_event_name: 'Stop'
stop_hook_active: boolean
}
type SubagentStopHookInput = BaseHookInput & {
hook_event_name: 'SubagentStop'
stop_hook_active: boolean
}
When stop_hook_active is true, the Stop hook has already fired once during this turn. Re-blocking from inside the hook can create an infinite loop where the agent tries to stop, your hook blocks, the agent tries again, your hook blocks again. Always early-return when stop_hook_active is true.
The Stop family supports a special output: top-level decision: 'block' with a reason string. This tells the agent it is not allowed to stop yet and must keep working.
Worked example: require coverage report before stopping
const requireCoverageReport: HookCallback = async input => {
if (input.hook_event_name !== 'Stop') return {}
const stopInput = input as { stop_hook_active: boolean }
if (stopInput.stop_hook_active) return {}
const reportExists = await fileExists('coverage/coverage-summary.json')
if (reportExists) return {}
return {
decision: 'block',
reason:
'Coverage report missing at coverage/coverage-summary.json. Run npm run test:coverage before finishing.',
}
}
This hook is safe because of the stop_hook_active early-return. The first stop attempt triggers the block; the second stop (after Claude has generated the report) skips the check and lets the agent finish.
Session, Subagent, and Compaction Hooks
The remaining six events do not gate tool execution but they are essential for observability, telemetry, and lifecycle bookkeeping.
SessionStart and SessionEnd
SessionStart is the natural place to bootstrap telemetry, prime conversation context, or log a run. SessionEnd is where you flush metrics, save transcripts, or post a summary to Slack.
const sessionTelemetry: HookCallback = async input => {
if (input.hook_event_name === 'SessionStart') {
await metrics.increment('agent.session.started')
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
additionalContext: `Session started at ${new Date().toISOString()}.`,
},
}
}
if (input.hook_event_name === 'SessionEnd') {
await metrics.increment('agent.session.ended')
return {}
}
return {}
}
SubagentStart and SubagentStop
SubagentStart fires when the agent invokes a subagent. Use it to pass context the parent agent has but the subagent does not. SubagentStop (covered above) is the subagent counterpart to Stop, including stop_hook_active.
PreCompact
PreCompact fires before the SDK runs message compaction. The hook itself cannot prevent compaction, but it can log which messages are about to be compacted so you can debug context-loss regressions later. This is the cheapest observability hook in the SDK: register it once, log the input, and you have a record every time history collapses.
Notification and PermissionRequest
Notification fires when the agent emits a notification (long-running tool completion, idle-state message, etc.). Wire it to your team's alerting layer. PermissionRequest fires when the agent asks for permission through the SDK's permission flow; use it to build custom approval UIs or write an audit trail.
Anthropic Agent SDK Hooks vs Claude Code settings.json Hooks
Both surfaces share an engine. Both use the same permissionDecision vocabulary. They differ in where the code lives and who runs it.
| Dimension | settings.json hooks (Claude Code) | Agent SDK hooks (TypeScript) |
|---|---|---|
| Where defined | .claude/settings.json declarative config | options.hooks inside query() at runtime |
| Handler types | command (shell), prompt (LLM), agent (subagent) | Async TypeScript HookCallback |
| Type safety | JSON validation | Full TS types: HookCallback, PreToolUseHookInput, etc. |
| Execution model | Spawns a subprocess per hook | Runs in the same Node.js process |
| Latency floor | Shell startup + JSON serialize/deserialize | Microseconds for in-process logic |
| Distribution | Checked into the repo, shared across team | Bundled with your application code |
| Best for | Operational governance, team-wide enforcement | Programmatic agents in production apps |
| Composition | Chain via && in command strings | Standard TypeScript composition |
The two surfaces are complementary, not competing. A real team often uses both: settings.json for team-wide guardrails (block .env edits, format on save) and SDK hooks for application-specific logic inside the product itself. The Claude Code hooks production guide covers the settings.json surface end-to-end; this post is the SDK counterpart.
How Anthropic Agent SDK Hooks Compare to Cursor Hooks
Cursor hooks (introduced in v1.7) target the Cursor editor agent specifically. They are configured in Cursor's settings, run inside the Cursor process, and have their own event names and decision shape.
The two systems do not interoperate at runtime, but the design patterns transfer cleanly:
- Cursor
beforeShellExecution↔ SDKPreToolUsewith aBashmatcher - Cursor
afterFileEdit↔ SDKPostToolUsewithEdit|Writematcher - Cursor
stop↔ SDKStop
If your team uses both Cursor and the Anthropic Agent SDK, keep the policy logic (allowlists, denylists, format rules) in shared TypeScript modules and call them from both environments. This avoids the drift problem where Cursor enforces one set of rules and the SDK enforces another, leaving an inconsistency window.
Hook Ordering, Chaining, and Return-Value Semantics
The hooks array per matcher is ordered. Callbacks execute top-to-bottom and the first one that returns a permissionDecision: 'deny' halts the chain. Subsequent hooks in the same matcher are not invoked for that event.
Within the same event but across different matchers, each matcher group is evaluated independently against the tool_name. Multiple matchers can fire on the same call, and their callbacks run in registration order:
options: {
hooks: {
PreToolUse: [
{ matcher: 'Edit|Write', hooks: [protectEnvFiles] },
{ matcher: 'Bash', hooks: [blockDangerousCommands] },
{ matcher: '.*', hooks: [auditAll] },
]
}
}
If the tool is Edit, protectEnvFiles and auditAll both run. If protectEnvFiles denies, auditAll still runs in this matcher group's tail position (the deny is recorded but does not prevent observability hooks from firing).
The return value contract:
- Returning
{}is the explicit allow signal. - Returning a
hookSpecificOutputwithpermissionDecision: 'deny'blocks the action (PreToolUse, UserPromptSubmit only). - Returning a
hookSpecificOutputwithpermissionDecision: 'allow'andupdatedInputmodifies the tool input. - Returning
decision: 'block'withreasonkeeps the agent running past a Stop or SubagentStop event. - Returning
systemMessageinjects guidance Claude will see on its next turn. - Returning
additionalContextinjects context for PostToolUse, UserPromptSubmit, SessionStart, and SubagentStart events.
Production Patterns: Three Ready-to-Paste Templates
These are the same patterns the Pixelmojo platform uses internally to keep the AI sales agent honest. Adapt and ship.
Pattern 1: Permission audit trail to Supabase
import { supabase } from './supabase'
const auditPermissionRequests: HookCallback = async (input, toolUseID) => {
if (input.hook_event_name !== 'PreToolUse') return {}
const preInput = input as PreToolUseHookInput
await supabase.from('agent_audit_log').insert({
tool_use_id: toolUseID,
tool_name: preInput.tool_name,
tool_input: preInput.tool_input,
session_id: preInput.session_id,
created_at: new Date().toISOString(),
})
return {}
}
Every tool call is logged with the session ID. Now you can answer "what did the agent try to do on Tuesday?" without grepping logs.
Pattern 2: Cost guard with maxBudgetUsd shadow
const costGuard: HookCallback = async input => {
if (input.hook_event_name !== 'PreToolUse') return {}
const spentSoFar = await getSpentForSession((input as any).session_id)
if (spentSoFar > 5.0) {
return {
hookSpecificOutput: {
hookEventName: input.hook_event_name,
permissionDecision: 'deny',
permissionDecisionReason: `Session budget exceeded ($5). Stopping.`,
},
}
}
return {}
}
The SDK already supports maxBudgetUsd in query options, but a hook gives you tighter, per-tool granularity and the ability to log overruns to your own telemetry.
Pattern 3: Slack notification on long-running operations
const notifySlackOnLongOps: HookCallback = async input => {
if (input.hook_event_name !== 'Notification') return {}
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: 'POST',
body: JSON.stringify({
text: `Agent notification: ${JSON.stringify(input)}`,
}),
})
return {}
}
Pair this with a long-running tool like a deep-research subagent and your team gets pinged when manual review is needed.
How Hooks Connect to the Rest of the SDK
Hooks are one of several control surfaces in the Anthropic Agent SDK. They compose with:
canUseToolinquery()options: a permission gate that fires before every tool call, distinct fromPreToolUsebecause it can ask the user interactively rather than autonomously denying. See the official permissions guide.allowedTools/disallowedTools: static allowlists/denylists evaluated before hooks fire. Same permissions guide.maxBudgetUsd/maxTurns: hard ceilings on session resources, independent of hook logic.- Subagents (via
agentsoption): hooks fire inside subagent contexts too, so aPreToolUsehook registered at the top level applies to subagent tool calls. - Sessions and MCP servers: hooks observe and gate work happening across both surfaces.
A complete production setup typically uses disallowedTools for the hardest restrictions, canUseTool for interactive prompts, and SDK hooks for everything that needs custom logic or telemetry. For deeper context engineering patterns alongside hooks, see Context Engineering Beyond CLAUDE.md.
How Do You Debug a Hook That Is Not Firing?
The most common failure mode is the matcher regex not matching the tool name you expected. Tool names are case-sensitive and exact, so 'edit' will never match the Edit tool. When a hook seems silent, instrument it before you debug deeper:
const debugAll: HookCallback = async (input, toolUseID) => {
console.error(
`[hook] event=${input.hook_event_name} toolUseID=${toolUseID}`,
JSON.stringify(input, null, 2)
)
return {}
}
// Register on every event with a permissive matcher
options: {
hooks: {
PreToolUse: [{ matcher: '.*', hooks: [debugAll] }],
PostToolUse: [{ matcher: '.*', hooks: [debugAll] }],
UserPromptSubmit: [{ hooks: [debugAll] }],
Stop: [{ hooks: [debugAll] }],
}
}
Run the query and watch stderr. You will see every event the SDK fires, exactly what tool_name Claude is passing, and the full tool_input payload. From there, narrow the matcher.
Three debugging gotchas worth knowing:
- Matchers are regex, not glob.
'Edit|Write'matches both tools because it is a regex alternation.'Edit,Write'matches neither because the comma is a literal character. hookEventNamemismatches silently fail. If you returnhookSpecificOutput.hookEventName: 'PreToolUse'from aPostToolUsecallback, the decision is ignored. Always echoinput.hook_event_name.return {}versusreturnwith no value. TypeScript requires the explicit{}return for the allow signal. A barereturnreturnsundefinedwhich the SDK does not treat as a valid HookJSONOutput and may surface as a runtime error in strict mode.
Unit-testing hooks is straightforward because they are plain async functions. Pass a constructed input object, await the return, assert on the shape. No SDK harness required:
import { describe, it, expect } from 'vitest'
describe('protectEnvFiles', () => {
it('denies .env edits', async () => {
const result = await protectEnvFiles(
{
hook_event_name: 'PreToolUse',
tool_name: 'Edit',
tool_input: { file_path: 'apps/api/.env' },
session_id: 'test',
} as any,
'test-tool-use-id',
{ signal: new AbortController().signal }
)
expect(result.hookSpecificOutput?.permissionDecision).toBe('deny')
})
})
Treat hooks like any other piece of your codebase: type-check, test, and review. They are policy code that runs against a Claude agent, which means a bad hook can either allow what it should block or block what it should allow. Both failure modes are silent unless you are actively logging.
Anthropic Agent SDK Hooks: Questions Developers Ask
Common questions about this topic, answered.
Where to Go Next
Official references (always check these for the latest API surface):
- Agent SDK overview: the canonical entry point
- Hooks guide: official reference for every hook event
- TypeScript SDK reference: full typed API surface
- GitHub repo: source of truth for type definitions and the
CHANGELOG.md
Pixelmojo posts that pair with this one:
If you are evaluating whether to use SDK hooks or settings.json hooks (or both), read the Claude Code hooks production guide for the declarative side and the patterns we already deploy in our own toolchain. The two posts together give you the complete hook surface.
If you are designing the AI governance layer around your hooks, the Claude Code Technical Debt Mitigation Guide covers the framework hooks plug into: thread-based engineering checkpoints, quality gates, and review thresholds.
Ready to ship AI products with hooks done right?
- AI Product Development - Production-grade Claude agents with hook-enforced governance
- Pixelmojo Radar - Audit your AI visibility surface, then automate fixes with the SDK
- Contact Us - Wire hooks into your agent stack
