tl;dr. hooks are lifecycle callbacks: PreToolUse,
PostToolUse, UserPromptSubmit, Stop,
SubagentStop, SessionStart, SessionEnd,
Notification, PreCompact. each fires at a known
point in the loop, can read the transcript, and can either pass through,
inject context, or block. lint-on-write, policy gates, telemetry, custom
statuslines — all hooks.
where hooks fire
- PreToolUsejust before a tool runs. block here to enforce policy; mutate
tool_inputto rewrite the call. - PostToolUsejust after a tool runs, before the result reaches the model. inject extra context, run validators, lint files the agent just wrote.
- UserPromptSubmitwhen a user message hits the loop. preprocess input — strip secrets, prepend repo context, normalise.
- Stop · SubagentStopwhen the agent (or sub-agent) decides it's done. block here to force another turn — useful for "did you run the tests" gates.
- SessionStart · SessionEndbookends. session start is where you'd warm caches; session end is where you'd flush metrics or write a summary file.
- Notification · PreCompactnotification fires for ux events (waiting, idle); precompact fires when the sdk is about to summarise older turns to save context.
matcher string. for
PreToolUse / PostToolUse it's a regex
against the tool name ("Edit|Write"); for the others
it's omitted. one hook with a tight matcher beats five hooks with no
matcher, because the dispatcher short-circuits.
lint-on-write
the cleanest PostToolUse example: every time the agent
writes a typescript file, run eslint --fix on it. if
eslint complains about anything it can't auto-fix, the hook
surfaces the message back to the model via
additionalContext, which lets the next turn see
"you wrote this, lint says X, please address."
import { query } from "@anthropic-ai/claude-agent-sdk";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const exec = promisify(execFile);
const run = query({
prompt: "fix the lint errors in src/api.ts",
options: {
allowedTools: ["Read", "Edit", "Bash"],
hooks: {
PostToolUse: [
{
// only fire on Edit / Write to typescript files
matcher: "Edit|Write",
hooks: [
async (input) => {
const path = input.tool_input.file_path;
if (!/\.(ts|tsx)$/.test(path)) return { continue: true };
try {
await exec("npx", ["eslint", "--fix", path]);
return { continue: true };
} catch (e) {
return {
continue: true,
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: `eslint --fix exited non-zero on ${path}: ${e}`,
},
};
}
},
],
},
],
},
},
}); policy gates
PreToolUse can refuse a call before it runs. that's
different from a permission deny — a permission deny tells the
model "you can't do this"; a hook block stops the call without the
model ever seeing it. use this for hard never-allowed actions, where
you don't want the model to even reason about a workaround.
// PreToolUse can BLOCK the call by returning continue: false.
// useful for hard policy gates that shouldn't even reach the model's view of the result.
hooks: {
PreToolUse: [
{
matcher: "Bash",
hooks: [
async (input) => {
const cmd = String(input.tool_input.command ?? "");
if (/\bgit\s+push\b/.test(cmd)) {
return {
continue: false,
decision: "block",
reason: "git push is not allowed from this agent. open a PR instead.",
};
}
return { continue: true };
},
],
},
],
}, stop hooks — the "are you sure" gate
a Stop hook can block the loop's termination. that
lets you require post-conditions: tests ran, lint passed, no
uncommitted changes. block by returning continue: false
with a reason; the agent gets the reason as a system
message and takes another turn.
// the Stop hook fires when the agent decides it's done.
// useful for "are you sure?" gates — return decision: "block" to force another turn.
hooks: {
Stop: [
{
hooks: [
async (input) => {
// refuse to stop until at least one test was run
const ranTests = input.transcript.some(
(e) => e.type === "assistant" &&
e.message.content.some((b) =>
b.type === "tool_use" && b.name === "Bash" &&
String(b.input.command).includes("test"),
),
);
if (!ranTests) {
return {
continue: false,
decision: "block",
reason: "you must run the test suite before stopping.",
};
}
return { continue: true };
},
],
},
],
}, hooks vs. permissions vs. tools
these three primitives overlap and beginners blur them. the working distinction:
- toolswhat the agent can do. the menu.
- permissionswhether a specific call is allowed. the bouncer at the door.
- hooksarbitrary code that runs around lifecycle events. the wiretap, the auditor, the side-effect engine.
what to notice
- hooks are async, but treat them as fast.a slow hook is latency on every tool call. heavy work belongs in a sub-agent or a background job, not a hook.
- return shapes matter.
{ continue: true }is a no-op pass-through.{ continue: false, decision: "block", reason }is a hard stop.hookSpecificOutput.additionalContextinjects text into the next model turn. - match precisely.a missing matcher means the hook fires for every tool call, every turn. easy to forget; expensive to leave in.
- hooks compose with shell hooks.claude code's
.claude/settings.jsonhooks (shell command invocations) and sdk hooks (in-process callbacks) both fire — sdk hooks for app logic, shell hooks for repo-wide policy.