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.agent&.name} refunded via #{event.tool_call.name}: #{event.result&.data}"
)
end
endNothing 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:
on(*event_classes) { |event| ... } # one or several Protege::Event subclasses- Arguments — each must be one of the event classes below. An unknown class raises
ArgumentErrorat 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_idis on every event. - Binding — the block runs via
instance_execon 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
onshare the block; severalondeclarations 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:
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
endThe 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):
| Event | Fires when | Readers |
|---|---|---|
InferenceStartedEvent | A run begins | agent → Protege::Agent · message → Protege::Message | nil |
InferenceGeneratedEvent | A turn is generated — only when tracing is on | turn → Integer · request → Hash · response → Hash (wire-format request/response) · model → String · settings → Hash |
InferenceCompletedEvent | A run finishes successfully | agent → Protege::Agent · message → Protege::Message | nil · result → Inference::Provider::Response (the final model response) |
InferenceFailedEvent | A run raises | agent → Protege::Agent · message → Protege::Message | nil · error → Exception |
InferenceMaxTurnsReachedEvent | The turn budget is exhausted | agent → Protege::Agent · turn → Integer · tool_calls → Array<ToolCall> (the calls left pending) |
ToolCallsReceivedEvent | The model asks for one or more tools | agent → Protege::Agent · tool_calls → Array<ToolCall> |
ToolCallStartedEvent | A single tool call begins | agent → Protege::Agent · tool_call → ToolCall |
ToolCallCompletedEvent | A tool call succeeds | agent → Protege::Agent · tool_call → ToolCall · result → Protege::Result |
ToolCallFailedEvent | A tool call fails | agent → Protege::Agent · tool_call → ToolCall · error → Exception |
InferenceChunkEvent | A streamed token arrives (console runs only) | agent → Protege::Agent · thread → Protege::EmailThread · chunk → Hash · chunk_content → String (the chunk's decoded text) |
LoopRunEnqueuedEvent | A scheduled run is enqueued | name → String · agent_name → String |
LoopRunStartedEvent | A scheduled run begins | name → String · agent_name → String |
LoopRunCompletedEvent | A scheduled run finishes | name → String · agent_name → String |
LoopRunFailedEvent | A scheduled run fails | name → 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).nilon 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'sinput_schema).Protege::Result— a tool's outcome:success?,data → Hash,error. A failed tool call (ToolCallFailedEvent) instead hands you the raisedExceptiondirectly.Inference::Provider::Response— the model's final response object onInferenceCompletedEvent; onInferenceGeneratedEventtherequest/responseare 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:
| Line | Emitted on |
|---|---|
<cid> INF START agent=sundae | InferenceStartedEvent |
<cid> INF DONE agent=sundae | InferenceCompletedEvent |
<cid> TOOLS RECV [lookup_order, send_email] | ToolCallsReceivedEvent |
<cid> TOOL START lookup_order id=call_0 | ToolCallStartedEvent |
<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=Scout | LoopRunEnqueuedEvent |
<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.