Skip to main content
reference

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.
ticket_claimedTicketClaimedPayloadAfter a task is claimed and moves to running.
ticket_blockedTicketBlockedPayloadAfter kanban_block commits the block transition.
ticket_completedTicketCompletedPayloadAfter kanban_complete's running -> done transition commits — the successful-completion path only.
ticket_stale_reclaimedTicketStaleReclaimedPayloadAround Dispatcher.tick()'s reclaimTask call, when a stuck running task is re-queued to ready (owner gone or heartbeat stale).
ticket_updatedTicketUpdatedPayloadOn a ticket field mutation outside the status/block/complete lifecycle — today, kanban_assign and KanbanService.assign.
dispatch_tickDispatchTickPayloadAt the bottom of Dispatcher.tick(), but only when claimedCount > 0 || reclaimedCount > 0 — an idle tick does not fire it.

Modifying hooks​

NamePayload → ResultWhen it fires
before_prompt_buildBeforePromptBuildPayload → BeforePromptBuildResultBefore the system prompt is assembled — handlers can prepend, append, or override.
before_tool_callBeforeToolCallPayload → BeforeToolCallResultBefore each tool's execute runs — handlers can amend args or set error to reject. Amended args fire the hook a second time on the amended args, with rewrittenFrom set to the originals, so guard handlers judge what will run. That second fire is judge-only: an error still refuses the call, any args it returns are ignored, and the args that run are the ones it judged (enforceBeforeToolCall, packages/core/src/agent-loop/stages/per-call-enforcement.ts). An approval surface that asked on the first fire asks again on the second, and the second prompt says the arguments were rewritten (REWRITTEN_ARGS_NOTE, packages/wiring/src/approval-seams.ts).
message_sendingMessageSendingPayload → MessageSendingResultBefore an outbound message hits an adapter — handlers can rewrite the message.
personality_switchedPersonalitySwitchedPayload → PersonalitySwitchedResultAfter /personality switches identities — handlers can substitute a different config.
subagent_spawningSubagentSpawningPayload → SubagentSpawningResultBefore a subagent session starts — handlers can rewrite prompt or pick a different personality.

Claiming hooks​

NamePayload → ResultWhen it fires
inbound_claimInboundClaimPayload → InboundClaimResultGateway dispatch: which adapter owns this inbound message?
before_dispatchBeforeDispatchPayload → BeforeDispatchResultOutbound dispatch: short-circuit handlers (e.g. dedup) can mark a message as handled to suppress send.
before_ticket_completeBeforeTicketCompletePayload → BeforeTicketCompleteResultFired 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 in single-personality deployments — with no handler registered, fireClaiming returns { handled: false } and completion proceeds. In team deployments (config teamName set) a default verifier handler (eval-harness scoring pass) is wired since Phase 7, making the review state non-skippable there.

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, rewrittenFrom? (set only on the re-judge fire)
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​