Skip to main content
Some tools need code rather than a request. Write the function in your package and name it from the tool file. Reach for a Python handler when:
  • the work is logic, not a request: a calculation, or a lookup in data you already have
  • you are using a library already included in the generated project
  • the request needs a signature, a custom header scheme, or a retry rule
If it is just an authenticated HTTP call, use a webhook tool instead. It needs no code from you and no code review. A local: handler works on livekit and pipecat. It is refused on an slng target: SLNG owns a tool’s code, version and gate pipeline, so a handler of yours has nowhere to run there. Reference a tool your organisation already has on the platform with slng: instead. On this page:

Quickstart

These files extend an existing package that declares customer_id. Add cancel_appointment to the package tool list and the agent tool list, then validate the package.
tools/cancel_appointment.yaml
tools/cancel_appointment.py
Those two are a worked example rather than a quote: they are the smallest shape that shows a handler receiving an injected value. For real ones, every tool in examples/salon-concierge is a local handler, and all seven share the one tools/salon.py.

The block

string
Path to a Python file inside the package. Omitted means tools/<tool-name>.py; that file must exist. The file defines the callable described below.
The function inside that file still has the tool name. For a tool named cancel_appointment, omitting handler selects tools/cancel_appointment.py.

The rules the function follows

The handler above takes customer_id even though input.properties is empty, because customer_id is injected. The model never sees that value and cannot invent one.

When the handler is slow

A local handler that reads a calendar or a database keeps the caller waiting the same way a webhook does. announce: gives the agent one fixed sentence to speak as the handler starts, and it does not wait for that sentence to finish, so the result arrives no later than it would in silence:
tools/record_complaint.yaml
That one is real, from examples/salon-concierge. Keep the line shorter than the gap it covers: a long one runs into the answer and breaks its own promise of a wait. The fixtures above carry no announce:, because they return instantly and a tool that speaks before doing nothing slow is just noise. Full rules in the behavior fields. Two more things decide whether a tool should carry one at all. The line is spoken when the tool is called, not when it succeeds, so a tool that can refuse the call will sometimes promise something it then does not do. The salon’s booking tool refuses a save that arrives unconfirmed, and the caller heard “putting that through now” followed by a question asking their permission. And only one line belongs in a turn: a task group’s own announce: and a tool’s fire together when the group’s first step calls that tool, which the caller hears as two promises to go and look.

Where it ends up

unmute compile copies the file into the generated project, next to the code that calls it:
The generated project imports it as a plain module and calls it like a plain function. The two targets differ only in how the result leaves the method:
A LiveKit @function_tool method returns its value directly. A Pipecat function hands it to params.result_callback instead, which is how Pipecat’s own function-calling convention reports a result. Either way the same handler file runs unchanged: only the three lines that call it differ.

Share a helper between tools

Keep two callables and their helper in one authored file. Both tool files point to it, so you maintain the helper once. This works on LiveKit and Pipecat. Create these three complete files in an existing package:
tools/text_helpers.py
tools/count_words.yaml
tools/first_word.yaml
Merge these names into both existing lists; keep the other agent fields:
agent.yaml
From the package directory, compile the declared targets:
Terminal
Each build contains tools/count_words.py and tools/first_word.py. Both contain the source above. The generated wrapper imports each tool’s module and calls the function matching its tool name.
This shares authored code, not module state. Each emitted copy is a separate Python module. Do not use its globals to share a cache or connection between tools.

Separate imported helpers are not bundled

A layout where two handler files import tools/shared.py is not supported natively. The compiler reads the files named by local.handler; it does not follow their imports or copy the rest of the source directory. The original filename is not the emitted module name: tools/text_helpers.py above becomes one copy per tool. An import of tools.text_helpers would therefore fail. Put reusable functions in that same authored file, or move the operation behind a webhook or MCP server.

Credentials, when a real handler needs one

Only the name belongs in the file. Put that name in agent.yaml under secrets:; keep the value in your environment or secret store.

Advanced

Dependencies, files, and regeneration

Handlers run from the generated project root, /app in the emitted images. Their module path is tools.<tool-name>, regardless of the authored handler’s location. Imports use that generated layout, not your source package directory. The runtime requirement lives in generated pyproject.toml, and the compiler selects dependencies from the chosen providers. Python’s standard library is available. Check that generated dependency list before importing another library. A local.dependencies declaration is refused on both code targets. LiveKit’s target pins only override recognized packages; there is no supported package field for adding arbitrary third-party dependencies. A library installed on your laptop does not become part of the deployed image. Adjacent JSON files, templates, certificates, and other module trees are not copied with a handler. Knowledge documents have their own declared inclusion path; that is not a general-purpose file packaging hook. For a small constant, keep it in the handler source. Otherwise use a remote service until the package supports the files and dependencies your operation needs. Edit the authored file and recompile to update its generated copies. Compilation replaces added modules and changes to the generated Dockerfile or dependency manifest. Unmute has no authored lifecycle-hook or custom build-file API.

What the SLNG sandbox expects

A local: handler no longer reaches SLNG at all, so none of this is a refusal you will meet from unmute. It is worth knowing anyway, because a tool SLNG hosts runs under these rules, and if you write one in the SLNG dashboard and then reference it with slng:, they are the rules it runs under. SLNG calls the handler itself, synchronously. A plain def that runs its own event loop internally, with asyncio.run(...), is fine: only the entry point has to be synchronous. SLNG derives the schemas by introspection. A hosted code tool’s module defines an Input model, an Output model and a handler() taking one Input, and the platform reads the parameter and result schemas off those classes. unmute pull mirrors that module into your package, so the same module is what runs on livekit and pipecat too:
handler() is what SLNG calls, and it is also what a generated livekit or pipecat project calls, through the mirror. A return value that does not fit Output fails there rather than reaching the model unnoticed. On the code targets output: on a tool file stays author documentation, the same as on a webhook tool: nothing sends it to the model or checks the handler’s return value against it.

Troubleshooting

unmute validate warns that a credential is not declared

unmute validate reads the handler, finds the names it looks up, and warns when agent.yaml forgot to declare one:
The literal lookup also makes the compiler include the name in generated environment instructions and the startup check even if the declaration is missing. Fix: add the name to secrets: in agent.yaml. Declaring it removes the warning and keeps the explicit inventory complete.

A hosted handler fails the moment it reaches the network

Code on SLNG has no internet access at all. A handler that imports requests, httpx, urllib, urllib3, aiohttp, http.client or socket and reaches the network fails at connection time inside the sandbox. Fix: move that work into an API-request tool, which SLNG runs itself, outside the sandbox.

Where to go next

MCP servers

Offer a whole server’s tools at once.

Secrets

Every seam a credential travels through.