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.
  • English
  • 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

    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.

    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:

    // src/hooks/session-start.ts
    interface SessionStartEvent {
      readonly cwd?: string;
      readonly sessionId?: string;
      readonly source?: string;
      readonly transcriptPath?: string;
    }
    
    export default (event: SessionStartEvent) => ({
      additionalContext: `Run verify-release from ${event.cwd ?? process.cwd()} before publishing.`,
      outcome: 'continue' as const,
    });

    The generated wrapper validates the result before projecting it into host-native output. Exactly four keys are accepted, and each one is checked:

    KeyContract
    outcomecontinue, deny, or stop.
    reasonA non-empty string, valid only when denying a beforeTool, stop, or agentStop hook. 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.
    • beforeTool cannot stop, and cannot replace input while denying.
    • stop accepts only continue, or deny with a reason.
    • agentStop cannot 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:

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

    // 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
      targets: ['claude', 'codex'],
      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) {
      // canonical.provenance = { host, hostContractRevision, nativeEvent, source: 'native' }
      return (
        <Agent.Result>
          <Agent.Context>{`Recorded an edit 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, 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:

    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. On tool/before the wrapper always answers with an explicit allow-or-deny decision in the host's own field names (allow unless 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:

    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. The developer Workbench exposes the same playground with the raw stdout, stderr, and outcome for each run.

    Next