Last night I moved five guards in my Claude Code harness from shell scripts into the new function hooks API. claude plugin list said both plugins were enabled. They were enabled. They were also doing absolutely nothing, in every session I'd started normally, for as long as the layer had existed.
Function hooks sit behind an experimental env var:
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Without it, Claude Code never calls register(). No handlers, no commands, nothing. When I promoted the layer I had that var exported in the terminal I was working in, and nowhere else. Every session that didn't inherit that shell loaded zero of it.
The failure is silent by construction. The plugins really are installed. They really are enabled. claude plugin list is telling the truth and the truth is useless, because nothing in that output distinguishes a plugin that ran from a plugin that was never invoked. The one thing that would have told me was a heartbeat file the layer writes on every turn, which I had built and then never looked at.
That's my own rule, sitting in my harness since August: a check that came back green has been run, not verified. Make it fail on purpose first. I had the instrument. I read the wrong dial.
It's in settings now, user scope, so every session gets it. But that was the first of three failures in two days that all had the same shape, and the other two are more interesting because they're failures in the guards themselves. This post is the migration, and then those three.
Why bother moving them at all
My harness has 27 hooks in it. Every one is a shell script that Claude Code spawns as a process, hands a JSON blob on stdin, and waits for. Exit 2 means block. That's the whole contract, and it has worked fine for seven months.
The cost of that contract is that a hook knows almost nothing. It gets the tool call and whatever it can read off disk. It can't ask what the session has spent, it can't see a subagent's token usage, and it can't touch a tool's result on the way back to the model. It sits on one side of a wall and shouts.
Function hooks are the same idea with a different mechanism: your code registers handlers that run inside the session, in process, with a context object.
What a function hook can do that a script can't
A classic hook gets stdin and returns an exit code. A function hook gets a context object and an event, and can return a decision.
The three capabilities that mattered enough to justify the migration:
It sees the tool result, not just the call. A tool.call handler wraps execution. You can inspect and rewrite what comes back before the model reads it. My old secret watcher ran after the fact and told me a key had already been printed into the transcript, which is an alert, not a guard.
It can ask the session about itself. $.session.usage() returns live token usage. My old context nudge was a Python script reading a file that the status line happened to write to %TEMP%, which is exactly as reliable as it sounds.
It has a store and a bus. State persists across turns without me inventing a JSONL file for every guard, and observational events arrive on a bus instead of each hook re-deriving the same facts.
The thing I underestimated: it also doesn't pay process startup. Twenty-seven scripts spawning per tool call is real overhead, and I had been quietly eating it.
The five I moved
I didn't port all 27. I moved the ones the new capabilities actually improved.
Routing. This is the big one. It replaces three separate classic hooks that each had an opinion about subagent spawns: one checked the model was declared, one checked the capability graph, one priced the spawn against a break-even. They used to run in sequence and argue.
Now it's one pure function over the spawn event. The capability graph is downward-only, Fable to Opus to Sonnet to Haiku, with an advisor edge going one rung up and Haiku as a leaf that spawns nobody. The important change is that it rewrites instead of denying. If a Sonnet agent asks for Opus, the old hook returned exit 2 and the model treated it like a transient error and retried. I have a log with 266 denials in it and edit denials retried five to seven times on the same file. A model does not learn from a wall, it probes it. So now the model gets corrected onto the graph and told why, on the tool result. Deny is reserved for what no rewrite can fix.
Secret redaction. Every tool result, MCP included, gets scanned and each match replaced with <REDACTED:kind:len> before the model sees it. The old hook could only tell me it had already happened.
Context nudge. Reads real usage off the session instead of a temp file.
Subagent accounting. Pure observation. Reserved cost versus measured cost per spawn, learned median per agent type, prediction error against the # EST: tag I make myself write.
Read cache. The third identical read of an unchanged file gets served from cache with a note. Counts observations across Read, Grep, Glob, cat, head and tail, per file. Writes invalidate.
The actual hard part: two layers, one decision
Here's the problem I didn't see coming.
The classic hooks are still installed. They have to be, because the new layer is experimental and I want a rollback that isn't "reinstall your harness from git." But that means for any given decision, two pieces of code can now block the same tool call.
Both firing is a double block. Neither firing is a silent hole. And "neither firing" is much worse, because a guard that quietly stops guarding looks exactly like a guard that has nothing to complain about.
So the migration is built around a single invariant: a decision is owned by exactly one side per session.
Each guard has a mode. classic means the old hook owns it. mod means the new one does. shadow_mod means both run, the new one's verdict is recorded and thrown away, and the old one still decides.
The mode alone isn't enough though. A config file saying "routing": "mod" doesn't prove the Mod actually loaded. So the classic hook doesn't stand down for the config. It stands down for a heartbeat: the Mod writes a per-session file naming the guards it armed this session, rewritten every main turn. The classic hook reads it, and yields only if this session's heartbeat says the Mod is live and armed for that specific guard.
If the plugin fails to load, there's no heartbeat, and 27 shell scripts take over exactly as before. The failure mode is the old behavior.
What the numbers say so far
The subagent accounting is the part that produces data rather than opinions, so it's the part I trust.
Every spawn reserves an estimated cost from a learned prior, then records what it actually used. Early rows, from Sonnet parents spawning lean workers:
| Type | Reserved | Measured | Reserve error |
|---|---|---|---|
| haiku-scout | 17,000 | 17,006 | +6 |
| advisor | 17,000 | 17,679 | +679 |
| haiku-scout | 17,006 | 17,500 | +494 |
| haiku-scout | 17,253 | 16,952 | −301 |
| haiku-scout | 17,128 | 17,133 | +5 |
A lean subagent costs about 17,000 tokens before it does anything useful. That's the startup tax: harness prompt, tool catalogs, skill descriptions. The prior converged on it within four spawns.
The number that matters more is the one underneath: my own # EST: declarations ran about 1,500 to 2,300 tokens high every single time. I was consistently overestimating what I'd need, which means the guard was letting through spawns I'd have skipped if I'd priced them honestly. Now it prices them against measured history instead of my guess.
Where this touches everything else
The reason I did this at all is that the same harness runs every project I work on. It isn't a Claude Code toy sitting off to one side.
The runtime adapter is one plugin whose only job is reading raw engine events and putting them on a bus. Everything else subscribes. That event stream lands in state/events/<session>.jsonl, which is the same file my context handoff bundle reads when I save a session to resume later, and the same stream the discovery loop pulls from when it runs solver searches overnight.
Governance still wraps all of it. DashClaw gets the guard decision before the action executes, so an approval still comes to my phone. The Mods layer changed who computes the verdict locally, not who has to say yes to the risky thing.
CostClaw reads the measured spawn rows. The accounting guard writes to both its own log and the classic budget log, tagged so the calibration script ignores rows it didn't produce. Two writers, one history, no double counting.
The pattern I keep landing on: build the observation layer once, have everything else subscribe. I got that wrong for months by giving every guard its own JSONL file and then writing scripts to reconcile them.
The canary that cried wolf
The canary went red while I was writing this post.
It pins the Claude Code version the layer was verified against. Claude updated from 2.1.273 to 2.1.274 underneath me, the pin didn't match, and the check failed:
version: installed 2.1.274 vs pinned 2.1.273 —
re-run lab/probe* and update RUNTIME_PIN before trusting mod mode
My first instinct was to bump the number. That would have been the wrong fix, because the check itself was wrong.
Claude Code ships patches most weeks. An exact-match pin means the canary goes red on a schedule, for a reason that usually isn't a real problem. And a canary that's red most of the time is one you learn to scroll past, which is the one outcome a canary must never produce. I'd built an alarm that trains you to ignore alarms.
The deeper issue is that the version string was never the thing I cared about. What I actually need to know is whether the API the Mod depends on still exists. The layer already measures that directly: every session it probes all nine capabilities it uses and writes the result to disk. On 2.1.274 all nine came back true.
So the check now works the way the evidence does. A minor or major version change still hard-fails, because event shapes can genuinely move. A patch change defers to the probe: if every capability still answers true on the installed build, the API is intact and the pin is merely stale. Clearing it is one command, and it refuses to run unless both the canary and the probe are clean, so it can't launder a real regression into a green light.
The old behavior would have had me bumping a constant every week until I stopped reading the output.
The other guard I broke this morning
Same session, different guard, and this one is funnier.
I have a hook that blocks recursive searches rooted at my home directory or C:\Projects, because they take two to three minutes on this machine and I'd burned about an hour of session time on them. It matches on the search tool's name.
It denied this:
node canary.cjs --json | node -e "j.notes.find(n => n.startsWith('version:'))"
There's no search in that command. The pattern was \bfind\b, which matched the word find anywhere, including inside a quoted JavaScript string as a method call. The guard was blocking work it has no opinion about, and the escape hatch is one comment marker away, so the practical effect was to train me to reach for the override.
The fix is that the tool name now has to sit at a command position, meaning the start of the line or right after a pipe, semicolon, && or a subshell opener.
Then I made the more interesting mistake. My first attempt built the pattern by concatenating strings, which ate a level of backslash escaping, so \b became a literal backspace character. The guard didn't get stricter or looser. It stopped matching anything at all, which looks exactly like a guard with nothing to complain about.
I only caught it because I'd written the test suite first, with cases in both directions: the false positives that must pass, and every genuinely slow root-scoped search that must still be denied. First run, seven of fourteen failed, all of them denials that had silently stopped happening.
Both bugs are the same bug wearing different clothes. A guard whose failure mode is silence needs a test that watches it fail on purpose, and a check nobody trusts is worse than no check, because it teaches you to override.
Three failures, one shape
Worth putting the three next to each other, because I didn't notice the pattern until I'd written them down.
The layer was inert in every normal session, and plugin list said enabled. The canary went red every patch release for a reason that wasn't a real problem, until red stopped meaning anything. And my regex fix stopped matching entirely, which produced exactly the same output as a guard with nothing to report.
None of those announce themselves. Each one produces the output you'd expect from a healthy system, which is why all three survived until something forced them to fail in front of me. The env var survived because I checked a list instead of an artifact. The other two only died because I wrote a test that asserted the denials, not just the allows, and ran the check against a case I knew should fail.
The uncomfortable version: I have 27 guards, and I've now verified three of them by watching them fail on purpose. I don't actually know what the other 24 do when they break.
What to take
If you're building enforcement into an agent harness, four things I'd skip relearning:
- Rewrite beats deny. A model treats a block like a transient error and retries, often on a different file. Correct it onto the valid path and explain why, on the result it's already reading.
- Never let two layers own one decision. Not "we'll be careful." A mode switch plus a liveness signal, where the old layer yields only to proof the new one is live this session.
- A plugin that's installed and enabled can still be doing nothing. Check for the artifact it produces when it runs, not for its presence in a list.
- A guard that false-positives is a guard you'll disable. Precision isn't a nicety here. Every wrong denial spends some of the trust that makes the right ones work.
The harness is mirrored public at claude-harness, swept of secrets and memory.
