Skip to main content
This page is the full detail behind Hooks. Start there to add hooks to a call.

What it gives you

How it works

  1. prompt hooks change each system prompt the library builds: the agent’s, each task step’s, a finish check’s and a summary’s.
  2. before_model hooks change the request about to go out. wrap_model hooks run around it. after_model hooks hear the answer.
  3. before_tool hooks change or refuse a tool call. wrap_tool hooks run around it. after_tool hooks may replace its result.
  4. before_call hooks run before the greeting. wrap_failure hooks run around the failure line.
  5. on observers watch events. They never change anything.
Define hooks once, at import. Build one CallHooks per call. Every adapter already takes call=, so the hooks reach every request and tool of the call. See Many calls at once for what calls share.

The points

  1. before_* and prompt hooks run first to last.
  2. wrap_* hooks nest. The first one added is the outermost.
  3. after_* hooks run last to first.
  4. Any hook may be sync or async, except prompt, which must be a plain function, and wrap_*, which must be async.
  5. A hook that raises fails its own request or tool call. A model request that fails this way fails the call, as a failed model call does. A tool call becomes a ToolError the model reads.
  6. An after_tool hook that raises is logged, and the model reads the tool’s own result, because the tool already ran. An after_model hook or an observer that raises is only logged.
  7. On Pipecat, an after_model hook reads each tool call’s arguments as UTF-8 JSON, like {"name": "José"}. It never sees é escapes. The Pipecat span and LiveKit show the same text.
  8. ModelRequest and ToolRun are frozen Pydantic dataclasses. dataclasses.replace(request, ...) builds a new object and checks it again. A wrong type, like site=5, raises a ValidationError. An unknown argument raises TypeError. messages, tools and params are not checked, so a hook may put any JSON in them.

Hooks around a tool call

Every tool call of a call passes the tool hooks. The before_tool, wrap_tool and after_tool points are the home for this.
  1. ToolRegistry.call(..., hooks=...) runs the call’s tool hooks in this order: before_tool, then the checks and the locked values, then wrap_tool around the tool, then after_tool.
  2. A before_tool hook that returns Refuse(message) stops the call. The model reads {"refused": message}, as for a value not set yet.
  3. A hook that changes the arguments gets them checked against the schema again.
  4. Every adapter passes the call’s CallHooks. So the hooks reach every tool of UnmuteAgent and UnmuteFlows with no more wiring.
  5. A ToolRun holds name, arguments as the model wrote them, and source. The source is model, or event for a tool on an event.
On a plain agent, pass the call’s hooks to the tools yourself, as in Run tool hooks on a plain agent. Pass hooks=call so the tool hooks run, and so tool_succeeded and tool_failed events fire for your tools. A CallHooks also drains the registry’s webhooks when the call ends. You do not call call.watch yourself. Framework tools skip the tool hooks: end call, handoffs and native_tools. So do prefetch lookups. See Known limits.

Writing hooks as a class

  1. hooks.add(obj), or Hooks(obj, ...), hooks the object in with each method named after a point.
  2. An on_event(event, data) method watches the patterns in its events attribute. All events when it has none.
  3. An EventTool is a hook too. See The call’s life.
  4. An object with none of these raises ValueError.

Events

  1. A pattern may hold *, like task.*.
  2. A pattern that matches no event raises ValueError when you register it.
  3. A sync observer runs at once. An async one runs as a task of its own, except for call.ended, which the end waits for. Another async observer still running when the process stops is cut off.

A suffix after every prompt

Some models take an instruction no parameter carries, like Qwen3’s /no_think. PromptSuffix adds it after every prompt, after a blank line. It follows Unmute’s prompt_suffix.
  1. It reaches the agent’s prompt, every task step’s, the finish checks’ and the summaries’.
  2. With model=, only prompts that model reads get it. A summarizer of another model keeps its prompt as it is.
  3. It refuses blank text, more than 512 characters, and a {{...}} placeholder. Each refusal says what to do instead.
  4. The native path and a ModelCall give the model the same final prompt.

Rules for one model

  1. request.model names the model that answers, like gpt-5.6-luna.
  2. request.params are extra body fields for this request only.
  3. RouterModelCall sends them. A request’s params win over the router call’s body. See SLNG.
  4. A native LLM gets them as extra_body, when it is OpenAI compatible.

Many calls at once

  1. A Hooks object holds only your functions. Many calls may share it, in one process or across threads.
  2. Everything a call changes lives in its own objects: CallHooks, CallState, the adapter with the CallTasks it builds and, on Pipecat, the LLM service. Each call builds its own. Build the AgentSpec per call too when it holds a model, because the adapter closes its models when the call ends.
  3. The library refuses a second call on the same object. A CallHooks binds once. CallTasks.use takes one call’s hooks. A Pipecat LLM service runs one call’s hooks.
  4. A hook that keeps state, or needs to know its call, belongs to that call. Build it per call, together with the shared hooks:
Hooks(SHARED, ...) copies the shared hooks in as they are at that moment. A hook added to SHARED later reaches only the calls built after it.

Hooks

Import it from unmute_core.hooks.
object
default:"none"
Objects to add at once, each as for hooks.add. A function, a class instance, an EventTool or another Hooks.

PromptSuffix

str
required
The suffix, like /no_think. It is stripped. It may not be blank, longer than 512 characters, or hold a {{...}} placeholder.
str | None
default:"None"
Only prompts this model reads get the suffix. None adds it for every model. Pass it by keyword.

Refuse

str
required
Why the tool did not run, in words the model can pass on.

PromptInfo

agent | step | finish_check | summary
required
Which prompt a prompt hook is filling.
str
required
An empty string for the main prompt, task:<name> in a step, summary for a summary.
str
required
The model that will read the prompt, like gpt-5.6-luna, or unknown.

ModelResponse

What an after_model hook gets. Import it from unmute_core.hooks.
str
required
The text the agent said.
tuple[ToolCall, ...]
default:"()"
The tools the model called.
str | None
default:"None"
The error’s type name when the request failed. A Pipecat pipeline request reports its failure through call.failed instead.
bool
default:"False"
True when the caller cut the reply off.

Known limits

  1. wrap_model reaches only requests a ModelCall answers. A framework’s own LLM keeps its own retries and fallback.
  2. On native Pipecat:
    • a hook that changes the tools is ignored, with one warning.
    • the system prompt must stay the first message, and a hook sees the service’s own prompt, without the guidance Pipecat adds to it.
    • params reach only OpenAI-compatible services.
  3. On native LiveKit, params need an OpenAI-compatible plugin, and the plugin’s own extra_kwargs win.
  4. Framework tools (end call, handoffs, native_tools) and prefetch lookups skip the tool hooks.
  5. Under FallbackModelCall, before_model runs once. Every fallback gets the same params, and request.model names the first.
  6. With slng/auto, the client does not know which model the router picks. A rule for one model then belongs in the router.

Troubleshooting

ValueError: matches no event

An on(...) pattern matches nothing. The message lists every event. Fix: use a name from Events, or a pattern like task.*.

ValueError: hooks in nowhere

hooks.add(obj) got an object with no method named after a point and no on_event. Fix: give it a method such as before_model, or an on_event method.

TypeError: a prompt hook is async

A prompt hook must be a plain function. Adapters build prompts where nothing can wait. Fix: do async work in before_model instead.

A second call refuses the same CallHooks

A CallHooks binds to one call. Fix: build a CallHooks per call from the shared Hooks.

Where to go next

Hooks

Add hooks to a call, and change prompts, requests and tool calls.

The call's life

CallHooks, tools on events, the failure line and the end.