Hooks
A hook is a handler module the compiler wraps and registers in each host's native hook document, or from Amp's generated PluginAPI factory. 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.
Event routes
Use one canonical event file under src/events/: .ts handlers return a lightweight event
result, while .tsx handlers render JSX. A .ts handler can request its separate .view.tsx
sibling with ctx.render('./name.view.js', data). Both shapes use the same event paths,
host adapters, and result validation. The rendered form can include static configuration:
canonical is the cross-host identity the framework derives, event, an idempotencyKey
hashed from the event, target, and native payload, observedAt, a sequence, the
provenance naming the host and the native event that fired, and the family's canonical
payload. native is a frozen snapshot of the validated host envelope. Nothing in
canonical is fabricated: a host that does not report a field leaves it absent.
requires selects every configured host whose capability table supports every listed row. The
event-route rows are events.sessionStart.context, events.stop.deny,
events.toolAfter.context, and events.toolBefore.deny; the generated
Event and hook matrix shows the underlying host support. Use
requires when the route needs a semantic capability rather than a particular host. targets
remains the host-specific escape hatch; an event route cannot declare both. If none of the
configured hosts satisfies a requirement, validation names the capability row and every host
that was considered.
Function and rendered events
Both forms use the same canonical event and host projection. A .ts handler returns
{ outcome: 'deny', reason }, { outcome: 'continue' }, or nothing. Continuing never grants
permission. A .tsx default handler returns Agent.* JSX as before.
The helper infers the canonical payload and family-specific outcomes; its event must agree with
the file path. A plain default function with the same context and result is also supported.
Use EventHandler<'tool/before'> when annotating that function.
Return updatedInput directly on a continuing tool/before handler to rewrite the pending
input. Return additionalContext on a continuing or denying handler for a short context
message. These results use the same host projection as JSX and start no render worker;
updatedInput must be a strict JSON object and cannot accompany a denial. Context channels
remain host-specific: Cursor drops context on tool/before but accepts it on tool/after;
Claude and Codex accept context on both. Use requires or targets for other event families
whose hosts differ. A rewrite preserves the host permission flow on Claude and Codex; Cursor
requires permission: 'allow' alongside updated_input.
For a cheap gate followed by JSX, put the view in a separate sibling module:
The handler and view are separate compiler entries. The hook loads the view only when the
handler returns ctx.render(); ordinary imports, local constants, and helper functions in the
handler work normally. The module argument must name the sibling .view.js file shown above.
Data must be strict JSON and reaches the view as renderInput; arbitrary paths and non-JSON
values fail closed. Put event configuration on the .ts handler. A .view.tsx file is support
code, not a second event. Denials require a nonempty reason and a deny-capable event family.
For complete copyable routes, examples/host-test/src/events/ uses lightweight events.*
handlers throughout; session/start.ts alone calls ctx.render('./start.view.js', data) because
its sibling view genuinely adds session context.
Execution trace events
The exported EventTraceEvent discriminated union gives developer tooling one stable,
payload-free vocabulary for the event kernel: handler.start, handler.outcome,
execute.start, providers.start / providers.finish, render.start / render.finish, and
terminal failure. The compiled hook wrapper emits the gate and execution boundaries.
Rendered handlers emit render boundaries; provider observations occur only when a handler
requests a provider. The handler outcome is continue, deny, or render.
Each frozen event carries one EventTraceExecution identity, a monotonic timestamp and sequence,
and only phase-specific metadata; failures use a bounded, stack-free EventTraceErrorSummary.
createEventTracer emits to its explicit observer or, without one, to the process-local observer
current at each emission (a no-op while none is installed), and isolates observer errors from
execution.
Developer tooling installs the process-local sink with installEventTraceObserver; the returned
disposer restores the previous sink. The trace types and helpers are exported from
agent-bundle and agent-bundle/api.
Rendered events default to standalone; opt into runtime: 'shared' to reuse a generated MCP server. Lightweight .ts handlers always run in the hook wrapper.
Request providers
Call await context.provider('writePolicy') in a function event, or
await (await agent()).provider('writePolicy') in JSX. The module loads only when requested;
concurrent callers share one promise, including a rejection, within that request. Generated
provider types infer keys and values. No config.providers declaration is needed. Cancellation
and expired request-context checks apply to provider access too. context.providers is removed;
use context.process for the framework-owned process identity. Pre-seeded values remain an
explicit runAgentRequest({ providers }) or test-harness input, read through provider(key).
The canonical payload
canonical.payload is the cross-host reading of the envelope for the route's family: the
fields at least two hosts report under their own names, toolName, toolInput, and
toolResponse on the tool families, sessionId (Claude and Codex session_id, Cursor
conversation_id), transcriptPath, cwd, prompt, agentId and agentType, reentry (Claude
and Codex stop_hook_active, Cursor loop_count > 0), and so on. Each field arrives as
{ value, nativeKey }: the decoded value beside the host key it was read from, so a route can tell
a mapped field from a missing one and still name the host's spelling in its own output. A field
the host did not send is undefined, never defaulted. Two readings go beyond the key: Cursor's
tool_output JSON string is parsed into toolResponse (and kept as the string when it is not
valid JSON), and Cursor's loop_count counter becomes the reentry boolean.
Type the route to its family, AgentEventRouteProps<'tool/after'>, and payload narrows to
that family's fields, so reading payload.reentry on a tool route is a compile error; the
generated .agent-bundle/routes.d.ts carries the same narrowing into renderRoute. A route
typed with the bare AgentEventRouteProps sees every field as optional. Anything the payload does
not model, Cursor's attachments, Claude's background_tasks, a host-only duration, is still
on native. The per-family, per-host table (field × host → native key) is the
Canonical payload fields section of the
generated events reference, rendered from the pinned capability tables; the runtime reads the same
table, exported as agentEventPayloadFields and agentEventPayloadNativeKeys.
Route-unit tests build the same props from a host envelope with createEventRouteInput from
agent-bundle/test, so a fixture exercises payload rather than hand-written identity:
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' | 'allow' | 'ask' | 'deny', reason?, updatedInput? }. continue, or
no value at all, is the pass-through answer: the route has no opinion, so no decision is
written and the host's normal permission flow applies. Only an explicit allow, ask, or
deny is projected as one. 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 decided tool/before becomes
hookSpecificOutput.permissionDecision: 'allow' | 'ask' | 'deny' on Claude and Codex but
{ permission: 'allow' | 'deny', … } on Cursor, which documents ask as unenforced and
therefore rejects it; a denied stop becomes { decision: 'block', reason } or Cursor's
followup_message. allow and ask are tool/before and model-switch/before decisions
(allow also answers permission/request on the user's behalf); every other family rejects them.
A decided model-switch/before becomes hookSpecificOutput.permissionDecision: 'allow' | 'ask' | 'deny' on Claude Code's PreModelSwitch (2.1.251 or later; ask is a refusal outside an
interactive /model), and model-switch/after observes the completed switch with
Agent.Context reaching Claude as additionalContext on the next request. Which families each
host supports is the generated Event and hook matrix.
updatedInput on tool/before rewrites the pending call and is independent of the decision. On
Claude and Codex a rewrite returned with continue carries no permissionDecision, so the host
evaluates its permission rules against the rewritten input; pair it with allow to auto-approve
the rewrite or with ask to show the rewritten input to the user. Cursor documents
updated_input only alongside a permission, so a rewrite is delivered as permission: 'allow'
there.
Event routes reach the twenty-two canonical families (session/end, prompt/submit,
compact/before, permission/request, model-switch/before, …); config-declared hooks cover
only the seven listed above.
A route or handler that throws produces no document and therefore no decision: the generated
wrapper writes to stderr, nothing to stdout, and exits 1, which every supported host documents as
a non-blocking error, the pending action proceeds as it would after a pass-through. The stderr
text is the thrown message for a runtime: 'standalone' route or a config-declared handler; a route
in the shared runtime reports Event route rendering failed., because the runtime answers
the wrapper with a generic runtime-failed error and keeps the original message in its own process.
A thrown tool/before does not deny; return outcome: 'deny' for that. The per-surface table is
in What happens when a route throws.
What is on the wire
Both shapes compile into a wrapper under the root hooks/ directory that the host invokes as
node "${CLAUDE_PLUGIN_ROOT}/hooks/<wrapper>.mjs" (or with its own root token). A hook that only
one selected host receives compiles to hooks/<name>.mjs; a hook shared by several selected hosts
compiles once per host as hooks/<name>.<host>.mjs, because each wrapper bakes in its host's
event spelling, PreToolUse / PostToolUse for Claude Code and Codex, camelCase preToolUse /
postToolUse for Cursor, and each host's document points at its own wrapper. Claude Code's
document is hooks/hooks.json, which it loads by convention without scanning hooks/ for other
documents, so the Claude manifest carries no hooks pointer: Claude Code reports a pointer at the
conventional file as a duplicate hooks file (manifest.hooks is for additional documents only).
Codex and Cursor manifests carry an explicit hooks pointer, so their documents sit beside their
manifests as .codex-plugin/hooks.json and .cursor-plugin/hooks.json. Each wrapper compares the
envelope's hook_event_name against the spelling baked for its own host, so
native hook_event_name must equal postToolUse on a Claude session means Claude's document is
invoking a Cursor wrapper, not that Claude sends camelCase. 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, or nothing at all. A route that
renders no decision and no context writes no output and exits 0, so the host proceeds
exactly as it would without the hook; on
tool/beforethat means its own permission prompt. A decision field is written only when the route returnedallow,ask, ordeny.
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.
The runtime side fails closed too. The generated MCP server owns the socket: it claims the
endpoint with an exclusive lock file, listens, and removes both again on shutdown only while
they are still the files it created. A shutdown that cannot remove its own socket path, or a
startup that cannot release its claim lock, reports an EventRuntimeTransportError with code
runtime-failed (Unable to remove the event runtime endpoint. / Unable to release the event runtime endpoint claim.) instead of exiting silently; the failed startup shuts its listener
down first, so it never leaves a live server behind the error. A stale socket or lock left by a
crashed process is recovered by the next server, which probes the endpoint and reclaims a
claim whose owner is provably dead.
Concurrent sessions. Hosts routinely run several sessions of one plugin from the same
install root, every Claude Code session Claude Desktop launches shares one
--plugin-dir, and Cursor imports Claude-installed plugins from that same cache, so several
generated MCP server processes with the same endpoint are normal. Exactly one of them owns
the socket at a time. A server that starts while another process owns the endpoint does
not exit: it comes up in the standby role, serves its own MCP session as usual, writes
agent-bundle event runtime: <socket> is owned by another process; standing by to stderr,
and probes the endpoint about once a second. The moment the owner exits, a standby takes the
socket over through the same claim-and-listen path an unowned start uses (and logs
… was released by its owner; took it over); if several stand by, the claim lock lets exactly
one win and the rest keep waiting. Hook wrappers need no change: while an owner is alive they
are answered as before, and during the takeover gap (at most about a second) they see
runtime-unavailable, which is exactly the case the fallback: 'standalone' route above
covers, a shared-only route exits nonzero for that one event. A takeover that fails for any
reason other than the socket still being owned (a socket directory it cannot write, a claim
that never clears) is reported on the server's stderr, one line per distinct failure, repeated
at most every 30 seconds, while the server keeps standing by. A startup that fails for any
other reason still fails closed, as does a stale socket the owner cannot remove.
Inside one composite root each selected host reaches that runtime through the first generated
MCP server its own MCP document lists, one process when every selected host lists the same
server, one per host otherwise. The runtime's socket is identified by the artifact alone (its
epoch and root directory, never the targets selection), and the invoking host rides on each
request, so whichever process owns the socket answers every selected host's wrappers.
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.
Native hook compatibility
The following config-based hooks are an adapter compatibility escape hatch. New framework handlers use the canonical event files above.
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.
Amp maps plain sessionStart to fire-and-forget session.start and plain beforeTool to
tool.call. Its agent.start and agent.end callbacks describe user turns, not subagents, so
plain agentStart/agentStop are not mapped. Event routes additionally map prompt/submit to
agent.start, stop to agent.end, and tool/after to tool.result. Amp explicitly has no
session.end; the adapter never manufactures one.
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.
Amp publishes no stable canonical built-in tool-name table. An Amp hook with selectors therefore
uses explicit amp:<native-name> selectors; canonical selectors fail rather than guessing a
native name.
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. HookHandler<E> from agent-bundle (also
re-exported from agent-bundle/config, where the hook is declared) types both sides for one
canonical event, so a satisfies clause is the whole contract:
event is HookEvent<'sessionStart'>, sessionId plus the optional cwd, source,
transcriptPath, and the other session facts a host may attach, and the return type is
HookResult<'sessionStart'>, so a sessionStart handler that returns
{ outcome: 'deny', reason: '…' } is a tsc error rather than a hook-time failure. The per-event
payloads (BeforeToolHookEvent carries toolName, toolInput, toolUseId; AfterToolHookEvent
adds toolResponse; StopHookEvent and AgentStopHookEvent carry stopHookActive;
AgentStartHookEvent carries agentId and agentType) are exported by name, a field is required
only when every host delivers it, and the handler's second argument (HookHandlerContext) names
the invoking target, its native event, and the validated native input verbatim for a handler
that needs a host-specific field. HookHandlerEventName is the set of events a config hook can be
declared for; each adapter maps only its documented subset, and workspaceOpen is served by an
event route instead. The types are the portable contract: a field they admit is
accepted by every host's wrapper and reaches that host's native output. Host-specific behaviour
stays outside them and is reachable only from an untyped result: additionalContext from
beforeTool (delivered by Claude and Codex; Cursor's preToolUse output carries only a permission
or an input rewrite), additionalContext from agentStart or agentStop (Claude and Codex,
Claude only), and a Cursor-only denial from agentStart. One runtime caveat the type cannot carry:
Cursor consumes an agentStop denial only for a subagent whose status is completed, so a
handler that also targets Cursor checks context.nativeInput.status before denying.
The generated wrapper still validates the result before projecting it into host-native output, the same rules, applied at hook time to JavaScript and prebuilt handlers too. 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. The typed contract rejects all three, and so do the Claude and Codex wrappers; Cursor's wrapper alone tolerates a denyingagentStartwith a reason, projected aspermission: 'deny'.beforeToolcannot stop, and cannot replace input while denying. A continuingbeforeToolhandler makes no permission decision: nothing is written to the host's decision field, so its normal permission prompt still applies, and aupdatedInputrewrite is evaluated by that flow against the rewritten input. Onlydenyis projected as a decision. To approve a call explicitly, use an event route and returnoutcome: 'allow'.stopaccepts onlycontinue, ordenywith a reason.agentStopcannot stop the parent flow or replace input, and only Claude Code carriesadditionalContextfrom it, Codex and Cursor reject it, and the typed contract excludes it everywhere.
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.
Amp request results
Amp's request callbacks use the exact pinned @ampcode/plugin unions. A tool/before event route
maps explicit allow, deny, and updatedInput to allow, reject-and-continue, and modify.
{ outcome: 'synthesize', output, exitCode? } becomes the native synthesized tool result.
Pass-through produces no wrapper decision; the generated inline tool.call handler returns the
API-required final allow only after every matching wrapper has passed.
A tool/after route may return status (done, error, or cancelled) with replacement
output/error; no value preserves the original result. A prompt/submit route's
Agent.Context becomes the appended agent.start message. A denied stop becomes
{ action: 'continue', userMessage }, which starts another turn, so the route must carry its own
guard when it can re-enter. No permission prompt, model choice, or continuation loop is added by
the adapter.
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 manifest's
executables.hooks[] rows, so hooks list and hooks simulate do not see them.
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. In the developer Workbench, select the event
under Application → Events / Hooks, choose a Canonical, Claude, Codex, or Cursor fixture,
and run it there. The route workspace shows the result first and keeps canonical/native codec
details in its projection inspector.
Next
- MCP servers and Apps: the other executable surface.
- Scripts and assets: code a hook or Skill can call.
- Compiler architecture: how event routes, config hooks, and host wrappers sit in the compiler pipeline.