scalable/ai/claude agent sdk/lesson 06 cph / /
lesson 06 / 08 · 11 min · updated ·

hooks — pre/post tool, stop, ux

lifecycle hooks for lint-on-write, telemetry, custom statuslines. the harness extension points you actually use.

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

matchers narrow the firing. every hook entry has an optional 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."

PostToolUse — lint after every Edit/Write to a ts file
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 — block git push, return a clear reason
// 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.

Stop — refuse to end without a test run
// 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:

what to notice

scalable labs·cvr 30091604·github·linkedin·hello@scalable.dk