Repository navigation
Typed hook handler contract: export HookHandler<'sessionStart'> / HookEvent / HookResult instead of restating the interface in docs and every example #488
Description
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-writesinterface 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 (minustranscriptPath), 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 generatedvalidateResultadmits exactlyoutcome | reason | updatedInput | additionalContextand enforces the per-event rules (sessionStart/afterToolcannot deny/stop/replace input;beforeToolcannot stop;stoponly 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 fromcanonicalInput(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/CanonicalHookEventalready name the families.packages/agent-bundle/src/index.ts/config/index.ts— exportAgentBundleHookEntry,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,HookResultare public exports ofagent-bundleandagent-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.tsandexamples/mcp-app/src/hooks/session-start.tsusesatisfies HookHandler<'sessionStart'>with no local interface.- A
sessionStarthandler returning{ outcome: 'deny', reason: 'x' }failspnpm typecheckin 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 fromhook-contract.ts.
Activity
feat(hooks): export the typed hook handler contract (HookHandler, Hoo…
feat(hooks): export the typed hook handler contract (HookHandler, Hoo…
feat(hooks): export the typed hook handler contract (HookHandler, Hoo…
feat(hooks): export the typed hook handler contract (HookHandler, Hoo…
feat(hooks): export the typed hook handler contract (HookHandler, Hoo…