Skip to main content
Hooks are under active development. Today the runtime invokes on_incoming_message / modify_incoming_message on final user text before it is stored, on_turn_started / on_turn_finished around each Mantle turn, on_model_request / modify_model_request and on_model_response / modify_model_response on each main LLM loop iteration and on rephrase, confirm-rephrase, and the post-activate set_fields fill, on_outgoing_text / modify_outgoing_text on each completed agent utterance, on_tool_call / modify_tool_call and on_tool_result / modify_tool_result on the tool-call batch path, plus on_knowledge_query / modify_knowledge_query and on_knowledge_results / modify_knowledge_results around search_knowledge.
Model request and response hooks run on the main LLM loop and on rephrase, confirm-rephrase, and the post-activate set_fields fill. On those three calls, RetryModel discards that completion instead of starting another main-loop iteration. A request-hook crash does not send the prompt. Hooks let you add optional steps to the Mantle turn path without replacing core components. Each hook is an async function registered with a point-specific decorator. Observe hooks (on_*) do not block the turn; if you register several for the same point, they run in parallel. Modify hooks (modify_*) are awaited and may return an updated payload.

Layout

Place hook modules at the agent root:
Both layouts may coexist. The loader discovers hooks.py and nested hooks/**/*.py files (excluding __init__.py, __pycache__, and *.pyc). Shared project code under lib/ is importable during hook module loading, the same way as tool modules.

Registration

Import the decorator you need directly from rasa.mantle.hooks. Parentheses are mandatory, even when you only use defaults:
hooks/redact_pii.py
Rules:
  • One modify hook per point. A second registration fails at train time and model load.
  • Many observe hooks per point. They run concurrently and unordered. The engine does not await them on the turn path.
  • Always async def. Synchronous handlers are rejected at validation.
  • Signatures: observe hooks take one typed payload and return None; modify hooks take one typed payload and return the same type.
  • Control flow is raised, never returned. Use exceptions for vetoes and model steering (see below).

Insertion points

Incoming-message and outgoing-text hooks apply to text only. Hooks for incoming audio, ASR partials, and outgoing audio are not available.

Control signals

Modify hooks signal intentional non-happy paths by raising exceptions:
  • HookRejection(reason=...) — veto this action. Allowed only at points listed in the table above.
  • RetryModel(feedback=...) — discard the current model response or tool call and steer the agentic loop with feedback. Allowed only on modify_model_response and modify_tool_call.
Raising a control signal at any other point is invalid hook behaviour and fails at train time when detected by the AST check. As a runtime backstop, an unsupported signal is logged and discarded, and the engine keeps the original payload. It never silently acquires rejection or retry semantics. RetryModel.feedback is a short note the engine adds to the next model request in the same turn. It is not saved in the conversation history, never appears as a user or bot message, and is never sent to the user. It only guides that next in-turn model call.

Multi-tool batches

There is still one registered modify_tool_call for the agent. When an LLM response contains several tool calls, the engine treats that single modifier as a preflight phase:
  1. The engine invokes on_tool_call / modify_tool_call once per call in the LLM batch, sequentially in batch order, each time with that call’s own payload — before listen-only routing, the knowledge_searched latch, or any tool in the batch runs.
  2. Only after every preflight invocation succeeds does routing and tool dispatch begin.
  3. Confirmation and tool-constraint gates still run inside tool dispatch after preflight. A closed gate never reaches the tool body. On requires_confirmation, MCP hook metadata is stored and replayed as described under metadata in the modify_tool_call field table below. A local tool pause stores the arguments only. After the user confirms, that stored call runs and does not re-run these hooks.
If the model returns the same tool-call id on more than one call in the batch while tool-call hooks run, preflight fails closed (same path as a crashing modifier) and nothing in the batch runs. With no on_tool_call observer and no modify_tool_call modifier, the batch is left unchanged. If any preflight invocation raises RetryModel, the entire batch is discarded, no tool in that batch runs, accompanying assistant text is not emitted, and the feedback steers the next model iteration. This prevents a retry requested for a later call from following an earlier tool side effect. Parallel preflight is out of scope for the first tool-call wiring. Unexpected modify_tool_call crashes or timeouts are fail-closed: the batch is not dispatched, accompanying text is not emitted, and the user hears utter_tool_call_hook_error (overridable in responses.yml). The exception text is not spoken. After each dispatched call returns, on_tool_result / modify_tool_result may rewrite the value persisted on tool_executed and replayed to the next provider request. That point has no control signals; unexpected crashes are fail-open and keep the original result. Tool-call ids stay engine-owned; hook MCP metadata stays paired with each call through dispatch (and through confirmation pause when applicable). Tool-call and tool-result hooks run on the LLM tool-call batch path only. They do not run for other engine-driven tool invocations such as confirmation resume, settable writes, memory validation tools, or correction replay. on_knowledge_query / modify_knowledge_query run after the empty-query guard and before the references index is searched. on_knowledge_results / modify_knowledge_results run after retrieval and before passages are serialized back to the model. Both modifiers are change-only: they may rewrite the query or filter/reorder/empty passages, but they do not accept HookRejection or RetryModel. Unexpected query-hook crashes or timeouts are fail-closed and skip the search (the model receives the same safe empty-result note used when retrieval fails). Unexpected results-hook crashes or timeouts are fail-open and keep the original passages. Missing-index, empty-query, and retrieval-exception paths are unchanged when hooks are absent or succeed. KnowledgeQueryPayload / KnowledgeResultsPayload fields: These hooks cannot block the turn. Do not raise HookRejection or RetryModel here. If you do, Mantle treats that as invalid modify behaviour. If modify_knowledge_query crashes or times out, Mantle skips the search. If modify_knowledge_results crashes, times out, or returns an unexpected shape, Mantle keeps the original passages.

Working example

hooks/rewrite_knowledge_search.py

Rejections

HookRejection is accepted at two points, and each has its own contract:
  • modify_incoming_message — the user text is kept as a discarded stored utterance, the user-message turn is skipped, and a response template is sent instead.
  • modify_outgoing_text — the draft reply is dropped without being stored, and a response template is sent instead.
Both accept an optional response_message naming a response defined in responses.yml. It is never free-form text. Each point has a packaged fallback response for when the name is missing, unknown, or has no matching variant.

Fail policies

A point’s fail policy applies only to unexpected crashes and timeouts, never to intentional control signals. closed stops the current turn and fails gracefully; open logs and continues with the last good payload. Observe hooks are always non-blocking: they receive independent copies, and their mutations, errors, timeouts, or control signals have no effect on the turn.

Timeouts

Default timeouts: Override per hook: @modify_model_request(timeout_ms=2000).

Payload wall

Every payload carries read-only identity fields:
  • sender_id
  • call (telephony/session metadata, when present)
  • channel (channel name and front-end metadata)
Hooks never receive access to the original tracker, the orchestrator, or raw tracker events. The runner copies the payload before calling a modify hook and restores engine-owned identity from the original input even when a modify hook returns different values for those fields. Use payload.model_copy(update={...}) to produce modify-hook return values:

Turn lifecycle

on_turn_started and on_turn_finished are observe-only. They run once per Mantle turn: started when the turn begins, finished when it ends — including when the turn fails unexpectedly — so you always see a matching pair. Neither point has a modify lane. Observers are scheduled concurrently and are not awaited on the turn path. Crashes, timeouts, mutations, and control signals from observers are logged and discarded — they cannot change tracker or engine state. TurnStartedPayload fields: TurnFinishedPayload fields: TurnFinishedPayload.status reports the turn outcome:

Working example

hooks/turn_timing.py

Validation

Hook modules are validated during rasa train (before packaging). Validation checks:
  • import and syntax errors
  • unknown hook points
  • duplicate modify registrations
  • non-async handlers
  • payload type annotations and return types
  • unsupported control signals for each point
Fix validation errors before deploying — the same checks also apply at model load as a second line of defence.

modify_incoming_message

Runs on final user text before Mantle stores that utterance and before the orchestrator turn begins. The modify lane may rewrite text; that rewritten value is what gets stored and what the rest of the turn runs on. The system conversation markers /session_start, /session_end, and /silence_timeout (Inspector, shell, voice connectors) skip this insertion point entirely — both observe and modify. Hooks apply to real user text only. Identity comes from the inbound channel message (sender_id, channel.name / metadata). IncomingMessagePayload fields: Do not raise RetryModel here. An unsupported retry is logged and discarded, and the last good payload proceeds.

Rejecting an incoming message

Raise HookRejection(reason=..., response_message="utter_message_rejected") to veto the message. Mantle then:
  1. keeps the original user text as a stored utterance and marks it discarded (see Discarded utterances);
  2. skips the user-message orchestrator turn, so on_turn_started / on_turn_finished do not fire for that turn;
  3. sends the reply named by response_message (see Choosing the reply).
Tracker save and message-processing finalization still run.

Choosing the reply

response_message must name a response defined in the bot’s responses.yml; it is not free-form copy. Omitting it, naming an unknown response, or naming a response whose conditioned variants all miss uses utter_incoming_message_rejected. Override that response in your project responses.yml to change the default wording — see responses.yml. Named replies are resolved the same way as engine utterances — conditioned variants, {session.*} placeholders, and static buttons — against the current conversation memory, then delivered like any other utterance. The reply still passes on_outgoing_text / modify_outgoing_text before it is stored and sent. A name that resolves to nothing never reaches the user as a raw utter_* string; the packaged fallback is sent instead.

Discarded utterances

Only the engine sets the discarded flag. It is stamped on the rejected user utterance itself — never on channel session metadata — and on the rejection reply, so model and completion-judge history omit the pair and keep alternating user/assistant roles. The LLM conversation history excludes discarded utterances on every later turn. If a channel front end includes a discarded key in the message metadata it sends (for example in the REST metadata field), Mantle removes it before hooks run. A message cannot mark itself discarded to hide from history while still being processed.

Rejecting the first message of a session

When the rejected message is the one that opened a session (a first message without a preceding /session_start, or a message after session expiry), Mantle still starts the session: it stamps the opener discarded, runs default_session_start as a session-start turn so that skill’s memory writes apply, then resolves and sends the reply. Because the reply is resolved after that turn, a named response can use memory written by the session-start skill. Empty openers — accepted or rejected — also run session start in the same request, so the skill is never parked onto the next accepted message.

Crashes and timeouts

This point is fail-closed. If the modify hook crashes, times out, or returns an invalid payload, Mantle treats the message exactly like a rejection with no response_message: the text is stored as discarded, the engine never runs on the original payload, and utter_incoming_message_rejected is sent.

Working example

hooks/filter_incoming.py

modify_outgoing_text

Runs once on every completed utterance that would reach the user, including fillers. It is a text hook: it does not run per streaming token, on audio, or on ASR partials. When modify_outgoing_text is registered, streamed model output is buffered until the full text has passed the hook, then emitted as one completed utterance. on_outgoing_text observers still run on that completed text but do not delay or coalesce tokens. OutgoingTextPayload fields: Modified text is both stored as the bot response and sent to the channel. Sends that are already cancelled skip both outgoing hooks and emit nothing. If the turn is cancelled while a hook is running, that invocation still finishes, then the engine drops the utterance. RetryModel is not supported at this point.

Rejecting outgoing text

Raise HookRejection(reason=..., response_message="utter_...") to drop the original response entirely. Text the agent never uttered is not stored on the tracker. The user receives the named response instead, or utter_outgoing_text_rejected when the name is omitted, unknown, or has no matching variant, and only that reply is stored as the bot response — the tracker records exactly what the user received. As with incoming rejections, response_message must be a responses.yml name, never free-form text; see responses.yml for the override pattern. Rejecting a confirmation ask clears the pending tool call. The next user message cannot confirm a tool whose question was replaced by the fallback. Rewriting the ask keeps the pending call, because the user still received a question. The fallback is passed through modify_outgoing_text once more before it is stored or sent, so a redaction hook still sees text that came from session memory. A rewrite of that fallback is what the user receives. If the hook rejects the fallback as well, the utterance is dropped and nothing is stored. Unexpected crashes, timeouts, and invalid returns are fail-open: the original text is stored and sent.

modify_model_request

This wired insertion point runs once per main LLM loop iteration, and on rephrase, confirm-rephrase, and the post-activate set_fields fill, after the prompt is trimmed to the token budget and before the provider call. A crash here does not send the prompt. Empty-completion retries reuse that same hooked request (messages and allowlisted model_settings) without re-running hooks — the messages do not change between those attempts, so the hook result stays in effect for each retry. ModelRequestPayload fields: You can change these model_settings keys: temperature, top_p, max_tokens, presence_penalty, frequency_penalty, stop, and seed. Values must match provider-safe types (numbers for the float/int keys; a string or list of strings for stop). Anything else is ignored — including API keys, the provider, and the model name. Hooks also cannot change which tools the LLM is offered. Message pass-through. Returned messages are forwarded to the provider as dict copies — Mantle does not validate roles/content or strip extra keys. An empty list still triggers the LLM call (the failure is a provider error, not HookExecutionError). Extra fields such as tool_calls or name are left in place intentionally so builders can shape provider-compatible payloads. This hook cannot block the turn. Do not raise HookRejection or RetryModel here. If you do, Mantle logs and ignores them, keeps the last good request, and still calls the LLM. If the hook crashes or times out, Mantle skips the LLM call and tells the user with utter_model_request_hook_error.

Working example

hooks/lower_temperature.py

modify_model_response

Runs once per main LLM loop iteration, and on rephrase, confirm-rephrase, and the post-activate set_fields fill, after the provider returns and before any user-visible text emit or tool dispatch. The modify lane may rewrite response text and tool calls; those rewritten values are what the engine uses. On the three quiet calls, RetryModel discards that completion. It does not start another main-loop iteration. ModelResponsePayload fields: Control signal: raise RetryModel(feedback=...) to discard this response (no text emit, no tool dispatch) and steer another model iteration. The feedback string is appended as an engine-only system note on the next request only. It is not stored on the tracker and is never sent to the user. Re-entry still counts toward the per-turn iteration cap; exhausting that cap uses the existing max-iterations result. Do not raise HookRejection here. Once an incoming message has been accepted, this point improves accuracy/safety by rewriting or steering — it cannot intentionally suppress the answer. An unsupported rejection is logged and discarded, and the original response proceeds. Unexpected modifier crashes, timeouts, or invalid return values are fail-open: Mantle keeps the original response and continues. RetryModel is intentional control flow and is never swallowed by fail-open handling. When a modify_model_response or modify_tool_call hook is registered, Mantle buffers the full main-LLM completion instead of streaming tokens live. Those hooks run only after the provider finishes; live streaming would let the user hear tokens the modifier might still rewrite or discard via RetryModel (including text dropped with a discarded tool-call batch). Channels that support streaming still receive the final (possibly hooked) text in one delivery. Observe-only hooks do not disable streaming. Changing between text-only and tool-call shapes also updates the knowledge_searched latch used for the search-then-answer loop — the latch follows the post-hook tool calls.

Working example

hooks/ground_answers.py

modify_tool_call

Runs as a batch preflight before listen-only routing, the knowledge_searched latch, and any tool dispatch. For each call in the batch, Mantle invokes on_tool_call / modify_tool_call once — sequentially in batch order — with that call’s own payload. Only after every preflight invocation succeeds does routing and dispatch begin. Confirmation resume, settable writes, memory validation tools, and correction replay never reach this path. Correction replay re-invokes a producer from write provenance with stored tool_args only, so hook metadata is not passed on the corrected MCP call. Confirmation and tool-constraint gates still run later inside dispatch after preflight. ToolCallPayload fields: Engine-owned tool-call id and provider type are preserved across rewrites; hooks never receive or change them. Preflight keeps hook metadata beside the rewritten call (not on arguments or LLMToolCall) and deep-copies it so later changes to the hook’s dict cannot alter the outbound MCP _meta. metadata is merged on top of the server’s configured meta_map for that one MCP call. On a key collision, the hook value wins. Returning the payload unchanged, or without metadata, keeps today’s call. Confirmation pause, tracker retention, and inspector visibility are described in the metadata row above. Control signal: raise RetryModel(feedback=...) from any preflight invocation to discard the entire batch (no tool has run yet, and any accompanying assistant text is not emitted) and steer the next model iteration. The feedback string is appended as an engine-only system note on the next request only. Do not raise HookRejection here. An unsupported rejection is logged and discarded, and the original call proceeds through preflight. Unexpected modifier crashes or timeouts are fail-closed: the batch is not dispatched, accompanying text is not emitted, and the user hears utter_tool_call_hook_error. A registered modify_tool_call hook buffers the main completion the same way modify_model_response does, so live streaming cannot leak speech that preflight later discards. Observe-only on_tool_call hooks do not disable streaming.

Working example

hooks/guard_tool_calls.py
Attach data only the MCP server should see. It does not become a tool argument and is not shown to on_tool_call:
hooks/attach_call_meta.py

modify_tool_result

Runs after each dispatched tool returns and before the result is persisted on tool_executed and replayed to the next provider request. The modify lane may rewrite value, arguments, and is_error. ToolResultPayload fields: Tool-call ids stay engine-owned. This hook has no control signals. Do not raise HookRejection or RetryModel here. If you do, Mantle logs and ignores them and keeps the last good result. Unexpected modifier crashes, timeouts, or invalid return values are fail-open: Mantle keeps the original result and continues. Langfuse tool-call span output uses the post-hook persisted result (same value stored on the tracker), so redaction in modify_tool_result applies to traces as well as prompt replay.

Working example

hooks/redact_tool_results.py