Skip to main content
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 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: instead. On this page:
tools/confirm_appointment.yaml
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.

The block

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.
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.
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.
object
Authentication using the fields below. Omit to add no authentication header.
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

{{customer_id}} is a variable, 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

string
required
Accepts bearer or api_key. Required when auth is present; no scheme is inferred.
UPPER_SNAKE name
required
The environment variable holding the token. Always a name, never a value.
header name
default:"X-API-Key"
Legal on api_key only.
Every name you use here goes in agent.yaml under 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.
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

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: 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 and Hosted tools.

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

Python tools

When the request needs code of your own, like a signature.

Variables

Where {{customer_id}} comes from.