Hooks let third-party code (plugins, skills, operator-authored scripts) observe and steer the agent at well-defined lifecycle points — block a tool call, rewrite its arguments, inject context, or run side effects.
Unlike the EventBus (which is observe-only and cannot change what happens),
hooks are interceptors: a hook can short-circuit an action, mutate its
inputs, or append context that the model sees. This is the system that lets an
operator say "if X happens, you step in" — without recompiling the host.
| Model | Who registers | Transport | Use case |
|---|---|---|---|
| Shell hooks | Operator, via config.hooks |
Subprocess: HookInput JSON → stdin, HookOutcome JSON → stdout |
Glue scripts, lint/format/notify pipelines, anything you'd rather write in bash/python than ship as a plugin |
| In-process hooks | Plugins, via api.registerHook |
Direct function call | Type-safe, low-latency, needs access to host internals (registries, stores) |
Both models share the same payload (HookInput) and same outcome
contract (HookOutcome), and both are driven by a single HookRunner per
session. The runner reads from one shared HookRegistry, so a tool call can be
shaped by a mix of shell and in-process hooks in the same turn.
Disable everything for a session with --no-hooks. Shell hooks are also
independently gated by the runner's allowShell flag (set false under
--bare and in untrusted sessions).
These are the lifecycle points a hook can attach to:
| Event | When it fires | Can block? | Can mutate / inject |
|---|---|---|---|
PreToolUse |
Before a tool runs, before the permission check | ✅ (tool never runs) | rewrite tool input via modifiedInput |
PostToolUse |
After a tool returns | — | append additionalContext to the result |
UserPromptSubmit |
Before a user turn is processed | ✅ (turn ends, no model call) | append additionalContext to the user message |
SessionStart |
Once, on the first turn of the session | — | append additionalContext to the system prompt (persists for the session) |
Stop |
At the end of every turn | — | side effects only |
All hooks for a given event fire in registration order. There are three distinct fire patterns, one per category of outcome:
-
Blockable chain (
PreToolUse,UserPromptSubmit) — hooks run sequentially, in the order they were registered. The first hook that returnsdecision: "block"short-circuits the chain: no later hook for that event runs, and the block decision is returned to the caller. If no hook blocks, all hooks in the chain complete. -
Mutation chain (
PreToolUseonly,modifiedInput) — within the sequential chain, each hook sees the output of the previous hook as itstoolInput. Mutations compose left-to-right. The final composed input is re-validated against the tool's JSON Schema before the tool runs. -
Fan-out collection (
PostToolUse,SessionStart,Stop) — hooks run in parallel (Promise.allSettled) because none mutate state or block. Each hook independently returnsadditionalContext; the runner joins all returned contexts with\nand passes the concatenation back to the caller. Order in the joined string is not guaranteed (parallel resolution).
Registration order for in-process hooks is the order api.registerHook was
called during setup(). Registration order for shell hooks is the order
they appear in the per-event array under config.hooks. When both exist for an
event, in-process and shell entries interleave by insertion time — the loader
walks the shared entries array in array order.
This is deliberate: a hook can veto a tool that the trust file would otherwise auto-allow, and a hook can rewrite arguments so a borderline call lands safely inside what the trust file already permits. The permission policy runs only on the post-hook (possibly rewritten) input.
Declared under config.hooks, a Partial<Record<HookEvent, ShellHook[]>>.
Loaded once at boot by HookRegistry.loadShellHooks(config.hooks).
Shell hooks are owned by the runtime (no plugin name), so they survive plugin install/uninstall cycles. They are reloaded only at boot — runtime config changes do not hot-reload hooks for the current session.
Registered through PluginAPI.registerHook. Returns an unsubscribe function;
the host also records the registration under the plugin's name so it can be
bulk-removed on teardown.
import type { PluginAPI } from '@wrongstack/core';
export default {
name: 'lint-after-edit',
capabilities: { hooks: true }, // declare intent (see Capability gating)
setup(api: PluginAPI) {
const off = api.registerHook('PostToolUse', 'edit|write', async (input) => {
const lint = await runLint(input.toolInput);
return lint ? { additionalContext: `Lint:\n${lint}` } : {};
});
// `off` is called automatically when the plugin is uninstalled.
// You usually don't need to call it yourself.
},
};A plugin may register multiple hooks against the same or different events; each call returns its own unsubscribe function. All of them are removed together when the plugin's API is drained (see Plugin / skill loading & unloading).
Identical for both transports. Flat and JSON-serializable so shell and in-process hooks see the same shape.
{
"event": "PreToolUse",
"toolName": "bash", // PreToolUse / PostToolUse
"toolInput": { "command": "ls" }, // PreToolUse / PostToolUse
"toolResult": { "content": "...", "isError": false }, // PostToolUse only
"prompt": "user text", // UserPromptSubmit only
"cwd": "/abs/project",
"sessionId": "01J..." // when known
}The types intentionally avoid referencing the live Context (which lives in a
higher layer) so types/config.ts can import them without a layering cycle. The
runtime pieces (HookRegistry, HookRunner, runShellHook) translate live run
state into this serializable shape at each phase.
A shell hook may print a JSON object to stdout; an in-process hook may
return one. Every field is optional — an empty object, undefined, or a
shell hook that prints nothing all mean "allow, no side effect".
{
"decision": "block", // "block" | "allow" (omit = allow)
"reason": "blocked: rm -rf", // shown to the model on block
"modifiedInput": { "command": "ls -la" },// PreToolUse only
"additionalContext": "note for the model"// see per-event semantics above
}Shell shortcut: exit code 2 forces decision: "block" (with stderr, or
failing that stdout, truncated to 2 000 chars as the reason), matching Claude's
convention. Any other exit code with no JSON on stdout is a no-op.
modifiedInput is only honored for PreToolUse. The executor swaps it in
and re-validates it against the tool's inputSchema before running — a hook
cannot bypass the schema. A re-validation failure is fed back to the model as
an error so it can self-correct.
PreToolUse and PostToolUse entries take a matcher. All other events ignore
it (every registered hook for that event runs).
A matcher is one of:
"*"(or empty/omitted) — matches every tool- A pipe-delimited, case-insensitive list of exact tool names, e.g.
"bash","edit|write","bash|edit|write"
Matching is by exact tool name, not substring or regex. "edit" matches the
tool named edit; it does not match editFile. The comparison is
case-insensitive on both sides, so "Bash" matches a tool registered as bash.
For non-tool events (UserPromptSubmit, SessionStart, Stop) the matcher is
treated as * and every registered hook runs. There is no content-based filter
on prompt or additionalContext — if you need one, write it inside your hook.
- In-process hooks may be sync or async. The runner always
awaits the return value, so aPromise<HookOutcome>is fine. Long-running work should still respect the session's iteration timeout — a hook that never resolves will block the agent loop. - Shell hooks are spawned and awaited with a per-invocation timeout
(
timeoutMs, default 5 000 ms). On timeout the child is sentSIGKILLand the hook resolves to a no-op (null). - The runner uses
Promise.allSettledfor fan-out events so a single slow hook does not block its siblings — but the caller still awaits every hook before continuing, so the slowest hook in a fan-out sets the floor for that phase. - Hooks share the agent's event loop. They cannot be cancelled mid-flight by the user pressing Ctrl-C; the abort signal propagates to tool execution but not into hook bodies. Keep hooks short.
A hook can never crash the agent. Every hook invocation is wrapped in a
try/catch inside HookRunner.invoke:
| Failure mode | Resolution | Surfaced as |
|---|---|---|
| In-process hook throws | Caught, logged at warn, treated as no-op (null) |
logger.warn("<event> hook threw: <msg>") |
| In-process hook returns a non-object | Coerced to null (no-op) |
nothing |
| Shell hook fails to spawn | Caught, logged, null |
logger.warn("hook spawn failed: ...") |
| Shell hook times out | Child killed, null |
logger.warn("hook command timed out after <ms>ms: <cmd>") |
| Shell hook exits non-zero (≠ 2) with no JSON | Parsed as null |
nothing |
| Shell hook emits invalid JSON | Parse error swallowed, null |
nothing |
| Shell hook emits valid JSON missing fields | Missing fields dropped, partial outcome used | nothing |
The isolation guarantee is per-hook: one hook failing does not prevent other hooks in the same chain/fan-out from running, and does not abort the tool call, user turn, or session.
The only exception is an explicit decision: "block", which is the hook
doing its job, not a failure. A block propagates normally (tool not run / turn
ended) and the reason is shown to the model.
- Shell hook stdout is capped at 64 KiB. Beyond that the buffer is truncated and the hook's outcome (if any) is parsed from the truncated prefix.
- Shell hook stderr is capped at 64 KiB for the block-reason fallback.
- Block reasons are truncated to 2 000 chars before being shown to the model.
- Shell hooks run arbitrary commands you put in your own config — they are not model-controlled and cannot be installed by a prompt. Still: keep hook scripts in version control and review them like any other automation.
runShellHookenforces a command allowlist (shells, interpreters, common utilities, git). Commands not on the list are rejected and logged. The two documented escape hatches for operator-authored executables are:- Reference a script by absolute path (POSIX
/...or WindowsC:\.../C:/...) — trusted because you wrote it. - Drop a wrapper under
.wrongstack/hooks/and reference it by absolute path.
- Reference a script by absolute path (POSIX
--no-hooksdisables both shell and in-process hooks for the session. Shell hooks are additionally gated by the runner'sallowShellflag.- Shell hooks inherit a sanitized child environment via
buildChildEnv(). - Hooks never receive secrets in their payload. The payload contains tool names, inputs, results, cwd, and sessionId — never API keys or tokens.
- Boot phase.
HookRegistry.loadShellHooks(config.hooks)registers every shell hook from config. These are owned by the runtime. - Plugin setup phase. The plugin loader topologically sorts plugins by
dependsOn/optionalDeps, then calls each plugin'ssetup(api). Insidesetup, a plugin callsapi.registerHook(...). Each call:- Adds an
inprocessentry to the sharedHookRegistry, tagged with the plugin's name asowner. - Pushes the returned unsubscribe function onto the plugin's private
pluginCleanupFnsstack.
- Adds an
- Capability gate. If a plugin declares
capabilitiesand includeshooks: false, the loader wraps its API soregisterHookemits a warning (default) or throws (whenenforceCapabilities: true). A plugin that declareshooks: true(or declares no capabilities at all) is not gated. See Capability gating below.
Plugin teardown happens in reverse registration order (mirroring stack-style resource ownership when plugin B depends on plugin A):
- The loader calls
plugin.teardown(api, { signal })with a per-plugin timeout (default 10 000 ms). DefaultPluginAPI.drainCleanup()runs every function onpluginCleanupFns— including each hook's unsubscribe — best-effort (errors swallowed).- Belt-and-braces backstop:
drainCleanup()then callsHookRegistry.drainByOwner(pluginName), which removes any in-process hook still tagged with that plugin's name. This catches the edge case wheresetup()threw partway through after registering some hooks — the per-call unsubscribes for the not-yet-pushed hooks would otherwise never fire, leaving dangling closures in the registry. - Shell hooks (runtime-owned) are never removed by
drainByOwner. They persist for the session and are cleared byHookRegistry.clear()only at full session teardown.
The result: no plugin-owned hook can outlive its plugin. Even a plugin that crashes during setup leaves a clean registry.
Shell hooks are hot-reloaded. The CLI subscribes to ConfigStore.watch
at boot (packages/cli/src/cli-main.ts); whenever config.hooks changes,
the watcher calls HookRegistry.replaceShellHooks(next.hooks) which:
- Drops every currently-registered shell entry (in-process entries are untouched — plugin-owned closures survive the reload).
- Installs the new shell set from the updated config map.
- Logs
"Shell hooks reloaded (N entries across K events)"atinfolevel so operators can confirm the reload.
The watcher uses a shallow per-entry equality predicate
(shellHooksEqual(a, b)) so unrelated config changes — model, log level,
provider, etc. — do not trigger a redundant reload. The reload only
fires when the shell-hook set actually changed (command, matcher,
timeoutMs, or the event list).
A failed reload never crashes the watcher — it's caught at warn level and the previous hook set stays in place.
In-process hooks follow plugin lifecycle as before: installing a plugin via
the plugin manager runs its setup (registering its hooks); uninstalling
runs its teardown (draining them).
PluginCapabilities includes an optional hooks flag, mirroring the existing
gates for tools, providers, slashCommands, and mcp:
export interface PluginCapabilities {
tools?: boolean;
providers?: boolean;
pipelines?: string[];
slashCommands?: boolean;
mcp?: boolean;
toolMutateCapabilities?: string[];
hooks?: boolean; // ← will the plugin call api.registerHook()?
}The loader applies the gate only when capabilities is non-null (this
matches the existing tools/providers behavior — capability gating is opt-in).
Inside the gate:
| Declaration | Behavior on registerHook |
|---|---|
hooks: true |
Pass-through, no warning |
hooks: false |
Warning logged (default) or PluginError thrown (enforceCapabilities: true); call still forwarded |
capabilities omitted entirely |
No wrap applied — pass-through (consistent with tools/providers) |
Use enforceCapabilities: true in CI / strict deployments to force plugins to
declare every subsystem they touch.
- Types:
packages/core/src/types/hooks.ts—HookEvent,HookInput,HookOutcome,InProcessHook,ShellHook,HookEntry. - Registry:
packages/core/src/hooks/registry.ts—HookRegistrywithregisterInProcess,registerShell,loadShellHooks,replaceShellHooks,list,has,all,drainByOwner,countByOwner,clear; plus the exportedhookMatcherMatches(matcher, toolName)predicate. - Shell-hook equality:
packages/core/src/hooks/shell-hooks-equal.ts—shellHooksEqual(a, b)andcountShellHooks(hooks)helpers used by the hot-reload path to decide whetherconfig.hooksactually changed before re-runningreplaceShellHooks. - Runner:
packages/core/src/hooks/runner.ts—HookRunnerwithpreToolUse,postToolUse,userPromptSubmit,sessionStart,stop, and the cheaphas(event)guard. - Shell executor:
packages/core/src/hooks/shell-executor.ts—runShellHook(spec, input, logger?)with allowlist, timeout, and output cap. - DI token:
TOKENS.HookRegistry(packages/core/src/kernel/tokens.ts). Resolve it from the container to get the session's shared registry. - Plugin API surface:
PluginAPI.registerHook(plugin/api.ts) and thehookRegistryfield onPluginAPIInit. - Consumer wiring:
PreToolUse/PostToolUseare called fromToolExecutor.executeBatch(execution/tool-executor.ts), gated behindhookRunner.has(event)so the payload is only built when something listens.UserPromptSubmitis auserInputpipeline middleware (packages/cli/src/hooks-wiring.ts→createUserPromptSubmitMiddleware). Ablockoutcome throwsHookBlockedError, which the pipeline's error boundary rethrows soAgent.runends the turn without a model call.SessionStartandStopare anAgentExtension(createLifecycleHooksExtension).SessionStartfires on the firstbeforeRunand appends itsadditionalContexttoctx.systemPromptfor the rest of the session.Stopfires on everyafterRun.
- Boot wiring:
packages/cli/src/cli-main.tscallshookRegistry.loadShellHooks(config.hooks)when hooks are enabled, and installs the middleware + extension into the agent.
From @wrongstack/core:
import {
HookRegistry, // class
HookRunner, // class
runShellHook, // (spec, input, logger?) => Promise<HookOutcome | null>
hookMatcherMatches, // (matcher, toolName?) => boolean
shellHooksEqual, // (a, b) => boolean — shallow per-entry equality
countShellHooks, // (hooks) => number — total entries across events
} from '@wrongstack/core';
import type {
HookEvent, // 'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'SessionStart' | 'Stop'
HookMatcher, // string
HookInput, // the payload
HookOutcome, // the return shape
InProcessHook, // (input) => HookOutcome | void | Promise<HookOutcome | void>
ShellHook, // { command, matcher?, timeoutMs? }
HookEntry, // discriminated union of registered entries
HookRunEnv, // { cwd: string }
HookRunnerOptions, // { registry, logger?, allowShell?, sessionId? }
PreToolUseResult, // { block?, reason?, input? }
PromptResult, // { block?, reason?, additionalContext? }
ShellHookSpec, // { command, timeoutMs? }
} from '@wrongstack/core';scripts/guard-bash.sh:
#!/usr/bin/env bash
input=$(cat) # HookInput JSON on stdin
cmd=$(printf '%s' "$input" | jq -r '.toolInput.command // ""')
if printf '%s' "$cmd" | grep -qE 'rm -rf|:\(\)\{'; then
echo '{"decision":"block","reason":"dangerous command blocked"}'
exit 0 # (or: exit 2)
fi
# allow (no output)config.json:
{
"hooks": {
"PreToolUse": [{ "matcher": "bash", "command": "./scripts/guard-bash.sh" }]
}
}api.registerHook('PreToolUse', 'bash', (input) => {
const cmd = (input.toolInput as { command?: string }).command ?? '';
// Force `ls` to always show long form
if (cmd.startsWith('ls ') && !cmd.includes('-l')) {
return { modifiedInput: { ...input.toolInput, command: cmd.replace('ls', 'ls -l') } };
}
return {};
});The rewritten input is re-validated against the tool's schema before it runs — if your rewrite produces an invalid shape, the model gets a clear validation error instead of a silent misuse.
api.registerHook('PostToolUse', 'edit|write', async (input) => {
const lint = await runLint(input.toolInput);
return lint ? { additionalContext: `Lint:\n${lint}` } : {};
});api.registerHook('SessionStart', undefined, () => ({
additionalContext: 'Reminder: this repo uses conventional commits.',
}));api.registerHook('Stop', undefined, async () => {
await flushCoverageReport();
});| Mechanism | Scope | Effect |
|---|---|---|
--no-hooks CLI flag |
Whole session | Neither shell nor in-process hooks run; HookRegistry stays empty |
--bare / untrusted session |
Whole session | Shell hooks skipped (allowShell: false); in-process hooks still run |
allowShell: false on HookRunnerOptions |
Runner instance | Shell hooks skipped; in-process hooks still run |
| Plugin uninstall | That plugin's hooks | drainByOwner removes every in-process hook the plugin registered |
HookRegistry.clear() |
Whole registry | Every entry (shell + in-process) dropped; used in tests and full session teardown |