For AI agents: the complete documentation index is available at https://scriptedalchemy.github.io/agent-bundle/llms.txt, the full documentation bundle is available at https://scriptedalchemy.github.io/agent-bundle/llms-full.txt, and this page is available as Markdown at https://scriptedalchemy.github.io/agent-bundle/guide/authoring/hooks.md.
Skip to main content
  • English
  • 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:

    // src/events/tool/after.tsx
    import { Agent } from '@agent-bundle/runtime';
    import type { AgentEventRouteConfig, AgentEventRouteProps } from 'agent-bundle';
    
    export const config = {
      runtime: 'standalone', // 'shared' renders inside the generated MCP server process
      requires: ['events.toolAfter.context'],
      timeoutMs: 30_000, // budget inside the host's own native deadline
      tools: ['file.write'], // canonical selector -> per-host native matcher
    } satisfies AgentEventRouteConfig;
    
    export default async function AfterFileEdit(
      { canonical, native, signal }: AgentEventRouteProps<'tool/after'>,
    ) {
      // canonical.provenance = { host, hostContractRevision, nativeEvent, source: 'native' }
      // canonical.payload.toolName = { value: 'Write', nativeKey: 'tool_name' } on every host
      const tool = canonical.payload.toolName?.value ?? 'a tool';
      return (
        <Agent.Result>
          <Agent.Context>
            {`Recorded an edit by ${tool}, reported by ${canonical.provenance.host}.`}
          </Agent.Context>
        </Agent.Result>
      );
    }

    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.

    // src/events/tool/before.ts
    import { events } from 'agent-bundle/routes';
    
    export default events.tool.before({}, async ({ canonical, provider }) => {
      if (canonical.payload.toolName?.value === 'Write') {
        return { outcome: 'deny', reason: await provider('writePolicy') };
      }
    });

    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.

    export default events.tool.before({}, () => ({
      outcome: 'continue',
      updatedInput: { command: 'cargo check --locked' },
      additionalContext: 'Checking with the lockfile.',
    }));

    For a cheap gate followed by JSX, put the view in a separate sibling module:

    // src/events/tool/before.ts
    import { events } from 'agent-bundle/routes';
    
    export default events.tool.before({}, async (ctx) => {
      if (ctx.canonical.payload.toolName?.value !== 'Write') return;
      return ctx.render('./before.view.js', { message: 'A write is about to run.' });
    });
    // src/events/tool/before.view.tsx
    import { Agent } from '@agent-bundle/runtime';
    import type { AgentEventRouteProps } from 'agent-bundle';
    
    export default async function BeforeWrite(
      { renderInput }: AgentEventRouteProps<'tool/before', { message: string }>,
    ) {
      return <Agent.Result><Agent.Context>{renderInput?.message}</Agent.Context></Agent.Result>;
    }

    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:

    import { createEventRouteInput, renderRoute } from 'agent-bundle/test';
    
    const rendered = await renderRoute('event:tool/after', {
      input: createEventRouteInput('tool/after', claudePostToolUseEnvelope, {
        host: 'claude',
      }),
    });

    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:

    1. 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.
    2. Wrapper → runtime (IPC). One newline-delimited request, protocolVersion, artifactEpoch, event, hostContractRevision, target, and the validated native envelope, over a per-user Unix socket (a named pipe on Windows). The socket directory is 0700, the socket 0600, 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 against timeoutMs (default 5000 ms).
    3. Runtime renders. The server rejects epoch mismatches and malformed messages, builds the canonical props, and renders the route component through the react-server worker inside the request context.
    4. Runtime → wrapper. One JSON reply: { status: 'ok', output }, or { status: 'error', code } where code is epoch-mismatch, invalid-message, or runtime-failed.
    5. 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/before that means its own permission prompt. A decision field is written only when the route returned allow, ask, or deny.

    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

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.hooks?: Partial<Record<"sessionStart" | "beforeTool" | "afterTool" | "stop" | "agentStart" | "agentStop" | "workspaceOpen", AgentBundleHookInput>> | undefined
    hooks
    : {
    sessionStart?: AgentBundleHookInput | undefined
    sessionStart
    : {
    AgentBundleHookEntry.handler: string | AgentBundlePrebuiltEntry
    handler
    : './src/hooks/session-start.ts' },
    },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.name: string
    name
    : 'hooks-and-scripts',
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Release preparation helpers.',
    },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'codex', 'claude'],
    });

    Each event accepts a bare handler path, an entry object, or an array of either when one event needs several handlers:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.hooks?: Partial<Record<"sessionStart" | "beforeTool" | "afterTool" | "stop" | "agentStart" | "agentStop" | "workspaceOpen", AgentBundleHookInput>> | undefined
    hooks
    : {
    beforeTool?: AgentBundleHookInput | undefined
    beforeTool
    : [
    './src/hooks/audit.ts', {
    AgentBundleHookEntry.handler: string | AgentBundlePrebuiltEntry
    handler
    : './src/hooks/guard-writes.ts',
    AgentBundleHookEntry.targets?: readonly string[] | undefined
    targets
    : ['claude'],
    AgentBundleHookEntry.timeout?: number | undefined

    Native hook timeout in seconds. Omit it to use the selected host's default.

    timeout
    : 10,
    AgentBundleHookEntry.tools?: readonly string[] | undefined
    tools
    : ['file.write', 'shell'],
    }, ], },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Guarded tooling.',
    AgentBundlePluginConfig.name: string
    name
    : 'guarded' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['portable', 'claude'],
    });
    FieldMeaning
    handlerThe handler module, or a prebuilt marker naming an already-built file inside a declared payload.
    targetsRestrict the hook to specific targets. Defaults to every selected target that supports hooks.
    timeoutNative hook timeout in seconds. Omit it to use the selected host's default.
    toolsTool selectors that scope when the hook fires.
    argsExtra command arguments. Only prebuilt handlers accept arguments, and only shell-safe strings.

    Canonical events

    EventFires when
    sessionStartA session begins.
    beforeToolBefore a tool call is dispatched.
    afterToolAfter a tool call returns.
    stopThe agent is about to stop.
    agentStartA subordinate agent starts.
    agentStopA subordinate agent stops.
    workspaceOpenA workspace is opened. Declare it as an event route (src/events/workspace/open.tsx), not a config hook: no target maps the plain hook, so a config-declared workspaceOpen is a build error on every host, including Cursor, which supports the event only through the route form.

    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:

    // src/hooks/session-start.ts
    import type { HookHandler } from 'agent-bundle';
    
    export default ((event) => ({
      additionalContext:
        `Run verify-release from ${event.cwd ?? process.cwd()} before publishing.`,
      outcome: 'continue',
    })) satisfies HookHandler<'sessionStart'>;

    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:

    KeyContract
    outcomecontinue or deny. 'stop' is not a legal result on any event: HookHandler<E> excludes it and the generated wrapper rejects it at run time.
    reasonA non-empty string, valid only when denying a beforeTool, stop, or agentStop hook (Cursor's wrapper also takes one on the agentStart denial it alone admits). Denying one of those without a reason fails.
    additionalContextA string appended to the agent's context.
    updatedInputReplacement input for the pending call.

    Per-event restrictions are enforced, not documented-and-hoped:

    • sessionStart, afterTool, and agentStart cannot 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 denying agentStart with a reason, projected as permission: 'deny'.
    • beforeTool cannot stop, and cannot replace input while denying. A continuing beforeTool handler makes no permission decision: nothing is written to the host's decision field, so its normal permission prompt still applies, and a updatedInput rewrite is evaluated by that flow against the rewritten input. Only deny is projected as a decision. To approve a call explicitly, use an event route and return outcome: 'allow'.
    • stop accepts only continue, or deny with a reason.
    • agentStop cannot stop the parent flow or replace input, and only Claude Code carries additionalContext from 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:

    import { 
    const defineConfig: (config: AgentBundleConfig | ConfigFactory) => AgentBundleConfig | ConfigFactory
    defineConfig
    } from 'agent-bundle/config';
    export default
    function defineConfig(config: AgentBundleConfig | ConfigFactory): AgentBundleConfig | ConfigFactory
    defineConfig
    ({
    AgentBundleConfig.hooks?: Partial<Record<"sessionStart" | "beforeTool" | "afterTool" | "stop" | "agentStart" | "agentStop" | "workspaceOpen", AgentBundleHookInput>> | undefined
    hooks
    : {
    afterTool?: AgentBundleHookInput | undefined
    afterTool
    : [{
    AgentBundleHookEntry.args?: readonly string[] | undefined

    Extra command arguments. Only prebuilt handlers accept arguments.

    args
    : ['--host', 'claude'],
    AgentBundleHookEntry.handler: string | AgentBundlePrebuiltEntry
    handler
    : {
    AgentBundlePrebuiltEntry.prebuilt: string
    prebuilt
    : './dist/runtime/hook/index.js' },
    AgentBundleHookEntry.targets?: readonly string[] | undefined
    targets
    : ['claude'],
    AgentBundleHookEntry.tools?: readonly string[] | undefined
    tools
    : ['file.write'],
    }], },
    AgentBundleConfig.payload?: Readonly<Record<string, AgentBundlePayloadInput>> | undefined
    payload
    : {
    runtime: {
        source: string;
        targets: string[];
    }
    runtime
    : {
    AgentBundlePayloadEntry.source: string
    source
    : './dist/runtime',
    AgentBundlePayloadEntry.targets?: readonly string[] | undefined
    targets
    : ['claude'] } },
    AgentBundleConfig.plugin: AgentBundlePluginConfig
    plugin
    : {
    AgentBundlePluginConfig.description?: string | undefined
    description
    : 'Self-compiled runtime.',
    AgentBundlePluginConfig.name: string
    name
    : 'prebuilt-plugin' },
    AgentBundleConfig.targets?: string[] | undefined
    targets
    : ['claude'],
    });

    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:

    npx agent-bundle inspect --root . --hooks
    npx agent-bundle hooks list --artifact artifact --target claude
    npx agent-bundle hooks simulate --artifact artifact --target claude \
      --hook session-start-session-start-7ab7e8a5 \
      --input '{"sessionId":"local","transcriptPath":"/tmp/transcript.jsonl",
        "cwd":"/tmp/project","source":"startup"}'

    --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