Hooking into the engine
Watching and steering a turn
An engine hook is a function of yours that Parlant calls at a fixed moment in every turn, such as right after a reply is generated. Hooks let you watch what the engine does: count rejected replies, or send each finished message to another channel. They also let you steer: skip a stage, reject a step so it regenerates, or answer the turn your own way. Nothing in the engine is replaced.
- How to register a hook and what every hook receives
- What the three return values do
- The hook points, grouped by where they fire in a turn
- Three practical uses of hooks
A hook point, such as on_message_generated, names the moment and holds the callbacks Parlant runs there. As the turn runs, Parlant calls each of your callbacks in order, handing it the live turn context along with a payload describing the event.
Hooks serve two purposes. The first is observing: emit a metric every time a message is generated, time each reasoning step, or write an audit record when the reviewer rejects a draft. The second is steering: skip a stage, reject a step so that it regenerates, or stop the turn and answer it your own way.
Note that neither purpose replaces a component. Hooks run alongside the engine, not instead of it.
If you need to swap out a part of the engine itself, see Overriding engine behavior.
Registering a hook
p.CompassHooks is the object that holds every hook point. It lives in the container, the registry that maps each server component to the implementation serving it. You reach the container through initialize_container, a function you pass to p.Server. Parlant runs it once at startup, after every component has been built.
Your coding agent resolves p.CompassHooks there and appends your callbacks to the points you care about:
import parlant.sdk as p
async def initialize_container(container: p.Container) -> None:
hooks = container[p.CompassHooks] # the one shared hooks object
async def on_message_generated(
context: p.EngineContext,
message: str, # this point's payload: the generated text
exc: Exception | None,
) -> p.CompassHookResult:
my_metrics.increment("messages.generated")
return p.CompassHookResult.CALL_NEXT # observe only; let the turn continue
hooks.on_message_generated.append(on_message_generated)
async with p.Server(initialize_container=initialize_container) as server:
...
Each hook point is a plain list, so several callbacks can share one point. Parlant runs them in the order you registered them.
Note that hooks are server-wide. They fire for every agent's turns, not just one.
The hook signature
Every hook has the same shape: (context, payload, exc) -> p.CompassHookResult.
The context is the live p.EngineContext for the turn. It gives you the session, agent, customer, interaction history, working state, logger, tracer, and event emitters. The payload depends on the hook point. exc is None except on on_error.
Hooks run inline. Parlant awaits each one within the turn, so a slow hook adds latency to the reply. Keep them fast, or hand heavy work to a background task.
The return value
Once a hook has run, Parlant looks at what it returned to decide what happens next. The return value is how you steer.
CALL_NEXTtells Parlant to run the next hook on this point. It is the normal return for anything that only observes.RESOLVEtells Parlant to skip this point's normal engine work and jump ahead. Onon_preparing, it skips rule matching and responds directly. On the review hooks, it skips the reviewer (the check that judges a draft or a proposed tool call against the agent's rules) and treats the step as passed. Onon_giving_up, it skips Parlant's forced final message, because your handler has answered instead.BAILtakes the stop branch. At the early turn gates, it stops the turn, so emit whatever you want to say first. At the review hooks, it rejects the step, which then regenerates with the payload'snote(a correction shown to the model on the retry), or gives up if you setpayload.give_up.
Note that a point which defines no RESOLVE or BAIL behavior treats them like CALL_NEXT.
The hook points
The points fall into four groups.
Around the turn
The first group brackets the turn as a whole, from the acknowledgement status Parlant emits on receiving a customer message, to the ready status that ends the turn:
| Hook | Fires | Payload |
|---|---|---|
on_error | The engine caught a runtime error | The error is in exc |
on_acknowledging / on_acknowledged | Around the acknowledgement status event | None |
on_preparing | Before rule matching and tool selection | None |
on_generating_messages | Before message generation | None |
on_message_generated | A message was generated | str |
on_messages_emitted | All of the turn's messages are in the session, after the ready status | Sequence[str], the delivered messages in order |
on_messages_emitted fires once per turn, after the reply is already on its way to the customer. Its payload is every message the turn delivered, in the order the customer saw it: any message sent before a tool call as well as the final reply.
Use it to send a completed exchange to a memory service or another channel, without delaying delivery of the reply.
Note that BAIL on on_message_generated intercepts the outgoing message only in block output mode. There, Parlant generates the whole reply and then sends it, so the hook runs before emission, and your handler may emit a replacement. In stream mode, the text goes out as it is produced and is already on the wire, so the hook is observational.
Inside the step loop
Parlant produces a reply in a loop of steps. Each step is a model call that may propose tool calls or draft the message. These hooks fire inside that loop:
| Hook | Fires | Payload |
|---|---|---|
on_step_started / on_step_completed | Around each step | int step number |
on_message_updated | A streamed message grew | str full text so far |
on_giving_up | The loop hit its step or failure limit | CompassHookPayload.GaveUp (reason, steps, kind) |
The kind on GaveUp is a p.CompassHookPayload.GiveUpKind. It says why the turn stopped, so a handler can treat the causes differently: RETRIES_EXHAUSTED (output was rejected by review or blocked by moderation once too often), MAX_STEPS (the turn used up the agent's max_steps without preparing a reply), or HOOK_REQUESTED (one of your review hooks set payload.give_up).
Handling semantic failures shows a handler that hands the conversation to a person instead of apologizing.
Around tool calls
These surround tool calls:
| Hook | Fires | Payload |
|---|---|---|
on_reviewing_tool_calls | Before the reviewer checks proposed calls | ReviewingToolCalls (tool_calls; editable note, give_up) |
on_tool_calls_reviewed | After that review | ToolCallsReviewed (tool_calls, passed, todo, adjusted_reasoning) |
on_calling_tools | Before approved calls run | The calls |
on_tool_calls_completed | The step's calls finished | Outcomes by tool name |
Around message review and compaction
These surround message review and compaction:
| Hook | Fires | Payload |
|---|---|---|
on_reviewing_message | Before the reviewer checks a draft | ReviewingMessage (message; editable note, give_up) |
on_message_reviewed | After that review | MessageReviewed (message, passed, adjusted_reasoning) |
on_compacting / on_compacted | Around session compaction | Token count before; Compacted (before, after) |
The payload types live under p.CompassHookPayload.
Note that a hook your engine does not fire stays dormant, so registering defensively is safe.
Per-rule callbacks
Sometimes you do not want to watch the whole turn. You want to react when one specific rule fires. For that, skip the hook lists and use the rule's own callbacks.
A rule is a policy or observation you created on the agent. On each turn, Parlant judges a policy's applicability from its content, and an observation's from its condition.
Two callbacks are available. on_matched runs when the rule matches a turn. on_discovered runs when it enters the session's working set (the rules Parlant keeps loaded for that session because they have become relevant).
Both callbacks receive the turn context and a p.RuleEvaluation, which carries the rule's ID and Parlant's verdict on it:
async def on_matched(context: p.EngineContext, evaluation: p.RuleEvaluation) -> None:
context.logger.info(f"Rule matched: {evaluation.id}")
await agent.create_policy(
content="When the customer asks for a refund, explain the refund policy",
on_matched=on_matched, # runs on every turn where this policy matches
)
Designing policies covers the rule kinds and their callbacks.
Three things hooks are good for
Fast-path a trivial input
If you can cheaply recognize an input that needs no rules or tools, such as a greeting, you can RESOLVE from on_preparing. Parlant then skips matching and replies from the agent's base instructions alone:
async def on_preparing(
context: p.EngineContext,
payload: None,
exc: Exception | None,
) -> p.CompassHookResult:
if looks_like_a_greeting(context):
return p.CompassHookResult.RESOLVE
return p.CompassHookResult.CALL_NEXT
Reject a bad message without the reviewer
When you can detect a breach directly in your own code, BAIL from on_reviewing_message. Parlant regenerates the step with your note, and no model is called for the verdict. This is the place for a guardrail on the agent's own output.
Note that this hook fires only when message review is active for the turn.
Message reviews explains how to turn message review on.
async def on_reviewing_message(
context: p.EngineContext,
payload: p.CompassHookPayload.ReviewingMessage,
exc: Exception | None,
) -> p.CompassHookResult:
if leaks_another_customers_data(payload.message):
payload.note = "Never reveal another customer's data; apologize and offer help with their own account."
return p.CompassHookResult.BAIL
return p.CompassHookResult.CALL_NEXT
Deliver a finished turn somewhere else
on_messages_emitted is the one point that fires after the whole turn is done. That makes it the place to push a reply into a channel that cannot stream.
Email & other channels builds that adapter.