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.agent&.name} refunded via #{event.tool_call.name}: #{event.result&.data}"
    )
  end
end

Nothing wires a hook in — like tools, discovery is by subclass. Define the class and its handlers are live.

The handler contract

on is the whole declaration surface, and its rules are exact:

ruby
on(*event_classes) { |event| ... }   # one or several Protege::Event subclasses
  • Arguments — each must be one of the event classes below. An unknown class raises ArgumentError at load time, so a typo fails fast, at boot rather than mid-run.
  • The block takes exactly one argument: a frozen instance of the specific event class that fired. You read its payload through the named readers in the catalog below (all nil-safe), or event[:key] for anything without one. event.correlation_id is on every event.
  • Binding — the block runs via instance_exec on a hook instance, so private helper methods on the hook (and, for stateful hooks, instance variables) are available inside it.
  • Ordering — several classes passed to one on share the block; several on declarations for the same event run in declaration order.
  • Isolation — an exception in a handler is logged and swallowed, never propagated: a broken audit hook can't break Sundae's reply. Handlers do run synchronously on the run's thread, so offload slow work to a background job from inside the handler.

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.agent&.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 event catalog

This is the complete set — every event the engine emits, when it fires, and every reader it carries (each nil-safe; every event also carries correlation_id → String | nil):

EventFires whenReaders
InferenceStartedEventA run beginsagent → Protege::Agent · message → Protege::Message | nil
InferenceGeneratedEventA turn is generated — only when tracing is onturn → Integer · request → Hash · response → Hash (wire-format request/response) · model → String · settings → Hash
InferenceCompletedEventA run finishes successfullyagent → Protege::Agent · message → Protege::Message | nil · result → Inference::Provider::Response (the final model response)
InferenceFailedEventA run raisesagent → Protege::Agent · message → Protege::Message | nil · error → Exception
InferenceMaxTurnsReachedEventThe turn budget is exhaustedagent → Protege::Agent · turn → Integer · tool_calls → Array<ToolCall> (the calls left pending)
ToolCallsReceivedEventThe model asks for one or more toolsagent → Protege::Agent · tool_calls → Array<ToolCall>
ToolCallStartedEventA single tool call beginsagent → Protege::Agent · tool_call → ToolCall
ToolCallCompletedEventA tool call succeedsagent → Protege::Agent · tool_call → ToolCall · result → Protege::Result
ToolCallFailedEventA tool call failsagent → Protege::Agent · tool_call → ToolCall · error → Exception
InferenceChunkEventA streamed token arrives (console runs only)agent → Protege::Agent · thread → Protege::EmailThread · chunk → Hash · chunk_content → String (the chunk's decoded text)
LoopRunEnqueuedEventA scheduled run is enqueuedname → String · agent_name → String
LoopRunStartedEventA scheduled run beginsname → String · agent_name → String
LoopRunCompletedEventA scheduled run finishesname → String · agent_name → String
LoopRunFailedEventA scheduled run failsname → String · agent_name → String · error → Exception

The recurring types, and what to read off them:

  • Protege::Message — the stored inbound email driving a reply run (from_address, subject, readable_body, email_thread, …; see Models). nil on scheduled runs, which is why the readers are nil-safe — guard with &. as the examples do.
  • ToolCall (Protege::Inference::Provider::ToolCall) — one call the model requested: id → String (the provider's correlation id for the call), name → String (the tool id, e.g. "lookup_order"), input → Hash (the parsed arguments, matching the tool's input_schema).
  • Protege::Result — a tool's outcome: success?, data → Hash, error. A failed tool call (ToolCallFailedEvent) instead hands you the raised Exception directly.
  • Inference::Provider::Response — the model's final response object on InferenceCompletedEvent; on InferenceGeneratedEvent the request/response are the raw wire-format hashes, captured for tracing.

Built-in hooks

The engine ships one hook of its own, always active: EventLoggerHook writes a human-readable, correlation-id-prefixed line to config.logger for nearly every event above — the zero-setup observability surface an operator greps first, and where you copy the correlation id to feed trace search. The grammar:

LineEmitted on
<cid> INF START agent=sundaeInferenceStartedEvent
<cid> INF DONE agent=sundaeInferenceCompletedEvent
<cid> TOOLS RECV [lookup_order, send_email]ToolCallsReceivedEvent
<cid> TOOL START lookup_order id=call_0ToolCallStartedEvent
<cid> TOOL OK lookup_order id=call_0 data={...}ToolCallCompletedEvent
<cid> TOOL FAIL lookup_order id=call_0 RuntimeError: ... (+ 3 backtrace frames)ToolCallFailedEvent
<cid> MAX TURNS turn=100 pending=[...]InferenceMaxTurnsReachedEvent
<cid> LOOP PENDING <duty> agent=ScoutLoopRunEnqueuedEvent
<cid> LOOP START / LOOP DONE / LOOP FAIL ...the remaining loop events

Each line opens with a timestamp and the run's correlation id (-------- when absent). The two events it deliberately skips are InferenceGeneratedEvent (tracing captures it durably) and InferenceChunkEvent (too chatty — that stream feeds the introspection panel instead).

Under the hood

Each event class is a thin wrapper around an ActiveSupport::Notifications topic (EventClass.channel returns it, e.g. "protege.inference.completed"). You can subscribe directly, outside any hook — Protege::InferenceCompletedEvent.subscribe { |event| ... } — and event[:key] reads any raw payload key that lacks a named reader.

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), the lifecycle log lines above, 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.

Next

  • Observing your agent — the introspection panel and tracing, both built on these events.
  • Tools — the actions whose calls these events describe.