Skip to main content

HookRegistry reference

A hook is a handler that fires at a named extension point inside AgentLoop or the channel gateway. The HookRegistry is the lookup table mapping hook names to handlers; DefaultHookRegistry is the in-memory implementation AgentLoop ships with.

Source

Interface in packages/types/src/hooks.ts. Implementation in packages/core/src/hook-registry.ts.

HookRegistry

Signature

import type { HookRegistry } from '@ethosagent/types';

export interface HookRegistry {
registerVoid<K extends keyof VoidHooks>(
name: K,
handler: (payload: VoidHooks[K]) => Promise<void>,
opts?: { pluginId?: string; failurePolicy?: 'fail-open' | 'fail-closed' },
): () => void;

registerModifying<K extends keyof ModifyingHooks>(
name: K,
handler: (payload: ModifyingHooks[K][0]) => Promise<Partial<ModifyingHooks[K][1]> | null>,
opts?: { pluginId?: string },
): () => void;

registerClaiming<K extends keyof ClaimingHooks>(
name: K,
handler: (payload: ClaimingHooks[K][0]) => Promise<ClaimingHooks[K][1]>,
opts?: { pluginId?: string },
): () => void;

fireVoid<K extends keyof VoidHooks>(
name: K,
payload: VoidHooks[K],
allowedPlugins?: string[],
): Promise<void>;

fireModifying<K extends keyof ModifyingHooks>(
name: K,
payload: ModifyingHooks[K][0],
allowedPlugins?: string[],
): Promise<ModifyingHooks[K][1]>;

fireClaiming<K extends keyof ClaimingHooks>(
name: K,
payload: ClaimingHooks[K][0],
allowedPlugins?: string[],
): Promise<ClaimingHooks[K][1]>;

unregisterPlugin(pluginId: string): void;
}

Methods

MethodReturnsDescription
registerVoid() => voidSubscribe to a fire-and-forget hook. The returned closure unregisters.
registerModifying() => voidSubscribe to a hook that may amend payloads. Handlers see the unmodified payload; results are merged.
registerClaiming() => voidSubscribe to a routing hook. The first handler to return { handled: true } wins.
fireVoidPromise<void>Fan out to every void handler in parallel via Promise.allSettled. Fail-open: rejected handlers are swallowed.
fireModifyingPromise<MergedResult>Run handlers sequentially. Merge results — first non-null value per key wins.
fireClaimingPromise<ClaimResult>Run handlers sequentially. Stop at the first { handled: true }; otherwise return { handled: false }.
unregisterPluginvoidRemove every handler registered with the given pluginId.

opts.pluginId

When a plugin registers a hook, the SDK passes opts.pluginId. AgentLoop's fire* calls receive allowedPlugins (derived from the active personality's plugins config); plugin-registered handlers fire only when their pluginId is in that list. Built-in handlers (no pluginId) always fire.

allowedPluginsEffect
undefinedNo filter — every handler fires.
[]Only built-in handlers fire.
['plugin-a']Built-in handlers plus handlers tagged plugin-a.

opts.failurePolicy

Void hooks only. Defaults to 'fail-open' (errors are logged, swallowed). 'fail-closed' propagates the rejection — reserve for hooks where a silent failure is unacceptable (auditing, billing). The default registry implementation logs and swallows regardless; 'fail-closed' is enforced by AgentLoop consumers that wrap the call.

Execution models

ModelMethodSemanticsUse for
VoidfireVoidParallel; Promise.allSettled; failures dropped.Logging, analytics, notifications, telemetry.
ModifyingfireModifyingSequential; merged results; first non-null key wins.Amending the prompt, overriding tool args.
ClaimingfireClaimingSequential; stop at first { handled: true }.Routing decisions: which platform handles this message.

See hook-execution-models for the design rationale.

Available hook points

Payload + result types live in packages/types/src/hooks.ts.

Void hooks

NamePayloadWhen it fires
session_startSessionStartPayloadFirst turn of a session, before any LLM call.
before_llm_callBeforeLLMCallPayloadImmediately before each LLM round-trip.
after_llm_callAfterLLMCallPayloadAfter each LLM round-trip completes.
after_tool_callAfterToolCallPayloadAfter each tool's execute returns.
tool_end_with_pathToolEndWithPathPayloadAfter a tool call whose args referenced a filesystem path.
agent_doneAgentDonePayloadAt the end of each turn, after the final done event.
message_receivedMessageReceivedPayloadWhen the gateway accepts an inbound channel message.
message_sentMessageSentPayloadWhen the gateway has dispatched an outbound message.
subagent_spawnedSubagentSpawnedPayloadAfter tools-delegation spawns a subagent session.
subagent_endedSubagentEndedPayloadWhen a subagent session ends.
after_ticket_revisionAfterTicketRevisionPayloadAfter kanban_complete was rejected by a before_ticket_complete verifier and the ticket was moved to needs_revision.

Modifying hooks

NamePayload → ResultWhen it fires
before_prompt_buildBeforePromptBuildPayloadBeforePromptBuildResultBefore the system prompt is assembled — handlers can prepend, append, or override.
before_tool_callBeforeToolCallPayloadBeforeToolCallResultBefore each tool's execute runs — handlers can amend args or set error to reject.
message_sendingMessageSendingPayloadMessageSendingResultBefore an outbound message hits an adapter — handlers can rewrite the message.
personality_switchedPersonalitySwitchedPayloadPersonalitySwitchedResultAfter /personality switches identities — handlers can substitute a different config.
subagent_spawningSubagentSpawningPayloadSubagentSpawningResultBefore a subagent session starts — handlers can rewrite prompt or pick a different personality.

Claiming hooks

NamePayload → ResultWhen it fires
inbound_claimInboundClaimPayloadInboundClaimResultGateway dispatch: which adapter owns this inbound message?
before_dispatchBeforeDispatchPayloadBeforeDispatchResultOutbound dispatch: short-circuit handlers (e.g. dedup) can mark a message as handled to suppress send.
before_ticket_completeBeforeTicketCompletePayloadBeforeTicketCompleteResultFired by kanban_complete before the running → done transition. A handler returning { handled: true, reason } rejects the completion; the ticket moves to needs_revision instead, then after_ticket_revision fires. Opt-in — with no handler registered, fireClaiming returns { handled: false } and completion proceeds.

Hook point payload reference

Key payload fields — see packages/types/src/hooks.ts for the full type definitions.

PayloadNotable fields
SessionStartPayloadsessionId, sessionKey, platform, personalityId?
BeforePromptBuildPayloadsessionId, personalityId?, history: StoredMessage[]
BeforeLLMCallPayloadsessionId, model, turnNumber
AfterLLMCallPayloadsessionId, text, usage: { inputTokens, outputTokens }
BeforeToolCallPayloadsessionId, toolCallId, toolName, args
AfterToolCallPayloadsessionId, toolName, result: ToolResult, durationMs
ToolEndWithPathPayloadsessionId, personalityId?, toolName, filePath, workingDir
AgentDonePayloadsessionId, text, turnCount, personalityId?, successfulToolCalls?, totalToolCalls?, toolNames?, initialPrompt?
MessageReceivedPayloadmessage: InboundMessage, sessionId?
MessageSendingPayloadchatId, message: OutboundMessage
InboundClaimPayloadmessage: InboundMessage
BeforeDispatchPayloadchatId, platform, text
PersonalitySwitchedPayloadsessionId, from?, to
SubagentSpawningPayloadparentSessionId, prompt, personalityId?
BeforeTicketCompletePayloadtaskId, summary, acceptanceCriteria?, autonomyTier?
AfterTicketRevisionPayloadtaskId, summary, acceptanceCriteria?, reason, assignee, autonomyTier?, successRatio?

Result types follow the same naming (BeforeToolCallResult, etc.) and only carry the fields a handler may override.

Notes

  • before_tool_call returning { error: '...' } does NOT skip the tool by itself — AgentLoop reads the result, adds the call to a rejected list, persists an is_error: true tool_result, and then excludes the call from executeParallel. Hooks must return the error; the loop enforces the skip.
  • The Anthropic message contract requires a tool_result block for every tool_use block in the preceding assistant message. Even rejected tool calls must produce a tool_result (with is_error: true) — AgentLoop handles this; hooks just return the rejection reason.
  • fireModifying merges results into an object where the first non-null value per key wins. Handlers that want to "win" should run early; ordering follows registration order. null results are skipped (use null as the "no opinion" return).
  • fireClaiming is fail-open: a thrown handler is skipped and iteration continues. Returning { handled: false } is the normal "pass" outcome.
  • unregisterPlugin removes every handler tagged with the plugin id from all three maps. The plugin loader uses this during deactivate.
  • The void-hook return closures (() => void) are useful in tests — collect them in an array and call all on teardown.
  • Channel-routing hooks (inbound_claim, before_dispatch) live in the gateway, not AgentLoop. They are part of the same HookRegistry instance so plugins can register against either.

Used by

ConsumerRole
packages/core/src/agent-loop.tsFires every hook in the turn cycle.
extensions/gateway/src/Fires inbound_claim, before_dispatch, message_received, message_sent.
extensions/tools-terminal/src/guard.tsRegisters a before_tool_call handler for command allowlisting.
packages/safety/channel/src/Channel-safety guards via before_dispatch and message_received.
packages/safety/injection/src/Injects classifier verdicts via before_prompt_build and before_tool_call.
extensions/skill-evolver/src/evolver.tsListens on agent_done to queue skill-candidate analysis.
extensions/observability-sqlite/src/Persists usage, tool_end, and agent_done via void hooks.
packages/plugin-sdk/src/index.tsEthosPluginApi.registerVoidHook / registerModifyingHook delegate here.

See also