Skip to content

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:

ruby
# 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
end
  • on(*EventClasses) { |event| ... } declares a handler. Name one event class or several (they share the block); declare on more 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:

ruby
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
end

The 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:

EventReadersFires when
InferenceStartedEventpersona, messageA run begins
InferenceGeneratedEventturn, request, response, model, settingsA turn is generated — only when tracing is on
InferenceCompletedEventpersona, message, resultA run finishes successfully
InferenceFailedEventpersona, message, errorA run raises
InferenceMaxTurnsReachedEventpersona, turn, tool_callsThe turn budget is exhausted
ToolCallsReceivedEventpersona, tool_callsThe model asks for one or more tools
ToolCallStartedEventpersona, tool_callA single tool call begins
ToolCallCompletedEventpersona, tool_call, resultA tool call succeeds
ToolCallFailedEventpersona, tool_call, errorA tool call fails
InferenceChunkEventpersona, thread, chunkA streamed token arrives (console runs only)
LoopRunEnqueuedEventname, persona_nameA scheduled run is enqueued
LoopRunStartedEventname, persona_nameA scheduled run begins
LoopRunCompletedEventname, persona_nameA scheduled run finishes
LoopRunFailedEventname, persona_name, errorA 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.