Skip to main content

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.

What you'll learn
  • 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
Talk this page through with your coding agent

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.

info

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.

info

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_NEXT tells Parlant to run the next hook on this point. It is the normal return for anything that only observes.
  • RESOLVE tells Parlant to skip this point's normal engine work and jump ahead. On on_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. On on_giving_up, it skips Parlant's forced final message, because your handler has answered instead.
  • BAIL takes 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's note (a correction shown to the model on the retry), or gives up if you set payload.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:

HookFiresPayload
on_errorThe engine caught a runtime errorThe error is in exc
on_acknowledging / on_acknowledgedAround the acknowledgement status eventNone
on_preparingBefore rule matching and tool selectionNone
on_generating_messagesBefore message generationNone
on_message_generatedA message was generatedstr
on_messages_emittedAll of the turn's messages are in the session, after the ready statusSequence[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:

HookFiresPayload
on_step_started / on_step_completedAround each stepint step number
on_message_updatedA streamed message grewstr full text so far
on_giving_upThe loop hit its step or failure limitCompassHookPayload.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).

Taking over the give-up message

Handling semantic failures shows a handler that hands the conversation to a person instead of apologizing.

Around tool calls​

These surround tool calls:

HookFiresPayload
on_reviewing_tool_callsBefore the reviewer checks proposed callsReviewingToolCalls (tool_calls; editable note, give_up)
on_tool_calls_reviewedAfter that reviewToolCallsReviewed (tool_calls, passed, todo, adjusted_reasoning)
on_calling_toolsBefore approved calls runThe calls
on_tool_calls_completedThe step's calls finishedOutcomes by tool name

Around message review and compaction​

These surround message review and compaction:

HookFiresPayload
on_reviewing_messageBefore the reviewer checks a draftReviewingMessage (message; editable note, give_up)
on_message_reviewedAfter that reviewMessageReviewed (message, passed, adjusted_reasoning)
on_compacting / on_compactedAround session compactionToken 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
)
Learn more about rules

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.

info

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.

tip

Email & other channels builds that adapter.