10. Hook

Hooks Are the Real Hooks: Injection, Backstops, and Audit Trails

Last post, covering permissions, I deliberately skipped over the gate sitting at the very front of the "six-step evaluation chain" — Hooks — and still owed the detail on "how does the path allowlist actually work." This post pays that debt. But a hook's capability goes far beyond "the first gate in the permission chain" — the deeper you dig, the more it turns out to be the all-purpose hook for injecting your own logic into how an agent runs.

👆 You are here
👆 You are here

🪧 Last Post's Unopened First Gate Turns Out to Be an All-Purpose Hook

Define a hook simply as "the gate sitting at the front of the permission chain," and you're underselling it badly. Having actually built with them, my takeaway is: a hook is the single most flexible entry point in the entire harness for reaching in and directly intervening in how the agent runs. It does three fundamentally different jobs for me: injection, backstops, and audit trails. This post walks through all three.

🧭 The Core Idea: A Hook Is a Row of "Slots" Along the Agent's Lifecycle

An agent's run passes through a series of key moments: "about to call a tool," "just finished calling a tool," "wants to end this turn," "context is about to get compacted," "a session just started."

A hook is exactly one of these slots, reserved in advance at each key moment. Plug a piece of your own code into a slot, and the moment that moment arrives, it fires automatically, runs, and can intervene right there in what happens next. The Claude Code Harness Book files this mechanism under "Interrupts." A concrete image: a hook is like a sensor-plus-robotic-arm bolted onto a few critical stations along the agent's automated assembly line — the moment a part arrives, it reaches in and does something automatically.

What can this row of slots actually do? The official docs group hook use cases into five categories: intercepting dangerous operations, auditing tool calls, transforming input/output, requiring human approval, and tracking session lifecycle. That five-category breakdown is genuinely useful — as you'll see, the three things SmartWriter actually uses (injection, backstops, audit trails) are the practical subset picked out of those five that matters for a writing context: "transforming input/output" becomes injection, "tracking lifecycle" becomes backstops, "auditing" becomes audit trails.

🔩 Claude Code: A Hook Is Just a Set of "Fire on Cue" Rules Written into Config

In Claude Code, a hook isn't some deep concept — it's just a set of rules written into a config file (.claude/settings.json). Each rule spells out three things: which lifecycle event it fires on, which tool or scenario it matches, and what it does when triggered. The fourth post in the Learn Claude Code source-walkthrough series takes Claude Code's hook mechanism apart in detail, worth a deeper look if you're curious.

The "which tool or scenario it matches" part is the key to fine-grained control: a hook can be scoped to fire only on Bash or only on Write, while every other tool runs unaffected — no need to route every single tool's execution through a review just to guard against one.

The common event slots mostly name themselves. Walking through them in the order they'd fire across one session gives you the whole picture at a glance: SessionStart (a session just began), UserPromptSubmit (the user just submitted something), PreToolUse (before a tool runs), PostToolUse (after a tool runs), Notification (when the user needs to be alerted — waiting on a permission grant, say), Stop (the model wants to end this turn), SubagentStop (a sub-agent finished its work), PreCompact (before context gets compacted), SessionEnd (a session ends).

This row of slots covers just about every moment in an agent's life that's worth reaching into. (One gotcha worth flagging specifically: the events above are what Claude Code fully supports, but the Python SDK currently only covers a subset of themSessionStart, SessionEnd, PostCompact are supported in the TypeScript SDK, but absent from the Python SDK's hook-callback list, forcing a fallback to shell-command hooks written directly into the config file. This is a real capability gap between the two SDKs, and I'll get into the trouble it caused shortly.)

One more distinction worth naming, because it shapes how hooks actually get written: in Claude Code's CLI, a hook firing usually means running a shell command; in the SDK, it more naturally becomes a callback function. The moment an event fires, the SDK packages up the current context (which tool, what arguments) into a JSON payload and hands it to your function; your function looks at it, does its thing, and returns a decision (allow, block, or rewrite). So in the SDK's world, "writing a hook" really means writing a function that "gets woken up automatically at a specific moment, receives the full context, and gets to make the call, right there."

Claude Code's native layer covers "how to configure it"; the SDK layer, coming up, covers "how to write it." Let's go through the handful of slots most useful for writing, with my own logic plugged in.

🛠 The SDK: My Most-Used Slots

Out of that whole row of slots, three saw the most use in this project, and they explain the mechanism well:

  1. PreToolUse (before a tool runs): fires right before a tool actually executes, letting me rewrite the tool's arguments, or block the call outright.
  2. Stop (when the model wants to end): the model thinks this turn is done and wants to stop — this slot fires, and I can even prevent it from stopping, forcing it to finish something first.
  3. PreCompact (before compaction): the only compaction-related slot in the Python SDK. It fires right before compaction happens, giving me a chance to make sure CLAUDE.md on disk is complete. Once compaction finishes, the CLI re-reads CLAUDE.md from disk, and anything that had been summarized away comes back. (You might ask: what about PostCompact, firing after compaction? The TypeScript SDK has that slot; the Python SDK currently doesn't — so the only option is to defend before compaction, not after.)

Concretely, in the Python SDK, registering a hook relies on HookMatcher: tell the SDK which event to hang it on (PreToolUse, say), which specific tool to match (echoing "which tool or scenario" from Claude Code above — scoped to Bash only, for instance), and the actual callback function. That callback has to be async def, receives the tool name and arguments and the rest of the context, and returns a dict once it's done processing; the field in that dict that actually decides "allow or block" is called permissionDecision, and takes values like allow, deny, ask. Don't want to intervene? Return an empty dict, and the SDK treats that as "no opinion," and lets it proceed as normal.

The PreCompact hook fires before context compaction; after compaction, the CLI re-reads CLAUDE.md from disk, bringing back any standing content that had been summarized away. A custom tool's return value (tool_result), on the other hand, never automatically re-fires — once compaction takes it, it's gone. In other words, wanting something to "automatically come back after compaction" means entrusting its injection to the PreCompact hook plus CLAUDE.md's disk-re-read mechanism — not to a plain tool's return value. This mechanism is the exact foundation the profile backstop plan (below) stands on.

🧰 What SmartWriter Does: Hooks Handle Three Jobs

Back to those five official use cases from the concept section — SmartWriter picked three out of them and leans on them constantly. "Transforming input/output" became injection, "tracking lifecycle" became backstops, "auditing" became audit trails. Let's go through each.

Injection: reaching in right before a tool runs

First job: a path allowlist.

I wrote a guard hanging off PreToolUse (call it path_guard). Every time the model wants to read, write, or edit a file, this guard fires before it actually happens, and checks the target path:

# PreToolUse hook: called right before a tool actually executes
def path_guard(tool_name, tool_input):
    path = tool_input.get("file_path", "")
    if is_sensitive(path):        # matches .env / .ssh / a private key, etc.
        return deny("this file can't be touched")
    if out_of_workspace(path):    # outside the working directory
        return deny("out of workspace bounds")
    return allow(tool_input)      # approve (arguments could also be rewritten here before approving)

Path validation — this kind of "catch it before it runs" job — fits PreToolUse naturally. There's another guard of the same kind that automatically fills in a missing argument for a specific tool call (setting it to run in the background). What both of these have in common: they act before approval — tweaking arguments, or blocking outright. Worth remembering as "injection-type logic = acting before approval happens."

Backstops: welding down the things that must happen, no exceptions

Second job: watching specifically over the handful of steps that can't afford to be skipped, even once.

The clearest example: the profile. Post 4 covered how the user's writing profile gets assembled by profile_reader and injected as a block written into CLAUDE.md. But what if a long writing task triggers compaction midway, and that profile block gets summarized away? The model would then "forget" your style for the rest of the session.

This is exactly where that key fact from earlier pays off: PreCompact fires before compaction; after compaction, the CLI re-reads CLAUDE.md from disk. So I attached a safeguard to the PreCompact slot: right before every compaction, check whether that profile block is still present on disk, and if it's gone, write it back in immediately. That way, the instant compaction finishes and the CLI re-reads CLAUDE.md from disk, the profile block is complete again — the profile gets its life extended for free. This is another concrete application of post 6's rule — "don't bet on the model's own discipline for critical steps, weld it in with a deterministic mechanism" — just with a hook doing the welding this time. (You might wonder: wouldn't checking at session start be even more robust? Unfortunately, the Python SDK has no SessionStart slot, so the only window I have is right before compaction. The saving grace: CLAUDE.md already gets read from disk once at session start anyway, so all that matters is that the profile block is complete on disk by then.)

Audit trail: the Stop hook takes a snapshot, and deliberately does nothing else

Third job: tucked inside the Stop slot.

Every time the model stops for a turn, I use the Stop hook to quietly snapshot that version of the draft into the task's metadata directory. What's the snapshot for? It's the upstream input for post 8's compute_diff — having "this version, as the AI left it" is what lets me later compute "AI version vs. your edited version" once you've manually revised it, feeding that productivity metric back into the profile loop.

But I kept this one deliberately restrained: the Stop hook only snapshots — it does not use this moment as an opportunity to trigger a full final review. Early on, I did think about doing exactly that — the model triggers Stop every time it stops anyway, why not run a full final review while we're here? I dropped that idea fast: Stop can't distinguish between "the whole piece is actually done" and "just finished a small step, catching its breath." Run a heavyweight final review every single time it stops, and users get interrupted into oblivion. So final review stays something the user owns: when you feel the piece is close to done, you fire /final_check yourself. The hook handles the invisible, mandatory chores (the snapshot); the heavyweight, when-a-human-should-decide-the-timing work stays with the user.

🧨 A Real Gotcha: Injection Logic Hung on canUseTool Is Dead Code

🔧 Gotcha · injection logic was wired up, and never fired even once

The first time I needed to inject an argument into a specific tool call, I hung it off the canUseTool approval callback covered in post 9, on the assumption that "well, it's intervening before the tool runs either way." No amount of local testing made it fire. Checked the logs, and found that callback had never been called at all.

Dug to the root of it, and the answer was sitting in that six-step diagram's ordering from post 9: Hooks → Deny → Ask → Mode → Allow → canUseTool. The tool I was trying to inject into was already on the Allow list (read/write tools are supposed to auto-approve). And the moment a tool hits Allow, it gets approved at step 5 — it never reaches step 6, canUseTool, at all. So the injection logic I'd hung on canUseTool was dead code that would never execute, sitting there quietly — no error, no effect, the worst kind of bug.

The fix: injection-type logic has to hang on PreToolUse, the gate at the very front of the evaluation chain. Since it has to act before "approval" happens, it needs to stand ahead of every single approval decision. When attaching logic to an ordered processing chain, work out exactly where it needs to take effect first — get the position wrong, and it might never even get through the door.

This isn't something I discovered by accident, either — the official docs say it explicitly: the PreToolUse hook runs before every other permission check, and even with the most permissive bypassPermissions mode active, a hook's denial still holds. Hit the wall first, verify against the docs after — these are the detours you still can't fully skip, even in the age of vibe coding. 😳

This connects directly to post 9's "mandatory approval relies on staying off the Allow list" mechanism — really, the two are two faces of the exact same ordering. Drawing them together makes it a lot more concrete:

Tool-call permission chain
Tool-call permission chain

In one line: want to reach in and tweak something before approval (injection)? Hang it on the very first hook. Want to block and ask the user before approval (approval)? Keep it deliberately off Allow, so it falls all the way through to canUseTool. The Allow step is the watershed — cross it, and everything auto-approves. Whatever kind of intervention you want, work out which side of that watershed it needs to stand on.

⚖️ Where This Gets Vertical: Critical Steps Get Welded Down with Hooks, Never Left to the Model's Discipline

Back to the comparison running through this whole series:

⚖️ The tradeoff · guaranteeing critical steps, generic vs. vertical

The common generic-agent approach SmartWriter Why
Mandatory steps Written into the prompt, relying on the model's discipline Welded down at lifecycle points with hooks The model can skip steps or run them out of order — critical ones can't afford that
Out-of-bounds access Relies on the model's discipline about "don't touch sensitive files" PreToolUse path allowlist, hard-blocked before execution Discipline isn't a defense — code is
Long tasks losing state Left to chance Hook auto-reinjects the profile after compaction Only a hook is guaranteed to re-run automatically

The core of this post rhymes with everything before it: anything that "absolutely must happen" or "must never happen," I don't trust to the model's own discipline — I find a deterministic moment in the lifecycle and weld it down with code. Post 6 welded down genre-methodology injection. Post 9 welded down the prohibition on dangerous operations. This post welds down paths, the profile, and snapshots. A hook is just the most convenient welding tool for the job.

That closes out the "World Interaction" section entirely. Looking back at the three posts: post 8 equipped the agent with tools (gave it hands), post 9 framed those tools with permissions (kept its hands from reaching where they shouldn't), post 10 used hooks to intervene at critical moments (making sure what must happen, happens, and what must be blocked, is blocked). At this point, our writing copilot can safely and predictably interact with the real world.

But everything covered so far has still been about "how a single agent does one thing well." Hit a long, structurally complex piece, and one agent writing straight through from start to finish can easily lose direction partway, or tangle up research, verification, and final review into one messy context.

That's exactly where higher-level organization comes in: planning before acting, and outsourcing some of the independent work to "clones" of itself. Starting next post, we move into "Orchestration & Delegation" — beginning with Plan Mode, and why there's already a built-in plan mode I chose not to use. Let's keep going.