Use it in your agent
The code for each kind of agent is in Use it in your agent.- Build the tools once per call. They read that call’s state, and what the call carries from
state.call. - Every option on this page works in all four kinds of agent:
UnmuteAgent, a plain LiveKitAgent,UnmuteFlowsand a plain Pipecat pipeline. The sections below say where one kind of agent needs one more line. callis the call’sCallHooks. It runs your tool hooks and lets background webhooks finish when the call ends.stateandcallare needed only when a tool reads them. On a plain agent, withoutstate, the tools take the state ofcall.- A reply line says dates and times in the call’s language.
function_toolsandtools_schematakelanguage=andtimezone=for it. OnUnmuteAgentandUnmuteFlows, the spec’sAgentSpec(language=, timezone=)sets them.
What it handles for you
You write a typed Python function. The library turns it into a tool that LiveKit and Pipecat call the same way. It checks every argument before your code runs and every result after it, says a line while a slow tool works, and tells the model plainly when a tool failed. It can save a result to State and speak a line from it, in the call’s language. You keep your framework, and you add only the tools.Inputs and outputs
Tool inputs and outputs are Pydantic. Write a function with type hints, andlocal_tool does the rest.
local_toolbuilds a Pydantic model from the type hints. Its JSON Schema is what the model reads.Field(description=...)tells the model what an argument is. Limits likegeandlego into the schema too.- Each call is checked before your function runs. Your function gets real Python types: a
date, anint, a cleanPhone. - A bad argument goes back to the model with what to fix, like “party: 40 is greater than the maximum of 12”. The model asks the caller or calls again.
- Every parameter needs a type. A parameter with none raises
TypeErrorthat names it.*argsand**kwargsare refused too. - The tool name is the function name, and the description is the docstring up to its
Args:section.name=anddescription=change them.
Outputs
Every tool names the Pydantic model of its result. A tool without one is refused when it is built.local_toolreads the output from the return annotation. A function that returns a dict passesoutput=. With neither,local_toolraisesTypeError: “name its output”.- A result that succeeded is checked against the output when the tool returns. The model then reads the checked result,
output.model_dump(mode="json"). Defaults are filled, and a field the model does not declare is dropped, unless the model setsextra="allow". - A result that does not fit fails the call. The model reads “find_order returned a result that does not fit Order: status: … Do not claim success.” The log names the tool and the field, never a value.
- A result that reports an error is never checked: an
erroror arefusedwith text in it, or"ok": false. For a failure in your own function, raiseToolError. Doneis the output of a tool that only runs:{"ok": true}. A webhook that answers with an empty body returns it.Acceptedis the output of a webhook that does not wait for its answer. Both come fromunmute_core.tools.- The
wrap_toolandafter_toolhooks see the checked result. What a hook puts in its place is not checked again. - The rule covers every library tool. A framework tool passed as
native_tools=is the framework’s own object, and the library does not check it.
One input model
When the inputs are a model you already have, make it the function’s only parameter. The function gets one value of the model.Inputs and outputs of a webhook
webhook_tool takes both models.
- Use precise types:
date,Literal,Phone, andFieldlimits. Each one is a check you do not write. - Give every argument a
description. It is the only place the model learns what the argument means. - Type the result’s dates and choices too:
date,time,Literal. A reply line then says them as a person does. - Raise
ToolErrorfor a failure the model should read. Do not return an error dict from a function typed to return a model.
What a tool does around its call
Every tool builder takes the same options:local_tool and webhook_tool.
- With several announce lines, the agent never says the same one twice in a row.
- A failed tool never ends the call. The model reads the error and goes on.
interruptionmatters on Pipecat, wherecancelis the default.continuelets the tool finish. The library keeps Pipecat’s own guidance about background tools out of the prompt, so the model reads the same prompt as on LiveKit. That holds only while the library’s tools are the only background tools. Register your own tool withcancel_on_interruption=Falseand Pipecat’s guidance comes back for all of them. LiveKit never cancels a running tool. When the reply that called it is cut off, the model reads that the tool is still running, and its result comes in a later reply.- A value that is not one of the listed words raises a Pydantic
ValidationError. It names the field and the allowed words. An unknown argument raisesTypeError.
A tool the caller talks over
Why it works this way. A cancelled tool may have half done its work, and its result comes late or never. The model then cannot tell the caller what happened. LiveKit never cancels a running tool, and Pipecat cancels one by default.continue gives Pipecat the LiveKit behaviour, with the same prompt on
both.
Best practice.
- Use
interruption="continue"for a tool that changes something: a booking, a payment, a transfer. - Leave
provider_defaultfor a lookup the model can simply ask again.
UnmuteFlowskeeps Pipecat’s background-tool text out of the prompt by itself.- A plain Pipecat pipeline needs
attach_call(..., llm=llm)for that. Without it, Pipecat adds its own text about background tools to the prompt. - On LiveKit, both kinds of agent need nothing more.
Announce lines with values
{{value}}reads a State value or an argument of this call.{{call.name}}reads what the call carries, like{{call.session_id}}.- The line is filled as it is said.
- If a line reads a value that is not set, the call is refused. Nothing is said and the tool does not run.
- With
announce_wait=True, LiveKit waits for the speech to end, and the caller cannot cut the line off. Pipecat waits for the bot to stop speaking, at most 30 seconds. On Pipecat,user_mute_strategies(protect=["tool_calls"])keeps the caller from cutting the line. - A realtime model that speaks itself skips the line and words its own. See Realtime models.
- Announce only a tool the caller would notice waiting for. A fast lookup needs no line.
- Keep each line short, and give two or three for a tool the agent calls often.
- Use
announce_wait=Trueonly when the line must come before the action, like “Putting you through now.”
A result the model does not read
- With
show_result=False, the model reads{"ok": true}for a result that succeeded. - A failure or a refusal is always shown. The model never claims a success. A result is a failure when it has an
errorwith text in it, arefusedwith text in it, or"ok": false.{"error": null}and an emptyerrorare a success. - Task code still reads the full result. Finish rules and
ToolReplylines work as before. result_instructionsis added to the description as"<description>\n\nUse the result like this: <instructions>".ToolRegistry.for_model(name, result)gives what the model reads. The adapters call it.hidden=("read_back",)leaves only those fields out of a result that succeeded. Use it for a line code speaks from the result, like a read-back: a model that can see the words may say them itself.assignrows and spoken lines still read the field. A field the output model does not have fails when the tool is built.
End the call
A tool witheffect="ends_conversation" ends the call once it succeeds, on both
frameworks. The model is not asked for another reply, even when another tool is
called in the same reply, and on a realtime model too. So the goodbye goes in the
reply that calls the tool.
end_call_tool ends the call with no second model request:
UnmuteFlows(native_tools=[tool]). The goodbye line is spoken
only when the reply that called the tool had no words. Write it in the call’s
language. Pass None to say GOODBYE_LINE from unmute_core.call, in English.
On Pipecat, voicemail_tool gives the model a tool that ends an outbound call at a
voicemail. See Phone calls.
A call that is refused
- The tool does not run, and nothing is said. The model reads the message and acts on its hint.
- If a step must still confirm the value, the hint is “Run first: it confirms with the caller”.
- Else, if a task saves the value, the hint is “Run first: it saves ”. The first task that saves it wins.
- Else the hint is “Get it from the caller”, with the value’s description from the State model in brackets when it has one.
- Every announce line of the tool is filled before one is picked. So every line’s values must be set, even when another line needs none.
- A refused call counts as a failure.
ends_conversationnever fires on it. - In code,
ToolRefusedis aToolError. LiveKit and Pipecat send the same JSON.
- In your own tool, raise
ToolErrorwith the words the model should read, like “No table is free that day. Offer another day.” - Let any other error raise. The model reads that the tool failed, and the log keeps the details.
- Use
show_result=Falsefor a result the caller must not hear. See A result the model does not read.
The schema the model reads
The model reads a plainer copy of each tool’s parameters. The tool still checks what the model sent against the full schema.- An optional value whose default is
nullloses itsnullbranch and that default. Leaving it out means the same thing. A value without that default keepsnull, because therenullmeans something, like “clear this value”. formatandpatternbecome words in the description. Some providers refuse them, and a format only this library knows, likespelled, tells a model nothing.- Local
$refs are inlined.
- A
nullbranch on every optional value makes the model choose between leaving a value out, sendingnulland guessing. Small models often guess. - A keyword the provider does not support can make it refuse the call, or return no call at all. The caller then hears silence.
- A format name the model has never seen, like
spelled, tells it nothing. Plain words do.
A required argument left out or empty
"" for what they do not know.
- A required argument that is absent, or text that is blank, is missing. The tool does not run. It raises
ArgumentsMissing, aToolRefused. - The model reads this refusal, not a format error like “expected a day written year-month-day”.
- When the reply that made the call already said something, no model reply follows. That reply asked the caller, so the caller answers next.
replies_to(result, spoke=...)fromunmute_core.toolsholds this rule. - When the reply said nothing, the model replies to the refusal and asks.
- A step’s closing tool with
confirmlines refuses the same way, before it asks anything. - On Pipecat,
tools_schemacannot tell whether the reply spoke, so the model always replies there. So does OpenAI’s live service, which cannot take a result without replying.
Save a result and speak it
A task step lists the values a result saves (Assign) and the lines code speaks
for it (ToolReply) on its TaskSpec. See Confirm in code.
An agent without tasks lists the same rows on its ToolRegistry.
assignmaps a tool to the State values its result saves.pathwalks the result by field names.path=()saves the whole result into a State value typed as the output model, likeorder: Order | None = None.- The values of one result are saved in one batch, or not at all. A save the State refuses is logged and told to the sinks as
tool.save_refused. The model still reads the result, because the tool ran. A tool’s save never confirms a value. repliesare tried in order. The first one that matches the result and fills is spoken. The model reads the result and is asked for no reply.whenmaps a result field to the values that call for the line.statemaps a State value to its values the same way. A reply with neither is the fixed reply. A line whose value is missing does not play, and the next reply is tried.- A line reads
{{result.<field>}}, nested like{{result.order.id}}, and{{variable}}from the State, nested like{{account.amount_words}}. Several lines are alternatives, said in turn. - A value is said by its output field’s JSON Schema
format, in the call’s language. Adatereads “tomorrow, October 16” with a timezone and “Friday, October 16, 2026” without one. Adate-timeadds the time, atimereads “9:00 AM”, aphoneis grouped, and aLiteralorEnumvalue is said as words. Any other value is said as it is. - The registry checks every row when it is built. A tool, a result field or a State value that does not exist, or a result value whose type cannot fill the State value, fails with a message that names the fix.
- A reply on a tool that ends the call is refused: the call is over before the line could play. Say a goodbye with the tool’s
announceandannounce_wait=True. - A result that reports an error saves nothing and speaks nothing. Inside a task step, a tool that does not end the step still saves its registry
assignrows at once, so a step that asks several questions keeps each answer as it comes. The step’s ownrepliesare spoken, then the tool’s own, and the step’s ownassignsaves when the step ends. - A
whenvalue on aLiteral,boolorEnumfield must be one the field can hold. A typo like"shiped"fails when the registry is built, with the values the field holds. - Each line code speaks is told to the sinks as
tool.replied, so a trace shows which turns cost no model reply.
Replies on the tool itself
A tool can carry its own replies. They play wherever the tool runs: on the main agent, as a step’s tool, and as a step’s closing tool.- A tool’s own reply leaves out
tool. One that names another tool is refused. - The place’s replies are tried first: the registry’s on the main agent, the
TaskSpec’s in a step. Then the tool’s own, in order. - A reply on a
ToolRegistryor aTaskSpecstill names its tool.
Check a rule before the tool runs
A closing tool withconfirm reads its input back before it runs. A rule about
the request, like “a day within ten days”, must answer before that read-back.
Give the tool a precheck:
- The precheck is a plain function, not async. It takes some of the tool’s parameters by name, with the same types, or the whole input model when the tool takes one. Locked values are filled.
- It returns an instance of the tool’s output model, the outcome, or None to go ahead. An outcome is the tool’s result: the tool does not run, and no announce plays.
- The tool’s replies say the outcome, like any result. A
whenvalue is checked against the output’sLiteralat build time. - On a closing tool with
confirm, the precheck runs before the read-back. A wrong value is never read back, and the turn costs one model request: the call. It runs again on the caller’s yes, since the state may have changed. - An outcome must not match the tool’s
finishrule. It is a refusal, not a success.
Assign and ToolReply rows serve a task step and a plain agent, so a
value is saved and spoken one way across the whole library.
Best practice.
- Use a reply for a line that must come out word for word, like a booking’s day and time.
- Save the whole output with
path=()when later prompts read several of its fields, like{{order.status}}. - Set
language=andtimezone=on the spec, or onfunction_toolsandtools_schema, so “tomorrow” means the business’s tomorrow.
Locked arguments
- The model never sees a locked argument. A value it sends is replaced.
{{name}}and{{name.field}}read the call state. The tool is refused, with a hint, while the value is unset.{{call.name}}reads what the call carries, like{{call.remote_number}}or{{call.carrier_call_id}}. It readsstate.call, so every adapter has it.- A tool that locks an empty call value is refused:
this tool reads call.carrier_call_id, which this call does not carry. {{@name}}no longer exists. A tool that uses it fails when it is built, and the message names the new spelling.{{@call_id}}is now{{call.carrier_call_id}}or{{call.session_id}}.{{@phone_number}}is{{call.remote_number}}.{{@transcript}}isTranscript().- A
ToolRegistrybuilt withstate=checks each locked path when the registry is built. The path must exist, and its type must fit the parameter. - A path may name a whole shape or a list, like
"{{customer}}". Lock it as the only text, to a parameter that is anobject, anarrayor has no type. The tool then gets the object or the list. - A shape or a list inside a longer text, like
"Customer: {{customer}}", fails at build.
- Lock every value the caller does not choose: customer ids, account numbers, the call id, the brand.
- Build the
ToolRegistrywithstate=. A typo in a path then stops the agent at startup, not in the middle of a call.
Send the transcript
Transcript is a locked argument that reads the call’s transcript so far.
A tool the model calls reads it from the call’s CallHooks, the same as a tool on an event. On a plain agent with no CallHooks, it is an empty list.
int
default:"200"
The newest messages to send. It must be 1 or more. A smaller number raises a Pydantic
ValidationError. Whatever the number, the result stays within TRANSCRIPT_MAX_BYTES (32 KiB): the oldest messages go first, and the newest is always kept.bool
default:"False"
False sends a list of {"role", "content"} messages. True sends one text with a role: content line per message.Webhooks
webhook_tool
str
required
The tool name. Letters, digits,
_ and -, at most 64.str
required
What the tool does and when to call it. The model reads it. A blank description falls back to the tool name.
str
required
The endpoint.
{{name}} placeholders may fill the path and query values from the call state, never the scheme or host.type[BaseModel]
required
The Pydantic model of the arguments. Its JSON Schema is what the model sees. See Inputs and outputs.
type[BaseModel]
required
The Pydantic model of the answer. See Outputs. Use
Done for a webhook that answers with an empty body.Mapping[str, object] | None
default:"None"
Arguments filled from the call state and hidden from the model.
str | Sequence[str]
default:"()"
A line said as the tool starts.
returns_data | ends_conversation
default:"returns_data"
ends_conversation ends the call once the tool succeeds.provider_default | cancel | continue
default:"provider_default"
What the caller’s speech does to a running call.
bool
default:"False"
Run the tool only after the announce line has played.
bool
default:"True"
False hides a result that succeeded from the model.str
default:""
How the model should use the result.
WebhookAuth | None
default:"None"
The credential, read at each call.
raw | envelope
default:"raw"
raw sends the arguments as the JSON object. envelope sends tool_name, description, arguments, a timestamp and context. The context holds what the call carries (state.call, without empty fields). An event tool adds trigger_event and transcript. So the receiver knows which tool called it.bool
default:"True"
False answers the model {"ok": true, "accepted": true} at once and sends the request in the background. A failed send is only logged. Call await webhook.drain() when the call ends. The tool’s output must then be Accepted, or it fails at build.str | None
default:"None"
Signs the body.
X-Signature-256 is its HMAC-SHA256 in hex. A GET webhook has no body to sign.str
default:"POST"
GET puts the arguments in the query. The others send them as JSON.
Mapping[str, str] | None
default:"None"
Fixed headers. Put a secret in
auth instead.float
default:"10.0"
Seconds for the whole call: DNS, connect, redirects and the answer. From 1 to 60.
bool
default:"False"
Allow private, loopback and link-local addresses, and plain
http.webhook_tool takes these options as keywords after its fixed arguments. The
WebhookOptions type, from unmute_core.tools, names them all for a type checker:
method, headers, hmac_secret, timeout, allow_private_network, auth,
body, tool_name and wait_for_response. A value out of range raises a Pydantic
ValidationError, like a timeout of 0 or a body that is not raw or
envelope. An unknown option raises TypeError.
- The URL is checked when the tool is built. The scheme must be
https, orhttpwithallow_private_network. The host must be there. User info, a fragment and a port outside 1 to 65535 are refused. - When the webhook answers with an error, the model reads the status and a short excerpt of the body. The excerpt is the
error,messageandcodeof a JSON body, or the first 300 characters of the text. - The tool events keep only the status. They never carry text that another service sent. In code,
ToolError(message, event_text=...)sets the two apart.
- A GET tool whose input or
lockednames a query value of the URL fails at build. Rename the argument or drop the value from the URL. - A call whose argument clashes with a URL query value is refused with an error that names the argument.
- With
auth, a fixedAuthorizationheader fails at build, in any letter case.authsends the credential.
WebhookAuth
WebhookAuth.bearer(token_env) sends Authorization: Bearer <token>.
WebhookAuth.api_key(token_env, header) sends the token as it is, in header.
The library reads the token at each call, so a new token works without a restart.
str
default:"''"
The environment variable that holds the token, in UPPER_SNAKE case. The name is read exactly, and case matters. An unset variable refuses the call, and the log names the variable.
str
default:"X-API-Key"
The header
api_key sends. bearer always sends Authorization. WebhookAuth.api_key defaults it to X-API-Key.Callable[[], str | Awaitable[str]] | None
default:"None"
A function that returns the token. It may be
async. It runs at each call. Use it in place of token_env.token_env and token.
Time and size limits
timeoutis one deadline for the whole request: DNS, the connection, every redirect and the answer. It is 10 seconds by default.- The request and the answer may each be 64 KiB at most. A larger one fails the tool call, and the model reads the error.
- With
wait_for_response=False, the model reads{"ok": true, "accepted": true}at once. The request goes on in the background. The tool’soutputis thenAccepted.
- Keep the 10 second default. Lower it for a lookup the caller waits on.
- Use
wait_for_response=Falsefor a notification nobody waits for, like telling a CRM about a booking.
UnmuteAgent and UnmuteFlows get
them from call=. A plain agent gets them from hooks=call. See
Use it in your agent.
Private addresses
allow_private_networkis off by default.- Off, the webhook refuses private, loopback and link-local addresses. It refuses plain
httptoo. - On, it accepts them.
localhost or a cloud metadata address, a tool
could reach services inside your network. So every address must be public. The
host name is looked up once, and the connection goes to the address that was
checked. A second lookup cannot send it somewhere else.
Best practice.
- Keep it off in production.
- Turn it on only for your own internal service, or for
localhostwhile you develop. - Never turn it on when someone else writes the URL.
Built-in tools
- Give
current_datetime_toolan IANA zone, likeEurope/Madrid. Another name raisesValueError. call_typeisinboundoroutbound, andnullwhen the call has nodirection.- When the call has no phone number,
user_phone_number_toolreturns{"available": false, "phone_number": null, "call_type": null, "reason": ...}. - Both take
name=anddescription=to change what the model reads. The defaults arecurrent_datetimeanduser_phone_number. - The tool reads
state.call, so the adapter needs nothing more.
Watch tool calls
- The sink gets names and timing only. It never gets argument values.
sourceismodel, oreventfor a tool on an event. msis the time the tool ran. A wait for the announce line is not in it. A cancelled call ends withtool.failedand the errorcancelled.- A sink that raises is logged. It never breaks the call. A sink the registry already holds is not added again.
only()andwith_tools(*tools)keep the sinks.- In a call, use the call’s hooks instead. See Hooks.
Advanced
Rules for a hand-written schema
A customToolSpec takes the JSON Schema of its arguments as parameters. See
Your own kind of tool. A schema built with
model_json_schema() already follows these rules, so local_tool and
webhook_tool always do.
The schema is JSON Schema 2020-12 with a fixed list of keywords. Anything else fails when the tool is built, and the message names the keyword.
In that schema:
definitionsand$idare refused. Use$defsand local#/$defs/...references.formatand the notes are hints. Nothing checks them.nullableis not JSON Schema, so it is refused. Write"type": ["<type>", "null"]instead.- The root of the parameters is a plain object schema. It may hold only
$schema,$defs,type("object"),properties,required,additionalProperties,description,title,$commentandx-keys. Frameworks send only the rootpropertiesandrequired, so another root keyword, likeoneOf,anyOf,allOf,not,patternProperties,propertyNames,minProperties,maxPropertiesorif, would be lost on one of them. It fails when the tool is built. Move it into a property, or wrap the choice in one property. Nested schemas may use every keyword in the table.
- Build the schema from a Pydantic model with
model_json_schema(), so it cannot drift from your code. - Keep the root a plain object of named properties. Put a choice, like
oneOf, inside one property.
Arguments the schema does not name
- The model may send an argument the schema does not list. The call is refused with a message that names it: “bogus is not an argument of lookup. Call lookup again without it.” The model reads this and calls again.
- A schema stays open when it sets
additionalPropertiestotrueor to a schema, or when it has noproperties. It then keeps the extra arguments. - A custom
ToolExecutorwithchecks_extra_arguments = Truegets the extra arguments. It then refuses them in its own words.
Ask the registry
name in registrytests for a tool by name.keeps_running(name)says whether the caller’s speech must leave a running call of the tool alone. An unknown name givesFalse, so the call is cancelled.- The adapters use both. You need them only when you write your own adapter.
Your own kind of tool
ToolExecutor is the plug point for every way to run a tool. The library ships
Webhook and LocalHandler. You can add a third, for example one that runs your
tools through your own service.
rungets the model’s arguments, with the locked values filled and checked against the schema. It must be anasyncmethod. AToolSpecrefuses an executor with no asyncrun.- Raise
ToolErrorwith the words the model should read. Any other error becomes “failed before completing. Do not claim success.” - Add a
contextkeyword torunto get the event context. Without it,rungets only the arguments and the state. - Add
validate(arguments) -> dictto refuse bad input before a step asks the caller to confirm it. - Set
checks_extra_arguments = Trueon the class to get arguments the schema does not name. Without it, the registry refuses them first. - The executor never changes what the model sees. The output model, the announce line, the effect and the interruption rule stay on the
ToolSpec, the same for every kind of tool.PARAMETERSis the JSON Schema of the arguments, andBookingthe output model.
Troubleshooting
A tool call fails with “does not fit”
The tool returned a result that its output model does not take. The message names the first field that is wrong. Fix: return what the output model declares, or change the model. A failure the model should read is a raisedToolError, not a returned dict.
A tool call returns refused and nothing is said
The call reads a value that is not set yet. The result is {"refused": ...}
and holds a hint.
Fix: set the value first, or start the task the hint names. Check every
announce line too, because all of them must fill.
A webhook call fails with “could not authenticate”
The token variable is unset, or the token provider failed. Fix: set the variable named in the log, or fix the provider.A webhook to localhost is refused
Private and loopback addresses are off by default. Fix: turn onallow_private_network for the tool, for development only.
Where to go next
Tools
Add a tool to your agent, step by step.
Hooks
Run your code around each tool call.
Tasks
Offer tools to a step that saves values.
Unmute tools
How
agent.yaml declares the same tools.