Hooks

Twenty-two hook points fire as a turn runs. Hooks watch, replace content, or change the flow, with the same names and rules as Hermes.

Hooks are how plugins take part in a turn without changing the loop. CommonBill records costs from a hook. The dashboard’s live feed is a hook. The approval policy is a hook. Hook names and behavior follow Hermes Agent’s plugin hooks, so Hermes plugins port over without surprises.

Three kinds

Kind Can return Used for
directive Block, AskUser, Modify Changing the flow: skip a message, block a tool, ask for approval, add context
transform Modify with a replacement Replacing content: a tool result, the final reply
observer Anything; it is ignored Watching: logging, metrics, cost
Rust
pub enum HookResult {
    /// No change.
    Continue,
    /// Directive: merged into the payload. Transform: replaces the content.
    Modify(Value),
    /// Stop, with a reason.
    Block(String),
    /// Pause until a person answers (`pre_tool_call` only).
    AskUser(String),
}

Hook points

To see these fire in order for one message, see Message Flow.

Hook Fires Kind Payload
pre_gateway_dispatch Each inbound message, at ingress directive profile, source, session_id, text
post_gateway_admission After the session is resolved directive session_id, platform, profile, text
on_session_start First turn of a new session observer model
pre_llm_call Once per turn, before the loop directive user_message, is_first_turn, model
pre_api_request Each model call observer provider, model, iteration, message_count
post_api_request Each successful model call observer provider, model, usage, stop
api_request_error Each failed model call observer model, status_code, retryable, error
transform_api_error_classification Each failed model call transform Same as above
on_stream_delta Reserved: no streaming yet observer —
pre_tool_call Each tool call directive tool_name, args, risk, call_id
post_tool_call Each result, including blocked and failed calls observer tool_name, args, result, is_error
transform_tool_result After post_tool_call transform tool_name, result
pre_approval_request Before asking a person observer The approval request
post_approval_response After the answer observer approval_id, tool_name, choice
subagent_start Each child agent starts observer child_role, child_goal, parent ids
subagent_stop Each child agent ends observer child_status, child_summary
transform_llm_output Before the reply is returned transform response_text, model
post_llm_call Once per completed turn observer assistant_response, model
post_compaction Reserved: no compaction yet observer —
agent_loop_stopped After an interrupt observer reason
on_session_end End of every turn observer completed, interrupted, model
on_session_finalize Reserved: session teardown observer —

Every event also carries the turn’s TurnInfo in event.turn, except the two gateway hooks, which fire before a turn exists.

What each directive can do

  • pre_gateway_dispatch: Block skips the message. Modify({"text": "..."}) rewrites it.
  • post_gateway_admission: Block means the hook handled the message; no turn runs.
  • pre_llm_call: Modify({"context": "..."}) adds context for this turn only. It fails open.
  • pre_tool_call: Block stops the tool, AskUser asks for approval, Modify changes the arguments. It fails closed.

The rules

These come straight from Hermes:

  1. Hooks run in registration order.
  2. The first Block wins, even over an earlier AskUser.
  3. Directive Modify objects are shallow-merged in order. For transforms, the first Modify wins.
  4. A result a point doesn’t accept is logged and ignored.
  5. A hook that errors or takes longer than 60 seconds blocks the action on pre_tool_call and is skipped everywhere else. Only pre_tool_call fails closed.

In CommonOS, the order is approval_policy, then live_feed, then plugin hooks in the order listed in commonos.toml.

Write a hook

An observer that prints each model call’s token usage:

Rust
struct Usage;

#[async_trait]
impl Hook for Usage {
    fn name(&self) -> &str {
        "usage"
    }
    fn points(&self) -> &[HookPoint] {
        &[HookPoint::PostApiRequest]
    }
    async fn call(&self, event: &HookEvent) -> anyhow::Result<HookResult> {
        println!("model call: {}", event.payload["usage"]);
        Ok(HookResult::Continue)
    }
}

A directive that stops client agents from delegating:

Rust
struct NoDelegationForClients;

#[async_trait]
impl Hook for NoDelegationForClients {
    fn name(&self) -> &str {
        "no_delegation_for_clients"
    }
    fn points(&self) -> &[HookPoint] {
        &[HookPoint::PreToolCall]
    }
    async fn call(&self, event: &HookEvent) -> anyhow::Result<HookResult> {
        let for_client = event
            .turn
            .as_ref()
            .is_some_and(|turn| turn.tags.iter().any(|tag| tag.key() == "client"));
        if for_client && event.payload["tool_name"] == "delegate_task" {
            return Ok(HookResult::Block("client agents work alone".into()));
        }
        Ok(HookResult::Continue)
    }
}

The model gets Blocked: client agents work alone as the tool’s result and carries on without it.

Register hooks on a HookBus when you embed CommonAgent, or with ctx.hook(...) from a plugin.

Edit this page on GitHub

Esc