What it gives you
How it works
prompthooks change each system prompt the library builds: the agent’s, each task step’s, a finish check’s and a summary’s.before_modelhooks change the request about to go out.wrap_modelhooks run around it.after_modelhooks hear the answer.before_toolhooks change or refuse a tool call.wrap_toolhooks run around it.after_toolhooks may replace its result.before_callhooks run before the greeting.wrap_failurehooks run around the failure line.onobservers watch events. They never change anything.
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
before_*andprompthooks run first to last.wrap_*hooks nest. The first one added is the outermost.after_*hooks run last to first.- Any hook may be sync or async, except
prompt, which must be a plain function, andwrap_*, which must be async. - 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
ToolErrorthe model reads. - An
after_toolhook that raises is logged, and the model reads the tool’s own result, because the tool already ran. Anafter_modelhook or an observer that raises is only logged. - On Pipecat, an
after_modelhook 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. ModelRequestandToolRunare frozen Pydantic dataclasses.dataclasses.replace(request, ...)builds a new object and checks it again. A wrong type, likesite=5, raises aValidationError. An unknown argument raisesTypeError.messages,toolsandparamsare 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. Thebefore_tool, wrap_tool and after_tool points are the home for this.
ToolRegistry.call(..., hooks=...)runs the call’s tool hooks in this order:before_tool, then the checks and the locked values, thenwrap_toolaround the tool, thenafter_tool.- A
before_toolhook that returnsRefuse(message)stops the call. The model reads{"refused": message}, as for a value not set yet. - A hook that changes the arguments gets them checked against the schema again.
- Every adapter passes the call’s
CallHooks. So the hooks reach every tool ofUnmuteAgentandUnmuteFlowswith no more wiring. - A
ToolRunholdsname,argumentsas the model wrote them, andsource. The source ismodel, oreventfor a tool on an event.
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
hooks.add(obj), orHooks(obj, ...), hooks the object in with each method named after a point.- An
on_event(event, data)method watches the patterns in itseventsattribute. All events when it has none. - An
EventToolis a hook too. See The call’s life. - An object with none of these raises
ValueError.
Events
- A pattern may hold
*, liketask.*. - A pattern that matches no event raises
ValueErrorwhen you register it. - 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
/no_think. PromptSuffix adds it after every prompt, after a blank line. It follows Unmute’s prompt_suffix.
- It reaches the agent’s prompt, every task step’s, the finish checks’ and the summaries’.
- With
model=, only prompts that model reads get it. A summarizer of another model keeps its prompt as it is. - It refuses blank text, more than 512 characters, and a
{{...}}placeholder. Each refusal says what to do instead. - The native path and a
ModelCallgive the model the same final prompt.
Rules for one model
request.modelnames the model that answers, likegpt-5.6-luna.request.paramsare extra body fields for this request only.RouterModelCallsends them. A request’sparamswin over the router call’sbody. See SLNG.- A native LLM gets them as
extra_body, when it is OpenAI compatible.
Many calls at once
- A
Hooksobject holds only your functions. Many calls may share it, in one process or across threads. - Everything a call changes lives in its own objects:
CallHooks,CallState, the adapter with theCallTasksit builds and, on Pipecat, the LLM service. Each call builds its own. Build theAgentSpecper call too when it holds a model, because the adapter closes its models when the call ends. - The library refuses a second call on the same object. A
CallHooksbinds once.CallTasks.usetakes one call’s hooks. A Pipecat LLM service runs one call’s hooks. - 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 fromunmute_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 anafter_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
wrap_modelreaches only requests aModelCallanswers. A framework’s own LLM keeps its own retries and fallback.- 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.
paramsreach only OpenAI-compatible services.
- On native LiveKit,
paramsneed an OpenAI-compatible plugin, and the plugin’s ownextra_kwargswin. - Framework tools (end call, handoffs,
native_tools) and prefetch lookups skip the tool hooks. - Under
FallbackModelCall,before_modelruns once. Every fallback gets the sameparams, andrequest.modelnames the first. - 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
Anon(...) 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
Aprompt 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
ACallHooks 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.