Skip to content

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

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-41canonicalHookEvents / 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

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