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 |
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:Blockskips the message.Modify({"text": "..."})rewrites it.post_gateway_admission:Blockmeans 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:Blockstops the tool,AskUserasks for approval,Modifychanges the arguments. It fails closed.
The rules
These come straight from Hermes:
- Hooks run in registration order.
- The first
Blockwins, even over an earlierAskUser. - Directive
Modifyobjects are shallow-merged in order. For transforms, the firstModifywins. - A result a point doesn’t accept is logged and ignored.
- A hook that errors or takes longer than 60 seconds blocks the action on
pre_tool_calland is skipped everywhere else. Onlypre_tool_callfails 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:
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:
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.