0075 - Hook
- Feature Name: Hook Lifecycle
- Start Date: 2026-03-15
- Discussion: #75
- Crates: core, runtime, daemon
Updated by 0162 (Hook-as-plugin) and 0189 (Policy at the Edge). Hooks now own their tools (per-hook
schema()+dispatch()) rather than registering through a sharedToolRegistry.on_before_runwas removed and replaced byon_register_agent/on_unregister_agentfor state tracking.preprocessreturnsOption<String>(None = pass through).
Summary
The Hook trait is the central extensibility point for agent lifecycle. Each subsystem (skills, memory, MCP, scoping, OS tools) implements Hook to provide schemas, dispatch tool calls, contribute system-prompt fragments, observe events, preprocess messages, and track per-agent state. The runtime composes hooks behind a single facade and never reaches into a subsystem directly.
Motivation
When the runtime was split out of the daemon (#75), a clean interface was needed between the runtime (which executes agents) and the hook implementations (which customize them). The runtime must not know about skills, memory, MCP, or daemon infrastructure. It only knows it has a Hook and calls its methods at the right times.
This separation enables two modes: the daemon (full hook with skills, MCP, memory, event broadcasting) and embedded use (no hook, or a minimal one).
Design
The trait
#![allow(unused)]
fn main() {
pub trait Hook: Send + Sync {
fn schema(&self) -> Vec<Tool> { vec![] }
fn system_prompt(&self) -> Option<String> { None }
fn on_build_agent(&self, config: AgentConfig) -> AgentConfig { config }
fn on_register_agent(&self, name: &str, config: &AgentConfig) {}
fn on_unregister_agent(&self, name: &str) {}
fn on_event(&self, agent: &str, conversation_id: u64, event: &AgentEvent) {}
fn preprocess(&self, agent: &str, content: &str) -> Option<String> { None }
fn scoped_tools(&self, config: &AgentConfig) -> (Vec<String>, Option<String>);
fn dispatch<'a>(&'a self, name: &'a str, call: ToolDispatch) -> Option<ToolFuture<'a>> { None }
}
}
All methods have default no-op implementations. () implements Hook.
Lifecycle points
schema — the tools this hook owns. The composite hook unions every sub-hook’s schema() to expose the runtime-wide tool set. There is no shared ToolRegistry — each hook is the source of truth for its tools.
system_prompt — optional fragment appended to agent system prompts at build time. Used by hooks that always inject standing instructions (e.g. memory’s behavioural guidance).
on_build_agent — called when an agent is registered. Receives the agent config, returns a possibly-modified config. The composite implementation chains: environment block (OS, shell, platform), per-hook system_prompt() fragments, resource hints (available MCP servers, available skills), and a <scope> block when the agent restricts its tools/skills.
on_register_agent / on_unregister_agent — called when an agent is added to or removed from the runtime registry. Hooks that track per-agent state (scopes, descriptions, MCP fingerprint refcounts) record and clean up here. Symmetric: by the time Runtime::agent() returns the new agent, hook state is in place; by the time the agent is invisible, hook state has been dropped.
preprocess — called before a user message enters the conversation. Returns Some(modified) to transform, None to pass through. Slash-command resolution (/skill-name args → wrapped <skill> body) lives here.
scoped_tools — given an agent config, returns the subset of this hook’s tools the agent may call, plus an optional <scope> prompt line. Default: include every tool from schema() with no scope line. Hooks override to gate inclusion on AgentConfig fields (e.g. memory only when enabled, skill tool only when the agent has a skills list).
dispatch — called when an agent issues a tool call. Returns Some(future) if this hook owns the tool name, None otherwise. The composite walks hooks in order and dispatches to the first owner.
on_event — called after each agent step. Receives every AgentEvent (text deltas, tool calls, completions). DaemonHook uses this to broadcast events to subscribers.
Composition
DaemonHook is the daemon’s composite hook. It holds a map of named sub-hooks (skill, memory, mcp, os, delegate, ask_user) and orchestrates them: schema() unions, dispatch() walks the registered owners, on_build_agent chains the system-prompt fragments, on_register_agent/on_unregister_agent fan out, on_event broadcasts.
For embedded use, () implements Hook as a full no-op so the runtime works without any subsystems.
Tool dispatch
Dispatch is part of the Hook trait. When an agent produces a tool call, the runtime walks the composite hook and calls dispatch(name, call) until one returns Some(future). Each hook owns the tools it declared via schema(); nothing else can claim them. Scope enforcement happens at the composite layer before walking sub-hooks.
Dispatch returns Result<String, String>. Ok carries normal tool output; Err carries a handler-reported failure (invalid args, not found, scope rejection, operation error) or a dispatch-level failure (no tool sender, tool channel closed, reply dropped). The same convention applies to server-specific tools owned by the daemon (ask_user, delegate). The distinction propagates to the AgentEvent::ToolResult.output field and to the wire protocol’s is_error flag so UIs can render errors distinctly and agents can make retry decisions without string-matching error messages. HistoryEntry::tool still stores the inner string regardless of the arm — the LLM wire format has no is_error field, so the model sees the text either way.
When an agent step produces multiple tool calls, the runtime dispatches them concurrently via FuturesUnordered; tool results are appended to history in the original call order (positional pairing with tool_calls is load-bearing for providers that correlate by index), but the ToolResult events fire in completion order so UIs show fast tools immediately without waiting on slow siblings.
Alternatives
Separate traits per concern. One trait for prompt building, one for tools, one for events. Rejected because they always compose together and the single trait is simpler to implement and reason about.
Closure-based hooks. Pass lambdas instead of a trait. Rejected because the hook needs shared state (skill registry, MCP connections, memory) that closures make awkward.
Unresolved Questions
- Should
on_build_agentbe async to support hooks that need I/O during agent construction? - Should
preprocesssupport returning multiple messages (e.g. for multi-skill invocation)?