> ## Documentation Index
> Fetch the complete documentation index at: https://unmute.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Unmute compiles to exactly three targets. Pipecat and LiveKit are code targets: compile writes a Python project you run. SLNG is a hosted target: compile writes a deployment body and SLNG runs the agent, so it has no `unmute dev`. Those three are the only values `provider` accepts in `targets.yaml`. Deepgram and ElevenLabs appear in these docs as model vendors, which is not the same thing as a target, and `slng` is both.
> The Go structs in `internal/spec` and `internal/ir` are the schema truth. Check a field against them, or run `unmute validate`, rather than against what you remember.

# Python tools

> A handler of your own in the package: what it receives, what it returns, and where it is copied.

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](/build/tools/webhook) 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:`](/build/tools/hosted) instead.

On this page:

* [Quickstart](#quickstart) - add one callable
* [The block](#the-block) - the one key it takes
* [The rules the function follows](#the-rules-the-function-follows) - five rules
* [When the handler is slow](#when-the-handler-is-slow) - cover the wait
* [Where it ends up](#where-it-ends-up) - inside the generated project
* [Share a helper between tools](#share-a-helper-between-tools) - one source file, two callables
* [Credentials, when a real handler needs one](#credentials-when-a-real-handler-needs-one) - names, never values
* [Advanced](#advanced) - modules, dependencies, data, and SLNG limits
* [Troubleshooting](#troubleshooting) - the two worth reading

## Quickstart

These files extend an existing package that declares `customer_id`. Add
`cancel_appointment` to the package tool list and the agent
[tool list](/build/tools/overview#define-once-attach-by-name), then validate the package.

```yaml tools/cancel_appointment.yaml expandable wrap theme={null}
description: Cancel the appointment outright. Call it only when the customer says plainly that they want to cancel, never when they want a different time.

input:
  type: object
  properties: {}

inject:
  - customer_id: "{{customer_id}}"

output:
  type: object
  properties:
    cancelled:
      type: boolean
    customer_id:
      type: string
  required:
    - cancelled
    - customer_id

local:
  handler: tools/cancel_appointment.py
```

```python tools/cancel_appointment.py theme={null}
def cancel_appointment(customer_id):
    return {"cancelled": True, "customer_id": customer_id}
```

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

<ParamField path="handler" type="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.
</ParamField>

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

| Rule                                                               | Why                                                         |
| ------------------------------------------------------------------ | ----------------------------------------------------------- |
| the function name matches the tool name                            | that is how the generated code finds it                     |
| its parameters match the `input` properties plus the `inject` keys | the call is built from both                                 |
| it returns the value your description and prompt expect            | the result goes back to the model                           |
| it may be `async def` on Pipecat and LiveKit                       | the code targets await an awaitable result                  |
| it imports nothing from Unmute                                     | the generated project does not depend on Unmute at run time |

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:

```yaml tools/record_complaint.yaml theme={null}
local:
  handler: tools/salon.py

announce: Let me get that written down.
```

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](/build/tools/overview#the-three-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:

```text theme={null}
build/pipecat/
├── bot.py
└── tools/
    ├── __init__.py
    └── cancel_appointment.py
```

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:

<CodeGroup>
  ```python LiveKit theme={null}
  result = tools.cancel_appointment.cancel_appointment(
      customer_id=ctx.userdata.customer_id
  )
  if inspect.isawaitable(result):
      result = await result
  return result
  ```

  ```python Pipecat theme={null}
  result = tools.cancel_appointment.cancel_appointment(
      customer_id=state.customer_id
  )
  if inspect.isawaitable(result):
      result = await result
  await params.result_callback(result)
  ```
</CodeGroup>

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:

```python tools/text_helpers.py theme={null}
def _words(text):
    return text.split()


def count_words(text):
    return {"count": len(_words(text))}


def first_word(text):
    words = _words(text)
    return {"word": words[0] if words else ""}
```

```yaml tools/count_words.yaml theme={null}
description: Count the words in text.
input:
  type: object
  properties:
    text:
      type: string
  required:
    - text
local:
  handler: tools/text_helpers.py
```

```yaml tools/first_word.yaml theme={null}
description: Return the first word in text, or empty text when there are no words.
input:
  type: object
  properties:
    text:
      type: string
  required:
    - text
local:
  handler: tools/text_helpers.py
```

Merge these names into both existing lists; keep the other agent fields:

```yaml agent.yaml theme={null}
tools:
  - count_words
  - first_word

agents:
  assistant:
    tools:
      - count_words
      - first_word
```

From the package directory, compile the declared targets:

```sh Terminal theme={null}
unmute validate
unmute compile
```

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.

<Note>
  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.
</Note>

### 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](/build/tools/webhook) or
[MCP server](/build/tools/mcp).

## Credentials, when a real handler needs one

```python theme={null}
token = os.environ["CRM_API_TOKEN"]
```

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](/targets/overview#targets-yaml) 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](/build/tools/knowledge) 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:`](/build/tools/hosted), 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:

```python theme={null}
def check_order(order_number: str) -> dict:
    status, delivers_on = ORDERS.get(order_number.strip().upper(), ("unknown", ""))
    return {"status": status, "delivers_on": delivers_on}


from pydantic import BaseModel


class Input(BaseModel):
    order_number: str


class Output(BaseModel):
    delivers_on: str | None = None
    status: str | None = None


def handler(input: Input) -> Output:
    result = check_order(order_number=input.order_number)
    return Output(**result)
```

`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

| Symptom                                       | Cause                                                   | Fix                                                                                 |
| --------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `ModuleNotFoundError` for a helper            | The imported file was not selected as a handler         | Use the single-file helper pattern above                                            |
| An SDK imports locally but fails in the image | Local installations do not enter generated dependencies | Check `pyproject.toml`; use a remote tool when a required dependency is unsupported |
| A handler cannot open an adjacent file        | Arbitrary data files are not bundled                    | Keep small constants in source or read the data through a remote service            |
| An edit disappears on compile                 | The edit was under `build/`                             | Change the authored handler and regenerate                                          |

### `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:

```text wrap theme={null}
pipecat: environment variables referenced but not declared in secrets: CRM_API_TOKEN (tools/sync_customer.py os.environ)
```

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

<Columns cols={2}>
  <Card title="MCP servers" icon="plug" href="/build/tools/mcp">
    Offer a whole server's tools at once.
  </Card>

  <Card title="Secrets" icon="key" href="/reference/secrets">
    Every seam a credential travels through.
  </Card>
</Columns>
