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 |
|---|---|---|---|
| Command hooks | Operator, via config.hooks |
Subprocess: HookInput JSON → stdin, outcome JSON → stdout |
Glue scripts, lint/format/notify pipelines |
| HTTP hooks | Operator, via config.hooks |
JSON HTTP POST/response | Local or remote policy services; HTTPS required except loopback |
| In-process hooks | Plugins, via api.registerHook |
Awaited function call | Type-safe, low-latency, receives deadline + AbortSignal |
All models share the same payload (HookInput) and outcome
contract (HookOutcome), and all 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 ordinary hook automation for a session with --no-hooks. Trusted
configured hooks marked policy: true remain active; this prevents a session
flag from removing a security boundary. Command/HTTP hooks are independently
gated by the runner's allowNonPolicy flag, except policy
hooks.
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 input via action: "mutate" |
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:
-
PreToolUse two-stage chain — hooks registered with
stage: "mutate"run sequentially first and compose argument changes. Hooks registered withstage: "validate"then inspect the same final argument object. Validators cannot mutate. Any explicit deny short-circuits execution. -
Mutation chain (
PreToolUseonly,action: "mutate") — 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, ConfiguredHook[]>>.
Loaded once at boot by HookRegistry.loadShellHooks(config.hooks).
HTTP hooks use the same controls plus "type": "http", "url", and
optional "headers". Non-loopback cleartext HTTP is rejected. policy: true
marks trusted enforcement that remains active under --no-hooks.
Configured command/HTTP hooks are owned by the runtime (no plugin name), so they survive plugin install/uninstall cycles. Config changes atomically replace the configured transport entries while leaving plugin-owned hooks intact.
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, runtime) => {
runtime.signal.throwIfAborted();
const lint = await runLint(input.toolInput);
return lint ? { additionalContext: `Lint:\n${lint}` } : {};
}, { name: 'lint-after-edit', timeoutMs: 5000, failurePolicy: 'open' });
// `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 command hook may print a JSON object to stdout; an HTTP hook returns one
in its response body; an in-process hook may
return one. Every field is optional — an empty object, undefined, or a
command hook that prints nothing all mean "allow, no side effect".
{ "action": "allow" }
{ "action": "deny", "reason": "blocked: rm -rf" }
{ "action": "mutate", "input": { "command": "ls -la", "timeout_ms": 30000 } }These outcomes are mutually exclusive. allow means this hook has no
objection; it never bypasses WrongStack's permission policy. YOLO work
therefore continues silently unless an explicit deny rule blocks it.
Legacy decision: "block"|"allow" and modifiedInput outputs remain
accepted and are normalized at the transport boundary.
Command shortcut: exit code 2 forces action: "deny" (with stderr, or
failing that stdout, truncated to 2 000 chars as the reason), matching Claude's
convention. Other non-zero exits are hook failures and follow failurePolicy.
Mutation is only honored for mutator-stage PreToolUse hooks. 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.
- Every invocation is awaited behind a per-hook deadline (
timeoutMs, default 5 000 ms). In-process hooks receive{ signal, deadlineAt }and must use asynchronous I/O. Blocking APIs such asexecSyncdefeat cancellation and must not be used in hook bodies. - Command hooks are killed as a process tree on timeout or abort. HTTP hooks
pass the same cancellation signal to
fetch. failurePolicy: "open"(default) logs timeout/crash/malformed output and continues.failurePolicy: "closed"turns the failure into a model-visible denial; it does not open an approval dialog.- 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. Ctrl-C and hook deadlines propagate via
AbortSignal. An in-process hook that ignores the signal may keep doing work after the runner stops awaiting it, so hooks must be cancellation-cooperative.
A hook can never crash the agent. Every invocation is isolated, then its failure is interpreted using the hook's explicit failure policy:
| Failure mode | Resolution | Surfaced as |
|---|---|---|
| In-process hook throws/times out | Signal fired; open = skip, closed = deny | warning + policy result |
| In-process hook returns a non-object | invalid_output; open = skip, closed = deny |
warning + policy result |
| Command hook fails or times out | Process tree killed; open = skip, closed = deny | warning + policy result |
| HTTP connection/non-2xx/timeout | Request aborted; open = skip, closed = deny | warning + policy result |
| Configured hook emits invalid JSON | invalid_output; open = skip, closed = deny |
warning + policy result |
| 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.
- Command-hook stdout and HTTP-hook responses are capped at 64 KiB. Beyond that the buffer is truncated and the hook's outcome (if any) is parsed from the truncated prefix.
- Command-hook stderr is capped at 64 KiB for the deny-reason fallback.
- Block reasons are truncated to 2 000 chars before being shown to the model.
- Command 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.
runShellHookchecks the first whitespace-delimited token against a command allowlist (shells, interpreters, common utilities, git). Because the accepted string is then executed withshell: true, this is a convenience filter, not a security boundary: an allowed first command can still use shell chaining, substitution, or redirection to execute other commands. Treat the complete command string as trusted operator code. The two documented escape hatches for operator-authored executables are:- Reference a script by absolute path (POSIX
/...or WindowsC:\.../C:/...). - Drop a wrapper under
.wrongstack/hooks/and reference it by absolute path.
- Reference a script by absolute path (POSIX
--no-hooksdisables ordinary automation across every transport. Hooks explicitly markedpolicy: trueremain active.- Command hooks inherit a sanitized child environment via
buildChildEnv(). - HTTP hooks accept loopback
http://URLs and any syntactically validhttps://URL; they do not block private, link-local, metadata, or other sensitive HTTPS destinations. Configure only operator-trusted endpoints. - Hook payloads include tool inputs/results and can therefore contain sensitive data. Treat command scripts and HTTP endpoints as trusted policy components; prefer loopback or HTTPS and never log raw payloads indiscriminately.
- Boot phase.
HookRegistry.loadShellHooks(config.hooks)registers every configured command/HTTP hook. 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. - Configured 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.
Configured 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:
- Builds the replacement before changing live state, then drops every old configured entry (in-process entries are untouched).
- Installs the new command/HTTP set from the updated config map.
- Logs
"Hooks reloaded (N configured entries across K events)"atinfolevel so operators can confirm the reload.
The watcher uses a structural 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 configured hook set actually changed (transport target,
matcher, timeout/failure/stage/policy controls, headers, 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>
runShellHookDetailed, // preserves timeout/exit/parse failure classification
runHttpHookDetailed, // POST HookInput JSON to a native HTTP hook
hookMatcherMatches, // (matcher, toolName?) => boolean
shellHooksEqual, // (a, b) => boolean — structural configured-hook 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, // legacy/non-PreToolUse return shape
PreToolUseOutcome, // explicit allow | deny(reason) | mutate(input)
AnyHookOutcome, // accepted transport-boundary output
InProcessHook, // (input, { signal, deadlineAt }) => value/Promise
HookRegistrationOptions,// timeout, failure policy, stage, policy marker
ShellHook, // command transport configuration
HttpHook, // native HTTP transport configuration
ConfiguredHook, // ShellHook | HttpHook
HookEntry, // discriminated union of registered entries
HookRunEnv, // { cwd: string }
HookRunnerOptions, // { registry, logger?, allowNonPolicy?, 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 '{"action":"deny","reason":"dangerous command blocked"}'
exit 0 # (or: exit 2)
fi
# allow (no output)config.json:
{
"hooks": {
"PreToolUse": [{
"name": "bash-safety",
"matcher": "bash",
"stage": "validate",
"command": "bash ./scripts/guard-bash.sh",
"timeoutMs": 3000,
"failurePolicy": "closed",
"policy": true
}]
}
}api.registerHook('PreToolUse', 'bash', async (input) => {
const cmd = (input.toolInput as { command?: string }).command ?? '';
// Force `ls` to always show long form
if (cmd.startsWith('ls ') && !cmd.includes('-l')) {
return { action: 'mutate', input: { ...input.toolInput, command: cmd.replace('ls', 'ls -l') } };
}
return { action: 'allow' };
}, { name: 'long-ls', stage: 'mutate', failurePolicy: 'open' });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, async () => ({
additionalContext: 'Reminder: this repo uses conventional commits.',
}));api.registerHook('Stop', undefined, async () => {
await flushCoverageReport();
});| Mechanism | Scope | Effect |
|---|---|---|
--no-hooks CLI flag |
Whole session | Ordinary hooks are skipped across all transports; policy: true hooks remain |
allowNonPolicy: false on HookRunnerOptions |
Runner instance | Ordinary hooks are skipped across all transports; policy hooks still run |
| Plugin uninstall | That plugin's hooks | drainByOwner removes every in-process hook the plugin registered |
HookRegistry.clear() |
Whole registry | Every entry (configured + in-process) dropped; used in tests and full session teardown |