- 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
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 - add one callable
- The block - the one key it takes
- The rules the function follows - five rules
- When the handler is slow - cover the wait
- Where it ends up - inside the generated project
- Share a helper between tools - one source file, two callables
- Credentials, when a real handler needs one - names, never values
- Advanced - modules, dependencies, data, and SLNG limits
- Troubleshooting - the two worth reading
Quickstart
These files extend an existing package that declarescustomer_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
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.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
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:
@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
agent.yaml
Terminal
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 importtools/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
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
Alocal: 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:
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 importsrequests, 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.