Hook Chain

Level 3 Engineering and policy

Mods 301

At scale a mod stops being a personal tweak and becomes part of how an organization runs Claude Code. This page is for the people who write policy mods, review other people's mods, or decide which mods may load at all. It covers seats in the chain, admission, what the loader's static analysis does and doesn't promise, every failure mode we know of, and how to ship.

1. Seats in the chain

Hooks don't run in load order, whatever some early write-ups say. They run by tier, outermost first:

SeatWho sits thereSet by
1. prependsec-default@builtin where the guard loads, then prependPlugins in listed order, then other org mods not in appendPluginsManaged settings
2. userMods the user installed. A mod runs before the mods it lists in dependencies.The user
3. appendappendPluginsManaged settings
4. builtinOther built-in mods: agents-md, diff, telemetry…Claude Code
5. coreClaude Code's own behaviour, reported as { plugin: 'engine', tier: 'core' }—

Within one module, hooks run in the order on was called. Setting prependPlugins replaces the default, so list sec-default@builtin yourself or you lose the guard. Repository settings can never set these lists. A prepend or append mod can skip the user tier with next.to(e, 'append' | 'builtin' | 'core'), and any hook can read next.origin to see who raised the event. The hook chain draws the full path of a tool call, including where managed PreToolUse hooks sit.

2. Policy mods

Two events make a mod a policy engine:

The built-in sec-default guard is the reference policy mod, and its source is public in anthropics/claude-code. Where it loads, it stops user mods from changing the system prompt and managed instructions. It also stops them lifting a managed deny rule unless allowModsToOverrideDenyRules is set. An 11 October update widened what it holds: managed and sign-in environment variables against env.set, injected session.append rows, prompt.attachment, ui.log and all of session.compact. A user mod's compaction hook doesn't run where the guard is seated. A user mod can still override ask rules and non-managed PreToolUse blocks, and in auto mode a call a mod approves skips the classifier.

Write admission checks with .catch. A plugin.register hook that throws is skipped, and the mod it was checking loads.

3. The switches, in order

SwitchEffect
Anthropic's remote kill switchTurns installed mods off. No local setting overrides it.
Workspace trustNo mod loads before the trust prompt is accepted.
--safe-mode / --bareTurns off installed mods for the session. --bare keeps managed ones. Built-ins unaffected.
disableAllHooksIn user settings, stops the user's mods and hooks. In managed settings, stops every installed mod, org mods included.
allowManagedHooksOnly (managed)Only org and built-in mods load, and users' settings hooks are blocked.
allowManagedModsOnlyAn option on the sec-default guard (pluginConfigs["cc-plugin-sec-default@builtin"].options), honoured only in managed settings. User mods stop, and users' settings hooks keep working.
disableSideloadFlags (managed)Rejects --plugin-dir and --plugin-url, and stops mods Claude writes from loading.
strictKnownMarketplaces and allowlistsDecide whether a mod can be installed in the first place.

An org mod counts as the organization's only when managed enabledPlugins lists it from a directory marketplace at an absolute path by relative path, so it loads in place. A GitHub, git, URL or npm source counts as a user's mod even when managed settings enable it.

4. An audit boundary, not a security boundary

The docs say both "the module runs in a sandbox of its own" and "mods aren't sandboxed". Both are true. The runtime is capability-isolated: no Node, no DOM, no file or network globals (timer globals do exist, despite the docs; see Field tests), and every effect is a $ call the loader can find statically. But $ hands out the user's full authority. Static analysis tells a reviewer what a mod can do. It doesn't stop it. Three gaps matter for policy:

5. Failure modes

FailureWhat happensNet effect
Hook throws, overruns 10 s of own time, or answers the wrong shape before nextSkipped; the next link runs in its placeFails open
Same, after next resolvedThat result stands; nothing runs twice—
.catch registeredHandler runs (1 s) with next.error.kind = throw, timeout or re-entry, and can denyFails closed, by choice
$.ui.ask hook times out while holding a callHook skipped, so the held command runsFails open
sec-default can't read managed settings or check deny rulesRefuses user mods or the callFails closed
Invalid ui.render treeEngine draws its own and logs the reasonGraceful
Top-level module code throwshooks module did not load—
A reload failsPrevious version stays loaded—
A hook blocks the worker threadThat mod unloaded: "crashed the hooks worker"—
Worker crashes three times with no single culpritAll non-built-in mods unloaded, org policy mods included, until /reload-pluginsPolicy fails open
session.end hooksAll of them share 1.5 sLate saves are lost

The worker-crash row is the one to plan around. A policy that must hold even when every mod is unloaded belongs in managed settings (deny rules, managed PreToolUse hooks), not only in a mod. Managed PreToolUse hooks run before any mod sees the call, and they run again on a call a mod rewrote.

6. Limits

LimitValue
Hook's own time10 s
.catch handler1 s
All session.end hooks together1.5 s
$.process.run30 s default, 10 min max
$.model.complete maxTokens default1,024
$.fs per file / $.store per plugin / $.http.fetch body4 MiB each
ui.invalidate redraws10/s (30/s visible terminal)
$.session.messages()newest 4,096
Names[A-Za-z0-9_-], ≤ 64
Registered tool description seen by the model (2.1.296)4,096 chars
One test in claude plugin test5 s

7. CI for mods

  1. Validate strictly. claude plugin validate <dir> --strict --json. Fail the build if calls grows unexpectedly or a gating hook loses its .catch (gatingHooks in the JSON).
  2. Test. claude plugin test <dir> exits 1 on failure and needs no network or sign-in. Run it in an empty folder first to check that mods can load in that environment at all.
  3. Type-check against the build. The authoritative API is the declaration file each build writes (about 21,500 lines in 2.1.295). A headless run doesn't write .claude-plugin/types/, so keep a pinned copy from the plugin-authoring skill's bundle and point a tsconfig at it. Regenerate it on every Claude Code upgrade; never edit it.
  4. Pin the Claude Code version in CI, and re-run on upgrade. The API is early access, and seven of the first nine releases after launch changed something for mod authors.

8. Shipping

One file makes a repository a marketplace: .claude-plugin/marketplace.json listing the mod, with "source": "./" for a single-mod repo. Users install with one line at the prompt:

/plugin install <mod> --marketplace <owner>/<repo>

Put that line in the README. Since 2.1.295 validate suggests it when it's missing. Installs are cached by version, so bump version on every release. An edit to a working copy reaches users only as a new version and claude plugin update. The exception is a directory marketplace with relative sources, which is read in place: edit it, run /reload-plugins, done. That is the model for org mods. Plugins can also be submitted to the Claude directory.

9. When something does nothing

What to remember

  1. The chain is ordered by seat, not load order. Policy sits in prepend.
  2. plugin.register plus the fact that every $ call is an event make a mod a policy engine.
  3. Static analysis is an audit boundary. $ carries the user's full authority.
  4. Mods fail open by default, and a worker crash loop unloads policy mods too. Back critical policy with managed settings.
  5. Validate strictly, test, type-check against the build and pin the version in CI.