Skip to content

Typed hook handler contract: export HookHandler<'sessionStart'> / HookEvent / HookResult instead of restating the interface in docs and every example #488

Closed

Description

@ScriptedAlchemy

Problem

A config-declared hook (hooks: { sessionStart: { handler: './src/hooks/session-start.ts' } }) is a plain function module, and the framework exports no type for either side of it: neither the event payload the generated wrapper hands the handler nor the four-key result the wrapper validates. Every author restates both by hand, so the documented contract lives in prose and in three copies of the same interface, and a handler that returns an illegal combination (e.g. sessionStart denying) is caught only at runtime by the wrapper.

Evidence (main 833e48fdc)

Three restatement sites, identical shape:

  • website/docs/en/guide/authoring/hooks.mdx:96-107 — the "handler contract" section itself hand-writes interface SessionStartEvent { cwd?, sessionId?, source?, transcriptPath? } and returns { additionalContext, outcome: 'continue' as const }.
  • examples/hooks-and-scripts/src/hooks/session-start.ts:1-15 — same interface, same result literal.
  • examples/mcp-app/src/hooks/session-start.ts:1-14 — same interface (minus transcriptPath), same result literal.

The framework already owns the contract it asks authors to guess:

  • packages/agent-bundle/src/adapters/hook-contract.ts:833-848 — the generated validateResult admits exactly outcome | reason | updatedInput | additionalContext and enforces the per-event rules (sessionStart/afterTool cannot deny/stop/replace input; beforeTool cannot stop; stop only continue-or-deny-with-reason; subagent events cannot stop or replace input).
  • packages/agent-bundle/src/adapters/hook-contract.ts:820-830 — the wrapper builds the native input from canonicalInput (sessionId, toolInput, toolName, toolUseId, toolResponse, transcriptPath, …), so the per-family fields are known at compile time.
  • packages/agent-bundle/src/core/types.ts:31-41 — canonicalHookEvents / CanonicalHookEvent already name the families.
  • packages/agent-bundle/src/index.ts / config/index.ts — export AgentBundleHookEntry, AgentBundleHookInput, CanonicalHookEvent, but nothing describing the handler function.

Found while auditing the examples for hand-rolled equivalents of public surface (#471): this was the one recurring restatement with no public type to point at.

Relationship to #466

#466 asks for a canonical per-family payload on AgentEventRouteProps for RSC event routes (src/events/**). This issue is the plain config hook handler surface (hooks.<event>.handler): its event parameter and its result type, including the per-event legality rules the wrapper enforces. They should share one per-family payload table, but #466 does not give a config hook handler a type, and a handler's result contract has no counterpart in #466.

Proposed shape

Export from agent-bundle (and re-export from agent-bundle/config, where handlers are declared):

type HookEvent<E extends CanonicalHookEvent> = /* per-family payload, e.g. */
  E extends 'sessionStart' ? { readonly cwd?: string; readonly sessionId?: string; readonly source?: string; readonly transcriptPath?: string }
  : E extends 'beforeTool' | 'afterTool' ? { readonly cwd?: string; readonly sessionId?: string; readonly toolName: string; readonly toolInput: JsonValue; readonly toolUseId?: string; /* afterTool: */ readonly toolResponse?: JsonValue }
  : /* stop, agentStart, agentStop … */;

type HookResult<E extends CanonicalHookEvent> = /* the legal subset per family, e.g. */
  E extends 'sessionStart' | 'afterTool' | 'agentStart' ? { readonly outcome?: 'continue'; readonly additionalContext?: string }
  : E extends 'beforeTool' ? { readonly outcome?: 'continue'; readonly additionalContext?: string; readonly updatedInput?: JsonObject } | { readonly outcome: 'deny'; readonly reason: string; readonly additionalContext?: string }
  : E extends 'stop' ? { readonly outcome?: 'continue' } | { readonly outcome: 'deny'; readonly reason: string }
  : /* agentStop … */;

type HookHandler<E extends CanonicalHookEvent> = (event: HookEvent<E>) => HookResult<E> | void | Promise<HookResult<E> | void>;

Authoring becomes export default ((event) => ({ … })) satisfies HookHandler<'sessionStart'>; with the illegal combinations rejected by tsc instead of by the wrapper at hook time. Keep the wrapper's runtime validation unchanged (JS authors, prebuilt handlers).

Acceptance

  • HookHandler, HookEvent, HookResult are public exports of agent-bundle and agent-bundle/config, and appear in the TypeDoc API pages.
  • hooks.mdx "The handler contract" (en + zh) shows the typed form and drops the hand-written interface; the four-key table stays as the runtime contract.
  • examples/hooks-and-scripts/src/hooks/session-start.ts and examples/mcp-app/src/hooks/session-start.ts use satisfies HookHandler<'sessionStart'> with no local interface.
  • A sessionStart handler returning { outcome: 'deny', reason: 'x' } fails pnpm typecheck in the examples; the same module still fails the wrapper at runtime.
  • The per-family field set is derived from the same source the adapters use to encode canonicalInput, so it cannot drift from hook-contract.ts.

Activity

  1. added 5 commits that reference this issue on Sep 4, 2026
    b14ed33
    8fdf1f9
    528b16c
    2dce21e
    59a39f2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmeta-frameworkAgent Bundle compiler-coupled meta-framework

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions