Hooks & Events
Every run emits a stream of events — inference started, tool called, tool completed, run finished. A hook is how your app reacts to them: audit a refund, notify a channel, count tokens, alert on failures. A hook is a subclass of Protege::Hook, discovered automatically and wired to the event bus at boot.
Anatomy
RefundAuditHook records every refund Sundae issues:
# app/hooks/refund_audit_hook.rb
class RefundAuditHook < Protege::Hook
on Protege::ToolCallCompletedEvent do |event|
next unless event.tool_call&.name == "process_refund"
Protege.configuration.logger.info(
"[audit] #{event.persona&.name} refunded via #{event.tool_call.name}: #{event.result&.data}"
)
end
endon(*EventClasses) { |event| ... }declares a handler. Name one event class or several (they share the block); declareonmore than once for different events. Passing an unknown event class raises at load time, so a typo fails fast.- The block receives the event instance and runs in the context of a hook instance, so private helper methods on the hook are available inside it.
- Handlers are isolated: an exception in a hook is logged and swallowed, never propagated. A broken audit hook can't break Sundae's reply.
Nothing wires a hook in — like tools, discovery is by subclass. Define the class and its handlers are live.
Stateful hooks
By default a hook gets a fresh instance per event, so instance variables don't survive between handlers. Declare stateful to reuse one instance per run (keyed by the run's correlation id), so state set in an early handler is visible to a later one — useful for accumulating across a run:
class TokenBudgetHook < Protege::Hook
stateful
on Protege::InferenceStartedEvent { @tools = 0 }
on Protege::ToolCallCompletedEvent { @tools += 1 }
on Protege::InferenceCompletedEvent do |event|
Protege.configuration.logger.info("[run] #{event.persona&.name} used #{@tools} tools")
end
endThe instance is evicted when the run ends (on a completed, failed, or max-turns-reached event), so state never leaks between runs.
The events
Every event carries the run's correlation id, so you can tie an event back to a specific run. Each also exposes a few nil-safe readers for its payload:
| Event | Readers | Fires when |
|---|---|---|
InferenceStartedEvent | persona, message | A run begins |
InferenceGeneratedEvent | turn, request, response, model, settings | A turn is generated — only when tracing is on |
InferenceCompletedEvent | persona, message, result | A run finishes successfully |
InferenceFailedEvent | persona, message, error | A run raises |
InferenceMaxTurnsReachedEvent | persona, turn, tool_calls | The turn budget is exhausted |
ToolCallsReceivedEvent | persona, tool_calls | The model asks for one or more tools |
ToolCallStartedEvent | persona, tool_call | A single tool call begins |
ToolCallCompletedEvent | persona, tool_call, result | A tool call succeeds |
ToolCallFailedEvent | persona, tool_call, error | A tool call fails |
InferenceChunkEvent | persona, thread, chunk | A streamed token arrives (console runs only) |
LoopRunEnqueuedEvent | name, persona_name | A scheduled run is enqueued |
LoopRunStartedEvent | name, persona_name | A scheduled run begins |
LoopRunCompletedEvent | name, persona_name | A scheduled run finishes |
LoopRunFailedEvent | name, persona_name, error | A scheduled run fails |
Readers are nil-safe by design — a scheduled run has no inbound message, for instance — so guard on what a given event actually carries.
Events power the engine too
Hooks are one consumer of the event stream, not the only one. The same events drive the live introspection panel (the per-thread inference + tool-call feed) and, when enabled, tracing. Writing a hook is joining a bus the engine already runs — which is why your reactions stay decoupled from the inference loop, and why a hook can never slow or break a run.
Next
- Observing your agent — the introspection panel and tracing, both built on these events.
- Tools — the actions whose calls these events describe.

