> ## 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.

# Webhook tools

> Call your own API: a URL from the environment, a path that renders per call, and values the model never sees.

The everyday tool. The model decides to call it, Unmute builds the request, and
your API answers.

Reach for a webhook when:

* you already have an API to call
* the URL is a deployment choice, not something to hardcode
* you want no code of your own to write or review

If the work is logic rather than a request, or there is no API to call, write a
[Python handler](/build/tools/python) instead.

A `webhook:` tool works on livekit and pipecat. It is refused on an slng
target: SLNG owns a tool's code, version and gate pipeline, so there is
nowhere in a package for a URL and its credential to write to there.
Reference a tool your organisation already has on the platform with
[`slng:`](/build/tools/hosted) instead.

On this page:

* [The block](#the-block) - the four keys
* [The path renders per call](#the-path-renders-per-call) - variables in the URL
* [Authentication](#authentication) - bearer or API key
* [What the model fills in, and what it cannot](#what-the-model-fills-in-and-what-it-cannot) - input against inject
* [Advanced](#advanced) - naming the base URL, describing the result
* [Troubleshooting](#troubleshooting) - the refusals, and their fixes

```yaml tools/confirm_appointment.yaml expandable wrap theme={null}
description: Confirm that the existing appointment stays as booked. Call it when the customer says the time works.

input:
  type: object
  properties: {}

inject:
  - customer_id: "{{customer_id}}"
  - dialed_number: "{{dialed_number}}"
  - channel: phone

webhook:
  url_env: SALON_API_URL
  base_url: https://api.example.com
  path: /customers/{{customer_id}}/appointments/confirm
  auth:
    type: bearer
    token_env: SALON_API_TOKEN

announce: One moment while I confirm that.
effect: returns_data
interruption: provider_default
```

This is a worked example rather than a quote. A webhook tool naming both
`url_env` and `base_url` this way compiles to livekit and pipecat; an slng
target refuses it, for the reason in the note above. `examples/hotel-concierge`
ships no webhook tool: its places-search tool reaches SLNG through the
hosted-tool block instead.

`announce:` is the one line worth knowing about here, because a webhook is the
tool most likely to keep the caller waiting. The agent speaks that exact sentence
as the call starts and does not wait for it to finish, so the answer arrives no
later than it would in silence. It is a fixed sentence, and `{{variables}}` are
refused. Full rules in [the behavior fields](/build/tools/overview#the-three-behavior-fields).

## The block

<ParamField path="url_env" type="string">
  An UPPER\_SNAKE environment variable name holding the base URL. Required on LiveKit and
  Pipecat. At least one of `url_env` and `base_url` must be present; no URL is inferred.
</ParamField>

<ParamField path="base_url" type="string">
  A literal HTTPS base URL. Omit it on code targets, which read `url_env` instead. The
  SLNG target refuses authored webhooks: publish the tool on SLNG and use a hosted `slng`
  reference.
</ParamField>

<ParamField path="path" type="string">
  Path appended to the base URL. A non-empty path starts with `/` and may use
  `{{variable}}` tokens, whose values are URL-encoded. Omit to use the base URL alone.
</ParamField>

<ParamField path="auth" type="object">
  Authentication using the fields below. Omit to add no authentication header.
</ParamField>

`url_env` holds a **name**, so the base URL is a deployment choice rather than a
package one: staging and production run the same package against different APIs.
Pipecat and LiveKit read it at run time and never look at `base_url`.

## The path renders per call

```yaml theme={null}
  path: /customers/{{customer_id}}/appointments/confirm
```

`{{customer_id}}` is a [variable](/build/variables), rendered when the tool is
called, and the rendered value is URL-encoded for you. Because it renders per
call rather than once at session start, a variable that only gets its value
once a task assigns it partway through the call is fine here.

If the variable has no value when the model calls the tool, the call is refused
and the model is told what to ask the caller for, rather than sending a
half-formed request.

## Authentication

```yaml theme={null}
  auth:
    type: bearer
    token_env: SALON_API_TOKEN
```

<ParamField path="type" type="string" required>
  Accepts `bearer` or `api_key`. Required when `auth` is present; no scheme is inferred.
</ParamField>

<ParamField path="token_env" type="UPPER_SNAKE name" required>
  The environment variable holding the token. Always a name, never a value.
</ParamField>

<ParamField path="header" type="header name" default="X-API-Key">
  Legal on `api_key` only.
</ParamField>

Every name you use here goes in `agent.yaml` under
[`secrets:`](/reference/secrets). The compiler also infers these fields as
required environment names, so leaving one out of `secrets:` warns without
dropping it from generated environment instructions or checks.

`api_key` and its header reach livekit and pipecat exactly as written. An
slng target never sees this field at all: `webhook:` is refused there, so
there is no bearer-only rewrite to plan around any more.

## What the model fills in, and what it cannot

`input` is the model's half of the request. `inject` is yours.

```yaml theme={null}
input:
  type: object
  properties: {}

inject:
  - customer_id: "{{customer_id}}"
  - dialed_number: "{{dialed_number}}"
  - channel: phone
```

Injected values are merged into the call and **never shown to the model**, so it
can neither see them nor overwrite them. That is the place a customer id, a
captured slot, or the number the call went out to rides along.

The tool above takes no parameters at all: everything the API needs is injected.
The model can call it or not call it, and that is the whole of its authority. It
cannot invent a customer id.

A value that is exactly one `{{token}}` keeps that variable's declared type, so a
number stays a number.

Every injected value must be a scalar. Strings, numbers, booleans, and null are
legal; maps and lists are refused.

## Advanced

### Naming the base URL

`base_url` exists for a hosted target: that is where SLNG used to store the
URL in the tool body it pushed, since there was no environment for it to read
at run time there. An slng target refuses a `webhook:` block outright now,
for the reason at the top of this page, so there is no longer a target that
reads `base_url`. Name `url_env` for livekit and pipecat, which read it at
run time and never look at `base_url`.

### Describing the result

```yaml theme={null}
output:
  type: object
  properties:
    slots:
      type: array
      items:
        type: object
        properties:
          slot_id:
            type: string
          start_time:
            type: string
        required:
          - slot_id
          - start_time
  required:
    - slots
```

`output` is optional author documentation. The compiler checks that it is a
JSON Schema object, but no generator sends it to the model or writes it to the
compile report, and nothing checks the API's response against it either. The
example records the shape the API is expected to return; teach the model how
to use that data in the tool description or prompt.

A [`local:`](/build/tools/python) handler's `output:` was the one exception,
checked on an slng target because SLNG ran that handler itself. That block is
refused there now, and the check moved with the code: a hosted `code` tool's
module carries its own `Output` model, and SLNG reads the result shape off
that. See [Python tools](/build/tools/python#what-the-slng-sandbox-expects)
and [Hosted tools](/build/tools/hosted).

## Troubleshooting

### Both code targets refuse a tool with no `url_env`

Drop `url_env` and both code targets refuse, and the message still names a
hosted target as the reason the field exists at all:

```text theme={null}
livekit: livekit target reads a webhook base URL from the environment: tool
  "confirm_appointment" needs url_env, keeping base_url for a hosted target
```

**Fix:** add `url_env` with the UPPER\_SNAKE name of the variable that holds the
base URL, and keep `base_url` if the package also has a hosted build.

### A `{{token}}` in the path names nothing

A token that names nothing at all fails at compile time:

```text theme={null}
tools/confirm_appointment.yaml:7: tool "confirm_appointment" webhook.path references
  {{not_a_variable}}, which is not a declared variable
```

**Fix:** declare the variable under `variables:`, or correct the spelling in the
path.

### `token_env` holds a value instead of a name

Again a name, never a value. A token written in place of a name is refused:

```text theme={null}
livekit: tool "confirm_appointment" auth token_env must be an UPPER_SNAKE environment
  variable name, never a secret value
```

**Fix:** put the variable's name here and the token itself in your environment or
secret store.

### An `auth` field belongs to the other scheme

A field from the other scheme is refused too, and the message says why:

```text wrap theme={null}
livekit: tool "confirm_appointment" auth header is not a bearer field: bearer always sends
  Authorization
```

**Fix:** drop `header` from a `bearer` block, or switch `type` to `api_key` if
the API really wants a named header.

### A key is in both `input` and `inject`

A key cannot be in both lists, and the refusal explains the reason rather than
just the rule:

```text wrap theme={null}
tools/confirm_appointment.yaml:5: tool "confirm_appointment" injects "customer_id", which
  is also an input property; an injected value is hidden from the model, so it cannot
  double as a parameter the model fills in
```

**Fix:** decide who supplies it. Leave it in `inject:` for a value the model must
never see, or in `input:` for one the model fills in.

## Where to go next

<Columns cols={2}>
  <Card title="Python tools" icon="file-code" href="/build/tools/python">
    When the request needs code of your own, like a signature.
  </Card>

  <Card title="Variables" icon="braces" href="/build/variables">
    Where `{{customer_id}}` comes from.
  </Card>
</Columns>
