← All posts

Guide

Hooks in the Claude Agent SDK

Hooks in the Claude Agent SDK

A hook in the Claude Agent SDK is an async function you pass in options.hooks, keyed by event name, that the SDK awaits when a lifecycle event fires. It receives a typed object describing the event; what it returns can allow, deny or rewrite a tool call, inject context into the model's next turn, or stop the run. There are 31 events in HOOK_EVENTS. In practice a normal single-prompt run fires seven of them.

This is the part of the SDK that is documented as a list and almost never as a behaviour. So we installed it, registered a callback on every one of the 31 events, and ran it against a toy repository until we had the thing nobody publishes: the measured order events actually arrive in, including what happens inside a subagent, what the permission flow looks like from the hook's side, and which events never showed up at all. Everything below was run against @anthropic-ai/claude-agent-sdk 0.3.231 (npm latest and next) on Node 22.22.2, on August 11, 2026. Where we could not trigger something, it says so.

If you have not written an SDK agent before, start with building an agent on the Claude Agent SDK — it covers query(), the message loop and the permission chain. This post assumes all of that and only talks about hooks.

First: two different things are called "hooks"

This is the confusion worth resolving before anything else, because the two mechanisms share event names, share an output schema, and are otherwise nothing alike.

settings.json hookSDK hook
Where it lives~/.claude/settings.json, .claude/settings.json, a pluginoptions.hooks in your program
What it isA shell command (or http, mcp_tool, prompt, agent)An async function in your process
How it receives the eventJSON on stdinA typed object as the first argument
How it blocksExit code 2, or JSON on stdout with exit 0The returned object. There is no exit code
Who installs itWhoever owns the repo or the machineYou, the author of the harness
Survives a restartYes — it is config on diskNo — it lives as long as your process

The trap is that they are not alternatives. They both run, in the same session, for the same event. We put a PreToolUse command hook in the toy repo's .claude/settings.json that appended a line to a log file, registered SDK PreToolUse callbacks for the same run, and got three SDK callback firings and three lines in the log — one pair per tool call. The docs are explicit about why: settings files are loaded "for default query() options."

The switch is settingSources. Re-running the identical program with settingSources: [] produced the same SDK callbacks and an empty log file. So:

By default your SDK agent inherits the hooks of whatever repository it is pointed at. A cloned repo's .claude/settings.json is executable configuration, and query() loads it unless you say otherwise. If your harness runs untrusted repositories, settingSources: [] is the line you are looking for. Note the cost: it also stops CLAUDE.md loading, which needs 'project'.

Everything you know about the shell flavour — the 30-plus events, the matcher grammar, exit 2 versus exit 1 — still applies on that side, and we took it apart in Claude Code hooks explained, with working configs in five copy-paste examples. From here on, "hook" means the SDK kind.

The callback signature, exactly

Three arguments, and a promise of an object. This is the whole contract:

type HookCallback = (
  input: HookInput,
  toolUseID: string | undefined,
  options: { signal: AbortSignal },
) => Promise<HookJSONOutput>

You register them per event, in an array of matchers, each holding an array of callbacks:

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

const q = query({
  prompt: 'Read src/a.js and summarise it.',
  options: {
    hooks: {
      PreToolUse: [
        {
          matcher: 'Write|Edit',
          timeout: 30,
          hooks: [
            async (input, toolUseID, { signal }) => {
              console.log(input.hook_event_name, input.tool_name, toolUseID)
              return {}
            },
          ],
        },
      ],
    },
  },
})

The three arguments, in order of how often you will care:

One field detail worth having before you write a path check. The tool_input a hook sees is the resolved input, not what the model emitted:

model emitted:   {"file_path": "src/a.js"}
hook received:   {"file_path": "/private/tmp/…/toyrepo/src/a.js"}

That is convenient — you can compare against an absolute allowlist without resolving anything yourself — and it is also the reason a naive startsWith('src/') guard silently matches nothing.

All 31 events

HOOK_EVENTS in sdk.d.ts is the authoritative list. The Python SDK exposes a subset; the TypeScript SDK exposes all of them.

EventMatcher matches onPython too?Fires when
SessionStartsource: startup, resume, clear, compact, forkNoSession initialisation
Setuptrigger: init, maintenanceNoSession setup / maintenance
UserPromptSubmitYesA user prompt is submitted
UserPromptExpansionsee referenceNoA typed command or MCP prompt expands
PreToolUsetool nameYesBefore a tool call; can block or rewrite
PermissionRequesttool nameYesA tool call needs a permission decision
PermissionDeniedtool nameNoAuto mode denies a call
PostToolUsetool nameYesA tool returned a result
PostToolUseFailuretool nameYesA tool failed instead of PostToolUse
PostToolBatchNoOnce per batch, before the next model call
MessageDisplayNoAn assistant text message completes
Notificationnotification typeYesAgent status message
SubagentStartagent typeYesA subagent starts
SubagentStopagent typeYesA subagent finishes
StopYesThe turn ends normally
StopFailureerror typeNoThe turn ends with an API error
PreCompactmanual | autoYesBefore compaction
PostCompactmanual | autoNoAfter compaction
TaskCreatedNoA task is created via TaskCreate
TaskCompletedNoA background task completes
TeammateIdleNoA teammate goes idle
Elicitationsee referenceNoAn MCP server asks for input
ElicitationResultsee referenceNoA user answers an elicitation
ConfigChangeconfig sourceNoA settings file changes
InstructionsLoadedload reasonNoA CLAUDE.md / rules file loads
CwdChangedNoThe working directory changes
FileChangedexact-match set onlyNoA watched file changes
DirectoryAddedNoA working directory is added mid-session
WorktreeCreateNoA git worktree is created
WorktreeRemoveNoA git worktree is removed
SessionEndexit reasonNoSession termination

The measured firing order

Here is the run. One prompt, a two-file toy repository, permissionMode: 'dontAsk', Bash in disallowedTools, and one callback registered on all 31 events that logs the elapsed milliseconds. Nothing is elided; the only lines added are the tool_use blocks from the message stream and the terminal result, for orientation.

  4903ms  UserPromptSubmit
  4907ms  --- system init  model=claude-opus-5
  7047ms  MessageDisplay
  7674ms  >>> tool_use Read
  7678ms  PreToolUse [Read]
  7696ms  PostToolUseFailure [Read]
  8236ms  >>> tool_use Read
  8236ms  PreToolUse [Read]
  8240ms  PostToolUseFailure [Read]
  8417ms  PostToolBatch
 10335ms  MessageDisplay
 11797ms  >>> tool_use Read
 11799ms  PreToolUse [Read]
 11812ms  PostToolUse [Read]
 12269ms  >>> tool_use Read
 12270ms  PreToolUse [Read]
 12277ms  PostToolUse [Read]
 12282ms  PostToolBatch
 15064ms  >>> tool_use Write
 15065ms  PreToolUse [Write]
 15092ms  PostToolUse [Write]
 15093ms  PostToolBatch
 18558ms  MessageDisplay
 18576ms  Stop
 18580ms  --- result success turns=6 usd=0.3516

Seven distinct events fired. Read the shape off it:

UserPromptSubmit MessageDisplay Stop one tool batch, repeated per turn PreToolUse can deny / rewrite PostToolUse or PostToolUseFailure PostToolBatch — once Failure replaces success. It is never both.
Measured on SDK 0.3.231. Seven of 31 events fire in an ordinary single-prompt run.

What a subagent looks like from outside

Second run, same setup, but the prompt asks for one general-purpose subagent. This is the trace people guess at:

  6946ms  >>> tool_use Agent
  6950ms  PreToolUse [Agent]
  6956ms  SubagentStart [general-purpose]
 11923ms  PreToolUse [Read]        <-- inside the subagent
 11938ms  PostToolUse [Read]
 11943ms  PostToolBatch            <-- inside the subagent
 16503ms  SubagentStop [general-purpose]
 16510ms  PostToolUse [Agent]
 16533ms  PostToolBatch
 22543ms  Stop

Two things fall out of that. First, the nesting is strict and complete: PreToolUse [Agent] wraps SubagentStart, which wraps the subagent's own tool events, which are followed by SubagentStop and only then PostToolUse [Agent]. Second, and more usefully: your PreToolUse hook fires for tools called inside subagents too. A guard registered once on the main query covers the whole tree. The way to tell them apart is input.agent_id, which is populated only inside a subagent — not agent_type, which is also set on the main thread of an --agent session.

PostToolBatch fires at both levels. If you use it for per-turn bookkeeping in a run with subagents, count on more of them than you have main-thread turns.

What did not fire, and why

Across five runs with all 31 events registered, 24 never fired. That is not a bug list; most of them describe situations a short headless run does not contain. But two of them are genuinely surprising, and worth more than a shrug.

SessionStart and SessionEnd callbacks never fired in any of our runs — while SessionStart and SessionEnd command hooks in the same session's .claude/settings.json did, writing shell SessionStart / shell Stop / shell SessionEnd to disk in that order. So the events fire; the SDK callback does not see them. The plausible reading is a registration race at both ends: callbacks are wired up over the control channel after the session is already up, and the transport is torn down before the end-of-session event is delivered.

The practical consequence is small but sharp: do not put session setup or resource cleanup in a hook. Setup goes before query(), teardown goes after the for await loop, in a finally. If you need on-disk session hooks to fire, that is the settings.json path — which, remember, is loaded by default.

The rest, honestly categorised:

The permission flow, from the hook's seat

Third run: permissionMode: 'default', a canUseTool callback that denies everything, and a prompt that wants to write a file. The order is not the one the field names suggest:

  4137ms  >>> tool_use Write
  4140ms  PreToolUse [Write]
  4151ms  canUseTool(Write) -> DENY
  4152ms  PermissionRequest [Write]
  4199ms  PostToolBatch

PreToolUse runs first, and only if it has not already decided does the call fall through to the permission flow. PermissionRequest is that fall-through — it is not fired for tools that PreToolUse, an allow rule or the permission mode already settled, which is the same short-circuit that makes canUseTool go quiet under bypassPermissions. And after a denial there is no PostToolUse, no PostToolUseFailure and no PermissionDenied: the batch simply closes.

Which leaves a real gap worth naming. There is no single event that means "this tool call did not happen." If you are building an audit trail, the honest reconstruction is PreToolUse minus PostToolUse minus PostToolUseFailure, or the tool_calls[] array on PostToolBatch, which carries an optional tool_response per call.

What a hook can return

Return {} to observe and change nothing. Everything else lives in two places: a handful of top-level fields accepted on every event, and one hookSpecificOutput object whose shape depends on the event.

ReturnEffectWhere
{}Proceed unchangedAny event
continue: false + stopReasonEnds the run. Outranks every event-specific decisionAny event
systemMessageShows the user a messageAny event
suppressOutputHides the hook's output from the transcriptAny event
{ async: true, asyncTimeout }The agent proceeds without waiting. Cannot block, modify or injectAny event
permissionDecision: 'allow' | 'deny' | 'ask' | 'defer'Decides the tool call. defer ends the query for later resumptionPreToolUse
updatedInputReplaces the entire tool input objectPreToolUse
updatedToolOutputReplaces the tool result before the model sees itPostToolUse
additionalContextAppends text the model will readUserPromptSubmit, PostToolUse, PostToolBatch, Stop, Notification, others
initialUserMessage, sessionTitle, reloadSkills, watchPathsSession shapingSessionStart — which, see above, we could not get to fire
decision: { behavior: 'allow' | 'deny' }The permission verdictPermissionRequest
retry: trueTells the model it may retryPermissionDenied

The three that we verified do what they say, and the one that does not:

deny reaches the model, in its own words

A PreToolUse hook on Write returning:

return {
  hookSpecificOutput: {
    hookEventName: 'PreToolUse',
    permissionDecision: 'deny',
    permissionDecisionReason:
      'Writing to disk is not permitted in this run. Print the content in your reply instead.',
  },
}

…produced this, from the model, unprompted: "I read src/a.js and found one TODO, but I couldn't write the file — this run doesn't permit writing to disk, so here's the content instead." The reason is not decoration; it is the model's only explanation of what happened, and a bad one ("denied by policy") produces an agent that retries the same call three times. When several hooks disagree the precedence is deny > defer > ask > allow: one deny is enough.

additionalContext genuinely steers the run

A UserPromptSubmit hook returning additionalContext: 'HOUSE RULE: the file you write must be named NOTES.md, never TODO.md.' against a prompt that explicitly asked for TODO.md produced a Write to NOTES.md. This is the cheapest injection point in the SDK — no system-prompt surgery, no per-call plumbing — and it is a serviceable place to put house conventions that would otherwise live in a CLAUDE.md your harness cannot rely on being loaded.

continue: false stops the loop, and does almost nothing else

This one is a trap. A PreToolUse hook returned { continue: false, stopReason: "harness pulled the plug" } for a specific file:

  4790ms  >>> tool_use Read {"file_path":"src/b.js"}
  4791ms  PreToolUse [Read b.js] -> continue:false
  4795ms  PostToolUse [Read]
  4848ms  --- result success turns=3 is_error=false result=""

The tool still ran — PostToolUse fired for that same call. The Stop hook did not fire. And the terminal message came back subtype: 'success', is_error: false, with an empty result string. A caller checking subtype sees a healthy run that produced nothing. If you use continue: false, record the reason yourself, and treat an empty result on a successful run as the signal it is.

Matchers, and what happens when several match

SDK matchers follow the settings-file grammar exactly: "*", "" or omitted matches everything; a string of letters, digits, _, -, spaces, , and | is exact-match or a |/, separated list; anything else is an unanchored JavaScript regex. MCP tools appear as mcp__<server>__<tool>, so mcp__memory alone matches nothing and you want mcp__memory__.*.

We registered four PreToolUse matcher entries at once — 'Read', 'Rea.*', no matcher, and 'Write' — with the first one sleeping 300 ms:

  2925ms  A: matcher "Read" #1   (sleeps 300ms)
  2926ms  B: matcher "Read" #2
  2926ms  C: matcher "Rea.*"
  2926ms  D: no matcher
  3228ms  A: matcher "Read" #1 wakes
  3239ms  PostToolUse [Read]

Three things are measured there at once. Exact, regex and omitted matchers all fire for the same call — matching is not first-match-wins, there is no specificity ordering, and a catch-all logger will double up with your targeted rule. All four entered within 1 ms of each other, so they run in parallel, and A's sleep did not delay B, C or D. And the tool itself waited for the slowest of them: PostToolUse landed 11 ms after A woke.

The documentation is blunt about the consequence and it is worth repeating: completion order is non-deterministic, so never write one hook that depends on another having run. The same rule that bites in settings.json — two hooks both returning updatedInput and the last writer winning — bites identically here.

Async behaviour: what blocks the loop

By default, everything. We made a PreToolUse hook sleep 3,000 ms and watched the agent sit there:

  3544ms  >>> tool_use Read
  3545ms  PreToolUse [Read] -> sleeping 3000ms
  6551ms  PreToolUse [Read] -> awake, returning allow
  6571ms  PostToolUse [Read]

Three full seconds of agent wall-clock, paid on every matching tool call. That is the price of a hook that does network I/O, and it is why the third argument carries an AbortSignal: hand it to your fetch so a slow endpoint is cancelled at the matcher's timeout rather than at TCP's leisure.

The escape hatch is { async: true, asyncTimeout: 30000 }. The agent proceeds immediately and your promise finishes in the background. The trade is total: an async hook cannot block, cannot rewrite input and cannot inject context, because the decision point is already behind it. Logging, metrics, Slack pings — yes. Policy — never. A hook that returns async: true and a permissionDecision is a policy you think you have.

timeout is set per matcher entry, in seconds, and applies to every callback in that entry. Omitted, your callback inherits the event's command-hook default — 600 s for most events, but 30 s for UserPromptSubmit and 10 s for MessageDisplay. Note the unit mismatch that is easy to fly past: timeout is seconds, asyncTimeout is milliseconds.

Error handling: what happens when your hook throws

We threw a plain Error from a PreToolUse hook matched on Write, in the middle of a run that was about to write a file:

 10219ms  D: no matcher
 10219ms  E: matcher "Write" -- THROWS
 10234ms  PostToolUse [Write]
 13326ms  --- result success turns=4 is_error=false

The exception was swallowed. The Write went ahead, PostToolUse fired 15 ms later, and the run finished clean. Nothing in the message stream said a hook had failed.

Do not build on that. The documentation says the opposite — "an unhandled exception can interrupt the agent" — and the two statements are reconcilable (it can, on some paths, in some versions) without either being safe to rely on. What you can rely on is the failure mode you actually care about: a policy hook that throws is a policy that is not enforced, and by default nobody tells you. The discipline is one try/catch per hook, with an explicit decision in the catch:

const guard: HookCallback = async (input) => {
  try {
    if (await isDangerous(input)) {
      return {
        hookSpecificOutput: {
          hookEventName: 'PreToolUse',
          permissionDecision: 'deny',
          permissionDecisionReason: 'Blocked by policy: writes outside the workspace.',
        },
      }
    }
    return {}
  } catch (err) {
    if (err instanceof Error && err.name === 'AbortError') return {}
    metrics.increment('hook.guard.error')
    return {
      hookSpecificOutput: {
        hookEventName: 'PreToolUse',
        permissionDecision: 'deny',
        permissionDecisionReason: 'Policy check failed to run; denying by default.',
      },
    }
  }
}

Fail closed if the hook is a guardrail, fail open if it is a logger — but decide, in code, rather than inheriting whatever the current version happens to do with an exception.

The short version

  • SDK hooks are functions returning objects. settings.json hooks are processes returning exit codes. Both fire, in the same run, unless you set settingSources: [].
  • 31 events exist; 7 fire in an ordinary run: UserPromptSubmit, MessageDisplay, PreToolUse, PostToolUse / PostToolUseFailure, PostToolBatch, Stop.
  • PostToolUseFailure replaces PostToolUse. Subscribe to both or your audit log lies.
  • SessionStart and SessionEnd callbacks did not fire in our testing. Do setup and teardown around query().
  • Your PreToolUse hook covers subagents too. input.agent_id tells you which.
  • All matching hooks run in parallel, and the tool waits for the slowest. Nothing is ordered.
  • deny + a good permissionDecisionReason is the highest-leverage thing a hook can return; the model reads it.
  • continue: false ends the loop and still reports success with an empty result.
  • A throwing hook was swallowed in our run. Catch and decide explicitly anyway.

Frequently asked questions

What is a hook in the Claude Agent SDK?

An async function you pass in options.hooks, keyed by event name, that the SDK calls when a lifecycle event fires. Its signature is (input, toolUseID, { signal }) => Promise<HookJSONOutput>. It receives a typed object describing the event, and what it returns can allow, deny or rewrite a tool call, inject context, or stop the run. There is no stdin, no exit code and no subprocess — that is the settings.json flavour of hooks, which is a different mechanism with the same name.

How are SDK hooks different from settings.json hooks?

Same event names, same JSON output schema, different delivery. A settings.json hook is a shell command Claude Code spawns with the payload on stdin, and it signals a block with exit code 2. An SDK hook is a function in your process that returns an object; there is no exit code. They also coexist: in our run both fired for the same tool call, because query() loads user, project and local settings by default. Pass settingSources: [] to run SDK hooks alone.

Do SessionStart and SessionEnd hooks fire in the Agent SDK?

Not as callbacks, in our testing. We registered a callback for all 31 events across five runs on SDK 0.3.231 and SessionStart and SessionEnd never fired, while shell hooks for the same two events in .claude/settings.json did fire in the same run. The events are real; the SDK callback for them is registered too late to see the start and the transport is gone by the end. Do session setup before query() and teardown after the loop.

Does an SDK hook block the agent loop?

Yes, unless you opt out. We made a PreToolUse hook sleep 3,000 ms; the hook returned 3,006 ms after it was entered and PostToolUse followed 20 ms after that. The agent waits for every matching hook to resolve before proceeding. Return { async: true } for pure side effects such as logging or webhooks, and the agent continues immediately — at the cost that an async hook cannot block, modify or inject anything.

What happens if my hook throws?

In our run, nothing dramatic: a PreToolUse hook that threw a plain Error was swallowed, the Write it was attached to still executed, PostToolUse still fired and the run finished with subtype success and is_error false. The documentation warns that an unhandled exception can interrupt the agent, so do not rely on the swallow. Catch inside the hook and decide explicitly, because a hook that throws is a policy that silently is not enforced.

How do I stop a run from inside a hook?

Return { continue: false, stopReason: "..." }. Be aware of what it does not do: in our run the tool call that the hook was inspecting still executed and still fired PostToolUse, and the final result message came back with subtype success, is_error false and an empty result string. It ends the loop, it does not veto the tool and it does not look like an error. To veto a tool call, return permissionDecision: 'deny' instead.

Sources

All measurements are ours, run on @anthropic-ai/claude-agent-sdk 0.3.231 (npm latest and next) on Node 22.22.2, on August 11, 2026, against a two-file toy repository — five runs, six turns or fewer each, on Claude Opus 5. They illustrate the mechanism; timings depend on the model, the repository and the network, and event coverage depends on which features a run exercises. Type definitions (HOOK_EVENTS, HookCallback, HookCallbackMatcher, BaseHookInput, SyncHookJSONOutput, AsyncHookJSONOutput and every per-event input and output type) are quoted from the published package's sdk.d.ts. Event descriptions, the Python/TypeScript availability split, matcher rules, the parallel-execution and precedence notes and the exception warning: Agent SDK — hooks. settingSources semantics and includeHookEvents: Agent SDK TypeScript reference. Shell-hook exit codes, per-event timeouts and the matcher grammar: Claude Code hooks reference. One negative result worth flagging: includeHookEvents: true produced no hook_started / hook_progress / hook_response system messages for SDK callback hooks in our run — those lifecycle messages appear to describe command hooks, which have stdout and stderr to report.

Where Backgrind fits

Everything above is the machinery for a decision that has no human attached to it. The other half of the problem is the run where there is one — where deny is the wrong answer and "ask the person who owns this repo" is the right one, except they are in another window. Backgrind is the overlay for that case: it PTY-wraps the Claude Code CLI you already run and turns a held PreToolUse decision into an ambient toast over whatever is on screen, answerable with one keystroke. Different layer, same event. If you are building the harness, build the reachable human in too — see the demo for what that looks like.