Hooks
A hook is a handler module the compiler wraps and registers in each host's native hook document. You declare it once, keyed by a canonical event, and the adapters translate the event name, the matcher, and the result shape into whatever the selected host expects.
Declaring a hook
Each event accepts a bare handler path, an entry object, or an array of either when one event needs several handlers:
Canonical events
A host that does not implement an event simply does not receive that hook; the emitted document stays honest rather than inventing an equivalent.
Tool selectors
tools accepts the canonical selectors — shell, file.read, file.write, mcp, agent —
each translated to the host's native matcher where the host has one. Cursor maps all five; Claude
Code has no agent; Codex has no agent and no file.read. Every selector on a hook must map on
every selected target, so tools: ['file.read', 'shell'] targeting codex fails the build even
though shell alone would have worked. The Event and hook matrix
renders the matcher table. tools also accepts explicit host-native selectors
spelled <target>:<native-name>, such as claude:WebSearch or codex:view_image, which
contribute only to that host's native matcher.
A hook that selects tools must leave every selected target with at least one applicable
selector. A hook restricted to claude:WebSearch while also targeting codex fails the build
rather than emitting a Codex document with an empty matcher that would never fire.
The handler contract
A handler module default-exports a function that receives the event payload and returns the outcome. Returning nothing is the same as continuing:
The generated wrapper validates the result before projecting it into host-native output. Exactly four keys are accepted, and each one is checked:
Per-event restrictions are enforced, not documented-and-hoped:
sessionStart,afterTool, andagentStartcannot deny, stop, or replace input.beforeToolcannot stop, and cannot replace input while denying.stopaccepts onlycontinue, ordenywith a reason.agentStopcannot stop the parent flow or replace input, and on Codex it cannot add context.
An unknown key, a wrong type, or a violation of any rule above fails the hook with a clear message instead of being projected into a host document that would misbehave silently.
Prebuilt handlers
A project that owns its own compilation can point a hook at an already-built file inside a declared payload:
A prebuilt hook emits its native command as node "<root>/<payload path>" <args…> — one config
declaration replacing a hand-rolled hooks/hooks.json per host. Prebuilt hooks are packaged like
native hook documents: they do not compile wrappers and do not appear in the simulatable hook
index.
Event routes
Config-declared hooks are the compact form. The second authoring shape is an event route: a
file under src/events/ whose path is the canonical event family it handles
(src/events/tool/before.tsx, src/events/stop.tsx). It is one async default Server Component,
like an MCP tool route, plus a statically extracted config export:
canonical is the cross-host identity the framework derives — event, an idempotencyKey
hashed from the event, target, and native payload, observedAt, a sequence, and the
provenance naming the host and the native event that fired. native is a frozen snapshot of
the validated host envelope. Nothing in canonical is fabricated: a host that does not report an
axis leaves it unavailable.
The route answers through its rendered document. Agent.Context text becomes the host's
additional-context channel, and Agent.Result's value may carry
{ outcome: 'continue' | 'deny', reason?, updatedInput? }. The projection is per event and per
host, and illegal combinations throw before anything reaches the host: session/end,
compact/after, tool/failure, and workspace/open are observation-only everywhere; a denied
tool/before becomes hookSpecificOutput.permissionDecision on Claude and Codex but
{ permission: 'deny', … } on Cursor; a denied stop becomes { decision: 'block', reason } or
Cursor's followup_message. Which families each host supports is the generated
Event and hook matrix.
Event routes reach the twenty canonical families (session/end, prompt/submit,
compact/before, permission/request, …); config-declared hooks cover only the seven
listed above.
What is on the wire
Both shapes share the emitted hooks/hooks.json wiring, and both compile into a wrapper the host
invokes as node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs" (or the host's own root token). A
config-declared handler runs in-process inside that wrapper. An event route with
runtime: 'shared' instead forwards to the warm runtime living inside the generated MCP server
process, so hooks share state with tools:
- Host → wrapper (stdin). The host writes one JSON envelope. The wrapper streams stdin with a
hard 1 MiB cap, parses exactly one value, and validates it per host and per event
(
session_id,transcript_path,cwd, tool fields, …). Any mismatch exits nonzero. - Wrapper → runtime (IPC). One newline-delimited request —
protocolVersion,artifactEpoch,event,hostContractRevision,target, and the validatednativeenvelope — over a per-user Unix socket (a named pipe on Windows). The socket directory is0700, the socket0600, and the endpoint hash binds the artifact epoch, the target, and the artifact's install directory, so two installs never share a runtime. The request is raced againsttimeoutMs(default 5000 ms). - Runtime renders. The server rejects epoch mismatches and malformed messages, builds the
canonicalprops, and renders the route component through the react-server worker inside the request context. - Runtime → wrapper. One JSON reply:
{ status: 'ok', output }, or{ status: 'error', code }wherecodeisepoch-mismatch,invalid-message, orruntime-failed. - Wrapper → host (stdout). The host-native response. On
tool/beforethe wrapper always answers with an explicit allow-or-deny decision in the host's own field names (allowunless the route denied) even when the route renders no decision; silence is reserved for observation-only families.
Failure is closed by default. The only fallback is fallback: 'standalone' on a route that
also compiled its standalone form, and it fires only for runtime-unavailable (no live
socket). runtime-timeout, epoch-mismatch, invalid-message, and runtime-failed exit
nonzero — stale code never answers, and a fabricated response is never emitted.
A route with runtime: 'standalone' bundles its module into the wrapper itself: the same
canonical identity, the same projection, no shared process state. AB4817 refuses a route
that requires the shared runtime on a target where no generated MCP entry hosts it and no
standalone fallback exists.
Inspect and simulate
Hooks are the surface where "it built" is least convincing, so the emitted wrapper is runnable directly:
--hook must be the exact name (or id) that hooks list printed — normalization derives it
as <event>-<handler>-<hash>, so a bare event name such as session-start matches nothing.
session-start-session-start-7ab7e8a5 is the name the declaration at the top of this page
produces; the hash covers the event, handler path, targets, tools, timeout, and args, so any
change to the declaration yields a new name, and the one to copy is always the one hooks list
prints for your build.
The payload is the canonical input; the wrapper converts it to the host's native envelope and
validates it as the host would, so a Claude sessionStart simulation needs sessionId,
transcriptPath, cwd, and source (tool events additionally need toolName, toolInput, and
toolUseId).
hooks simulate runs the real emitted wrapper — the same file the host will execute — so the
result you see is the result the host would get. The developer Workbench exposes the same
playground with the raw stdout, stderr, and outcome for each run.
Next
- MCP servers and Apps — the other executable surface.
- Scripts and assets — code a hook or Skill can call.