> ## Documentation Index
> Fetch the complete documentation index at: https://rasa.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Hooks

> The hook authoring interface for observe and modify insertion points.

> [!WARNING]
>
> 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:

```
my-agent/
├── hooks.py              # optional single-file layout
└── hooks/                # optional folder layout
    └── redact_pii.py
```

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:

```python hooks/redact_pii.py theme={null}
from rasa.mantle.hooks import (
    HookRejection,
    IncomingMessagePayload,
    ModelRequestPayload,
    ModelResponsePayload,
    RetryModel,
    ToolResultPayload,
    TurnFinishedPayload,
    modify_incoming_message,
    modify_model_request,
    modify_model_response,
    on_tool_result,
    on_turn_finished,
)


@modify_model_request()
async def redact_pii(request: ModelRequestPayload) -> ModelRequestPayload:
    return request.model_copy(update={"messages": request.messages})


@modify_model_response()
async def steer_or_drop(response: ModelResponsePayload) -> ModelResponsePayload:
    raise RetryModel(feedback="Only answer from retrieved passages.")


@modify_incoming_message()
async def drop_spam(message: IncomingMessagePayload) -> IncomingMessagePayload:
    if "spam" in message.text.lower():
        raise HookRejection(
            reason="spam detected",
            response_message="utter_spam_rejected",
        )
    return message


@on_tool_result()
async def audit(result: ToolResultPayload) -> None:
    return None


@on_turn_finished()
async def mark_done(payload: TurnFinishedPayload) -> None:
    return None
```

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

| Point | Observe | Modify | Control signals (modify only) | Fail on crash/timeout (modify) |
| - | - | - | - | - |
| turn started | `on_turn_started` | — | — | — |
| turn finished | `on_turn_finished` | — | — | — |
| incoming message | `on_incoming_message` | `modify_incoming_message` | `HookRejection` | closed |
| model request | `on_model_request` | `modify_model_request` | — | closed |
| model response | `on_model_response` | `modify_model_response` | `RetryModel` | open |
| tool call | `on_tool_call` | `modify_tool_call` | `RetryModel` | closed |
| tool result | `on_tool_result` | `modify_tool_result` | — | open |
| knowledge query | `on_knowledge_query` | `modify_knowledge_query` | — | closed |
| knowledge results | `on_knowledge_results` | `modify_knowledge_results` | — | open |
| outgoing text | `on_outgoing_text` | `modify_outgoing_text` | `HookRejection` | open |

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`](https://rasa.com/docs/mantle/reference/responses-yml#turn-error-fallbacks)
(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](https://rasa.com/docs/mantle/reference/tools.md#memory-validation-tools), or correction replay.

### Knowledge search

`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:

| Field | Mutable? | Notes |
| - | - | - |
| `query` | yes (query point) / no (results point) | Search string passed to the references index |
| `passages` | yes (results point only) | Retrieved chunks as `{text, scope, source, score?}` dicts |
| `sender_id` / `call` / `channel` | no | Engine-owned identity |

**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

```python hooks/rewrite_knowledge_search.py theme={null}
import structlog

from rasa.mantle.hooks import (
    KnowledgeQueryPayload,
    KnowledgeResultsPayload,
    modify_knowledge_query,
    modify_knowledge_results,
    on_knowledge_query,
)

structlogger = structlog.get_logger()


@on_knowledge_query()
async def log_query(payload: KnowledgeQueryPayload) -> None:
    structlogger.debug(
        "hook.knowledge_query.observed",
        query=payload.query,
        sender_id=payload.sender_id,
    )


@modify_knowledge_query()
async def expand_query(payload: KnowledgeQueryPayload) -> KnowledgeQueryPayload:
    # Example: normalize short ticket-style queries before search.
    query = payload.query.strip()
    if query.isdigit():
        query = f"ticket {query}"
    return payload.model_copy(update={"query": query})


@modify_knowledge_results()
async def drop_low_scores(
    payload: KnowledgeResultsPayload,
) -> KnowledgeResultsPayload:
    passages = [
        passage
        for passage in payload.passages
        if float(passage.get("score") or 0.0) >= 0.2
    ]
    return payload.model_copy(update={"passages": passages})
```

### Rejections

`HookRejection` is accepted at two points, and each has its own contract:

* [`modify_incoming_message`](#rejecting-an-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`](#rejecting-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:

| Lane | Default `timeout_ms` |
| - | - |
| observe (`on_*`) | 100 |
| modify (`modify_*`) on text / LLM / tool / knowledge | 500 |

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:

```python theme={null}
from rasa.mantle.hooks import OutgoingTextPayload, modify_outgoing_text


@modify_outgoing_text()
async def rewrite(payload: OutgoingTextPayload) -> OutgoingTextPayload:
    return payload.model_copy(update={"text": payload.text.strip()})
```

## 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:

| Field | Mutable? | Notes |
| - | - | - |
| `is_voice` | no | Whether this turn came from a voice channel |
| `turn_id` | no | Reserved; may be unset today |
| `sender_id` | no | Conversation / user id for this turn |
| `call` | no | Telephony/session metadata when present; empty unless the channel supplies it |
| `channel` | no | Channel name and front-end metadata |

`TurnFinishedPayload` fields:

| Field | Mutable? | Notes |
| - | - | - |
| `status` | no | Turn outcome: `completed`, `cancelled`, or `error` |
| `turn_id` | no | Reserved; may be unset today |
| `sender_id` | no | Conversation / user id for this turn |
| `call` | no | Telephony/session metadata when present; empty unless the channel supplies it |
| `channel` | no | Channel name and front-end metadata |

`TurnFinishedPayload.status` reports the turn outcome:

| Status | When |
| - | - |
| `completed` | The turn finished normally |
| `cancelled` | The turn was interrupted (for example, barge-in) |
| `error` | The turn failed |

### Working example

```python hooks/turn_timing.py theme={null}
import time

import structlog

from rasa.mantle.hooks import (
    TurnFinishedPayload,
    TurnStartedPayload,
    on_turn_finished,
    on_turn_started,
)

structlogger = structlog.get_logger()

# Process-local timing only — observers must not mutate tracker/engine state.
_started_at: dict[str, float] = {}


@on_turn_started()
async def mark_start(payload: TurnStartedPayload) -> None:
    _started_at[payload.sender_id] = time.monotonic()
    structlogger.debug(
        "hook.turn.started",
        sender_id=payload.sender_id,
        is_voice=payload.is_voice,
        channel=payload.channel.name,
    )


@on_turn_finished()
async def mark_end(payload: TurnFinishedPayload) -> None:
    started = _started_at.pop(payload.sender_id, None)
    duration_ms = (
        None if started is None else round((time.monotonic() - started) * 1000)
    )
    structlogger.info(
        "hook.turn.finished",
        sender_id=payload.sender_id,
        status=payload.status,
        duration_ms=duration_ms,
        channel=payload.channel.name,
    )
```

## 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:

| Field | Mutable? | Notes |
| - | - | - |
| `text` | yes | Final user text for this turn |
| `message_id` | no | Channel message id when present |
| `sender_id` / `call` / `channel` | no | Engine-owned identity (`channel.metadata` is a copy of the channel's message metadata with reserved keys removed). `channel.name` is the input channel recorded on the message. On REST, the request body can set `input_channel`, so that name is not proof of which connector received the HTTP request |

**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](#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](#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`](https://rasa.com/docs/mantle/reference/responses-yml#incoming-message-reject-default-response).

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

```python hooks/filter_incoming.py theme={null}
import structlog

from rasa.mantle.hooks import (
    HookRejection,
    IncomingMessagePayload,
    modify_incoming_message,
    on_incoming_message,
)

structlogger = structlog.get_logger()


@on_incoming_message()
async def audit_incoming(message: IncomingMessagePayload) -> None:
    structlogger.debug(
        "hook.incoming_message.observed",
        sender_id=message.sender_id,
        channel=message.channel.name,
        text_len=len(message.text),
    )


@modify_incoming_message()
async def normalize_or_drop(
    message: IncomingMessagePayload,
) -> IncomingMessagePayload:
    text = message.text.strip()
    if not text:
        raise HookRejection(
            reason="empty message",
            response_message="utter_empty_message",
        )
    if "spam" in text.lower():
        raise HookRejection(
            reason="spam detected",
            response_message="utter_spam_rejected",
        )
    return message.model_copy(update={"text": text})
```

## `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:

| Field | Mutable? | Notes |
| - | - | - |
| `text` | yes | The completed agent utterance |
| `is_voice` | no | Whether the current turn uses a voice channel |
| `sender_id` / `call` / `channel` | no | Engine-owned identity |

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`](https://rasa.com/docs/mantle/reference/responses-yml#outgoing-text-reject-default-response)
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:

| Field | Mutable? | Notes |
| - | - | - |
| `messages` | yes | The chat messages about to be sent to the LLM |
| `model_settings` | yes | Allowlisted per-call overrides only (see below). Starts empty — the engine does not seed provider defaults into the payload; hooks supply any overrides for this path |
| `tool_names` | no | Read-only view of offered tool names |
| `iteration` | no | Current loop iteration |
| `sender_id` / `call` / `channel` | no | Engine-owned identity (`call` is populated only when telephony/session metadata is available) |

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`](https://rasa.com/docs/mantle/reference/responses-yml#turn-error-fallbacks).

### Working example

```python hooks/lower_temperature.py theme={null}
import structlog

from rasa.mantle.hooks import ModelRequestPayload, modify_model_request, on_model_request

structlogger = structlog.get_logger()


@on_model_request()
async def log_request(request: ModelRequestPayload) -> None:
    # Observe lane: fire-and-forget; mutations have no effect.
    structlogger.debug(
        "hook.model_request.observed",
        message_count=len(request.messages),
        tool_count=len(request.tool_names),
        iteration=request.iteration,
        sender_id=request.sender_id,
    )


@modify_model_request()
async def cool_down(request: ModelRequestPayload) -> ModelRequestPayload:
    return request.model_copy(
        update={
            "messages": request.messages,
            "model_settings": {"temperature": 0.2},
        }
    )
```

## `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:

| Field | Mutable? | Notes |
| - | - | - |
| `text` | yes | Assistant text from this completion (may be `None`) |
| `tool_calls` | yes | List of tool-call dicts with `id`, `tool_name`, `tool_args`, and optional `type` |
| `latency_ms` | no | Provider latency for this completion, when available |
| `sender_id` / `call` / `channel` | no | Engine-owned identity |

**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

```python hooks/ground_answers.py theme={null}
import structlog

from rasa.mantle.hooks import (
    ModelResponsePayload,
    RetryModel,
    modify_model_response,
    on_model_response,
)

structlogger = structlog.get_logger()


@on_model_response()
async def audit(response: ModelResponsePayload) -> None:
    structlogger.debug(
        "hook.model_response.observed",
        has_text=bool(response.text),
        tool_call_count=len(response.tool_calls),
        latency_ms=response.latency_ms,
        sender_id=response.sender_id,
    )


@modify_model_response()
async def require_grounding(response: ModelResponsePayload) -> ModelResponsePayload:
    # Drop tool calls the policy forbids before any dispatch.
    allowed = [
        call
        for call in response.tool_calls
        if call.get("tool_name") != "place_order"
    ]
    if len(allowed) != len(response.tool_calls):
        return response.model_copy(update={"tool_calls": allowed})

    # Steer another iteration when the text answer is ungrounded.
    if response.text and "according to" not in response.text.lower():
        raise RetryModel(
            feedback="Only make claims supported by the retrieved passages."
        )

    # Optional rewrite of user-visible text before emit.
    if response.text:
        return response.model_copy(update={"text": response.text.strip()})
    return response
```

## `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:

| Field | Mutable? | Notes |
| - | - | - |
| `tool_name` | yes | Name of the tool about to run |
| `arguments` | yes | Tool arguments dict |
| `headers` | yes | Payload-only today — not plumbed into dispatch |
| `metadata` | yes | Optional dict sent only as MCP `_meta` on this LLM-dispatched call. Stripped before the tool call is built, so it is not part of `arguments`. `on_tool_call` does not receive it. Dropped for non-MCP tools. Not written to `ToolExecuted` or model history. On `requires_confirmation` for an MCP tool, stored on `system.pending_tool_confirmation` and replayed as MCP `_meta` on confirm; the confirmation memory event is visible in the inspector. The write stays in tracker history after the field is cleared, so do not put secrets here. A local tool pause does not store it. Must be JSON-serializable. Traces and logs record key names only. |
| `sender_id` / `call` / `channel` | no | Engine-owned identity |

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`](https://rasa.com/docs/mantle/reference/responses-yml#turn-error-fallbacks).

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

```python hooks/guard_tool_calls.py theme={null}
import structlog

from rasa.mantle.hooks import (
    RetryModel,
    ToolCallPayload,
    modify_tool_call,
    on_tool_call,
)

structlogger = structlog.get_logger()


@on_tool_call()
async def audit_call(call: ToolCallPayload) -> None:
    structlogger.debug(
        "hook.tool_call.observed",
        tool_name=call.tool_name,
        sender_id=call.sender_id,
    )


@modify_tool_call()
async def normalize_and_steer(call: ToolCallPayload) -> ToolCallPayload:
    if call.tool_name == "place_order" and not call.arguments.get("card_id"):
        raise RetryModel(feedback="Call place_order with a card_id.")
    if call.tool_name == "place_order":
        return call.model_copy(
            update={
                "arguments": {
                    **call.arguments,
                    "card_id": str(call.arguments["card_id"]).strip(),
                }
            }
        )
    return call
```

Attach data only the MCP server should see. It does not become a tool
argument and is not shown to `on_tool_call`:

```python hooks/attach_call_meta.py theme={null}
from rasa.mantle.hooks import ToolCallPayload, modify_tool_call


@modify_tool_call()
async def attach_call_meta(call: ToolCallPayload) -> ToolCallPayload:
    if call.tool_name != "get_id_document":
        return call
    return call.model_copy(
        update={"metadata": {"document_ref": "upload-123"}}
    )
```

## `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:

| Field | Mutable? | Notes |
| - | - | - |
| `tool_name` | no | Name of the tool that just ran |
| `arguments` | yes | Arguments used for this call (may be rewritten for persistence/replay) |
| `value` | yes | Result value persisted and replayed to the model |
| `is_error` | yes | Whether the result should be treated as an error |
| `sender_id` / `call` / `channel` | no | Engine-owned identity |

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

```python hooks/redact_tool_results.py theme={null}
import structlog

from rasa.mantle.hooks import (
    ToolResultPayload,
    modify_tool_result,
    on_tool_result,
)

structlogger = structlog.get_logger()


@on_tool_result()
async def audit_result(result: ToolResultPayload) -> None:
    structlogger.debug(
        "hook.tool_result.observed",
        tool_name=result.tool_name,
        is_error=result.is_error,
        sender_id=result.sender_id,
    )


@modify_tool_result()
async def redact_pii(result: ToolResultPayload) -> ToolResultPayload:
    if result.tool_name != "lookup_customer":
        return result
    safe_args = {k: v for k, v in result.arguments.items() if k != "ssn"}
    return result.model_copy(
        update={
            "value": "redacted",
            "arguments": safe_args,
            "is_error": False,
        }
    )
```


## Related topics

- [MCP Servers](https://rasa.com/docs/reference/integrations/mcp-servers.md)
- [External Sub Agent](https://rasa.com/docs/reference/config/agents/external-sub-agents.md)
- [Rasa Pro Change Log](https://rasa.com/docs/reference/changelogs/rasa-pro-changelog.md)
- [Tools reference](https://rasa.com/docs/mantle/reference/tools.md)
- [Sanic Extensions](https://rasa.com/docs/reference/integrations/action-server/sanic-extensions.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.