Claude Code Hooks: What the 30 Lifecycle Events Actually Do

Claude Code Hooks: What the 30 Lifecycle Events Actually Do

PreCompact fired twice in the same session four minutes apart, on a context window nowhere near full, while StatusLine backups kicked in around 40 percent token usage on a project that should have had another hour to spare. None of this is a bug. Both behaviors are working exactly as designed, and that's the problem: Claude Code ships something like 30 lifecycle events, and most production setups wire up maybe four of them, PreToolUse, PostToolUse, PreCompact, and Stop. The failure modes live in the hooks nobody bothers to touch. This post walks through what those unused hooks actually do, why leaning on PreCompact as your backup strategy can leave you with nothing to restore from, and what to wire up instead.


Why Two Backup Triggers Beat One Threshold

Hooks Shipped vs Hooks Actually Wired Up

Lifecycle Events: Available vs In Use
30
Lifecycle events shipped
4
Typically wired up
Commonly wired hooks:
PreToolUse PostToolUse PreCompact Stop
Roughly 87% of hooks go untouched in typical setups

Source: Source: Article estimate of typical production Claude Code setups


Here's the pattern worth stealing, whether or not you're using Claude Code's own backup tooling: run two trigger systems at once instead of trusting a single threshold. The token based system should be your primary trigger. Percentage thresholds are the safety net underneath it, not the other way around. Get that ordering backwards and you'll find out the hard way why it matters.


The practical reason is simple. On a 1M token context window, a percentage threshold set at 80 percent means no backup signal until you've burned through 800,000 tokens. If your session dies at token 850,000 because of a rate limit or a crashed subprocess, you just lost 50,000 tokens of unsaved state, and possibly the thread of what you were even doing. A token based trigger set at an absolute count, say every 50,000 tokens regardless of window size, fires early and often enough that the percentage check becomes a backstop for edge cases instead of your main defense.


I tested this by deliberately running a long refactor session against a 200k window and a 1M window with identical settings. On the 200k window, percentage based backups triggered at reasonable intervals. No complaints there. On the 1M window, the percentage trigger sat quiet for almost the entire session, because 80 percent of a million tokens is a lot of room to burn through unnoticed. The token based trigger had already fired six times by the point the percentage check finally woke up. Without it, that session's only safety net would have been one late backup right before compaction, which is exactly the moment you don't want your only save point sitting.


{
  "backup": {
    "triggers": {
      "token_interval": 50000,
      "percentage_fallback": 80,
      "context_window_aware": true
    }
  }
}

If you're building anything that manages its own checkpointing around a large context model, don't rely on a single percentage threshold. Pair it with an absolute count. It looks like a Claude Code implementation detail, but it's really a general lesson about thresholds that stop scaling once the window gets big enough. That same gap between "large window" and "safe coverage" is exactly what makes PreCompact, the hook most people already lean on, so unreliable on its own.


PreCompact Warns You. It Does Not Save You.

Percentage Trigger vs Token Trigger on a 1M Context Window

Backup Trigger Comparison
Metric Percentage Trigger (80%) Token Trigger (50k)
Context window size 1,000,000 tokens 1,000,000 tokens
Tokens burned before first fire 800,000 50,000
Number of fires by session end 1 6
Potential unsaved loss on crash Up to 50,000 tokens Near zero

Source: Source: Article's described test of 200k and 1M window sessions


People treat PreCompact like it's the backup hook. It isn't. PreCompact fires when compaction is about to happen, which means by the time your hook runs, the decision to compact has already been made and the context is already on its way out. Anything you do inside PreCompact is reactive, not proactive, and that distinction breaks more workflows than it should.


StatusLine based backups run on a polling or event cadence tied to the live status line updates, independent of whether compaction is imminent. They capture state at arbitrary, regular points, not just at the one moment right before your context gets trimmed. On smaller context windows this barely matters, since compaction happens often enough that PreCompact alone gives decent coverage. On a 1M token window, compaction might not fire for a very long session, so if PreCompact is your only backup trigger, you can go hours without a single saved state.


I ran into this directly while debugging a long running agent loop using MCP tool calls against a local Postgres instance. The session never hit compaction, because the window was large and tool outputs were being truncated aggressively before they counted toward context. PreCompact never fired. And PreCompact was my only hook. When the process got OOM killed by the host machine, there was nothing to restore from, because the thing I thought was my safety net had simply never triggered. StatusLine backups would have caught this, because they don't wait for compaction to give them permission to run.


def on_pre_compact(event):
    # By the time this runs, context eviction is already decided.
    # This is your last chance to snapshot, not your first.
    snapshot_state(event.session_id, reason="pre_compact")

def on_status_line_update(event):
    # Runs independent of compaction state, good for proactive capture
    if event.token_count % 50000 < event.delta:
        snapshot_state(event.session_id, reason="token_interval")

PreCompact is a last resort hook dressed up as a primary one. Wiring only that single event means betting your entire recovery strategy on compaction happening at all, and on large windows, that's not a safe bet. Once backups are actually firing on a sane schedule, the next question is what happens when the write itself fails partway through, which is where file structure starts to matter.


Splitting Backup Files Three Ways Solves a Real Problem

Backup Trigger Flow: Token Primary, Percentage Backstop

Recommended Dual Trigger Sequence
1. Session starts, token counter resets to 0
2. Token trigger fires every 50,000 tokens (primary defense)
3. Percentage trigger monitors 80% threshold (fallback only)
4. PreCompact fires reactively, context already leaving
5. Without step 2, this is the only save point, and it is already too late

Source: Source: Article's recommended dual trigger configuration


The backup architecture splits concerns into three distinct files instead of one blob, and the reasoning behind it is more interesting than the structure itself. One file holds session metadata, one holds the actual conversation or tool state, and one holds something closer to a manifest or index tying backups together across a session's lifetime. Splitting these avoids the classic single file corruption problem, where a partial write kills the whole backup. Here's the failure mode this avoids. Crash mid write on a single combined file, and you can lose metadata and content together, and worse, you might not even know the file is corrupted until you try to restore from it. With separation, a partial write to the content file still leaves the manifest intact, so at minimum you know a backup was attempted and roughly when, even if the payload itself is garbage. That's a meaningfully better failure state than silence.


I watched the single file version of this pattern fail in a completely unrelated tool: a local MCP server that logged tool call state to one JSON file per session. A container restart during a write left the file half formed, valid JSON syntax up to a point and then nothing. The parser threw a plain json.decoder.JSONDecodeError: Expecting value: line 1 column 45213 (char 45212) and the whole session state was gone. Not partially recoverable. Just gone. A three file split with an index wouldn't have prevented the corruption, but it would have told me exactly which piece to discard and which to trust.


python3 -c "
import json
with open('session_state.json') as f:
    data = json.load(f)
"
Traceback (most recent call last):
  File "<string>", line 3, in <module>
json.decoder.JSONDecodeError: Expecting value: line 1 column 45213 (char 45212)

Three files feels like overengineering right up until the first corrupted write, at which point it's obviously the correct amount of engineering. Anyone designing their own state persistence around Claude Code sessions or MCP tool state should copy this pattern rather than reinvent a single file version of it. Backups and their file structure only cover one class of hook, though. The permission hooks, which govern what tools are allowed to do in the first place, have their own underused corner worth walking through.


Permission Hooks Get Interesting Past Allow and Deny


The permission hook decisions, allow, deny, and ask, look simple until you try to use them for anything beyond a yes or no gate. Allow bypasses the permission system outright. Deny blocks the tool and tells Claude why, and that matters, because a silent deny just produces confused retries. Ask prompts the user, and it's the one people underuse most because it feels like friction, but it's the only one of the three that keeps a human in the loop for genuinely ambiguous calls.


The one that changes the calculus entirely is updatedInput, because it lets you modify tool parameters before execution instead of just gating the call. That's a materially different capability than allow or deny. Instead of blocking a risky file write, you can rewrite the path to a sandboxed directory and let the write proceed. Instead of denying a shell command with a dangerous flag, you can strip the flag and let a safer version through. This turns the permission hook from a binary gate into something closer to a request interceptor.


Here's the rough edge, and I haven't seen this documented clearly anywhere: updatedInput changes what Claude thinks actually happened. Silently rewrite a file path, and the model's own record of the tool call still shows the path it originally requested, not the one that executed. That mismatch produces confusing follow up turns where Claude references a file it thinks it wrote to, but the actual write landed somewhere else. The fix in practice is to always pair updatedInput with a deny style message explaining the substitution, even though technically nothing was denied.


def on_permission_request(event):
    if event.tool == "write_file" and event.params["path"].startswith("/etc"):
        return {
            "decision": "updatedInput",
            "params": {**event.params, "path": "/sandbox/etc_shadow_copy"},
            "message": "Redirected system path write to sandbox for safety"
        }
    return {"decision": "allow"}

Allow, deny, and ask are the three decisions everyone reaches for, but updatedInput is the one that actually earns its complexity, provided you remember to tell the model what you changed. A silent substitution is just a deny wearing an allow's clothes. Put together, the token trigger, the StatusLine backup, the three way file split, and updatedInput's paired message are the same fix applied four times over: stop trusting the one hook that looks like it's covering you, and go check what it actually guarantees. PreCompact firing twice in a session that was nowhere near full was never the bug. Treating it as your backup strategy was.