> For the complete documentation index, see [llms.txt](https://docs.promptshuttle.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.promptshuttle.com/tools/caller-hosted-tools.md).

# Caller-Hosted Tools

Most external tools live at a fixed address — a public API, a service you run at a known hostname. You put the URL on the tool and PromptShuttle calls it.

Caller-hosted tools are the other case: the tool is served by **your own application**, the same one making the request. That means its address is different on every machine your app runs on — your laptop, a preview environment, production — and none of those addresses belong on a shared tool definition.

So don't store one. Give the tool a **relative path** and let each request say where its tools live.

```json
{
  "name": "search_categories",
  "toolType": "external",
  "webUrl": "/api/v1/tools/categorization/search?schema_id=[[schema_id]]"
}
```

```bash
curl -X POST https://app.promptshuttle.com/api/v1/flows/categorize/runs \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "X-Shuttle-Callback-Url: https://app.example.com" \
  -H "Content-Type: application/json" \
  -d '{ "parameters": { "schema_id": "abc123" } }'
```

One tool definition. Your developer machine answers its own tool calls, production answers its own, and neither is written down anywhere.

## Absolute or relative

The shape of `webUrl` is the whole switch. There is no separate flag.

| `webUrl`                         | Meaning                                                                    |
| -------------------------------- | -------------------------------------------------------------------------- |
| `https://api.example.com/search` | Fixed host. Resolves as it always has; the callback header is not needed.  |
| `/api/v1/search`                 | Caller-hosted. Origin comes from `X-Shuttle-Callback-Url` on each request. |

Environment overrides compose with this: a tool can be caller-hosted by default and pinned to a real host in one environment.

```json
{
  "webUrl": "/api/v1/search",
  "environmentOverrides": {
    "production": { "webUrl": "https://api.example.com/v1/search" }
  }
}
```

`[[parameter]]` placeholders work in relative paths exactly as in absolute URLs.

## A missing origin fails the call

If a caller-hosted tool is invoked on a request that carries no `X-Shuttle-Callback-Url`, the tool call **fails**. The model receives an explicit error naming the cause, and the run records it. No stored URL is substituted.

This is deliberate, and it is worth understanding why, because the alternative looks harmless:

> A flow ran on a developer machine with no callback origin configured. Its tools fell back to the URL on the definition — the deployed app. Production had never heard of that developer's data, so it answered honestly: `{"results": [], "total": 0}`. Three times. The model concluded no categories existed, invented a plausible taxonomy, and returned an answer with 0.85 confidence. Downstream, a batch import ran against nothing at roughly 100× the normal cost and reported success.
>
> Every tool call was a 200. The run is recorded as `Succeeded`.

A wrong answer that looks exactly like a right one is worse than an error. So: send the header from every environment, production included. Treat it as part of your PromptShuttle configuration, not as a debugging aid you switch on when something breaks.

## Registering allowed origins

The origin arrives in a request header, so it needs a boundary. Otherwise an API key would buy the ability to point PromptShuttle's outbound calls at any host on the internet and read back whatever it returns.

Register the origins your apps serve tools from under **Settings → Callback origins**, one per line:

```
https://app.example.com
*.ngrok-free.dev
localhost:5173
```

* Scheme and port are optional. When present, they must match.
* A leading `*.` matches sub-domains — `*.ngrok-free.dev` matches `lexi-wanning.ngrok-free.dev` but not `ngrok-free.dev` itself, and not a look-alike like `evil-ngrok-free.dev`.
* **With no origins registered, caller-hosted tools do not run.** Deny-by-default: an unregistered origin fails loudly rather than resolving to something plausible.

Private and reserved IP ranges are blocked regardless of the allowlist, so a registered origin still cannot reach internal infrastructure. That check is relaxed only in local development builds — which is why `localhost` in the example above works against a local PromptShuttle but not against the hosted one. To reach a laptop from hosted PromptShuttle, use a tunnel.

## Per-run tokens

Your tool endpoints should authenticate their callers. A single secret stored on the tool definition can't do that once the host varies per request — a developer's tunnel would present production's token.

Send the secret with the request instead:

```
X-Shuttle-Callback-Token: <this instance's secret>
```

PromptShuttle holds it for the duration of the run, never persists it, and presents it back on caller-origin calls under `X-Tool-Token` — or whatever header name the tool's **Callback token header** field specifies. It takes precedence over any same-named header stored on the tool.

Each instance can then hold its own secret, and a leaked tunnel token is worth nothing anywhere else.

## Configuring the client SDK

The .NET client sends both headers on every request when configured:

```json
{
  "PromptShuttle": {
    "ApiKey": "...",
    "CallbackUrl": "https://app.example.com",
    "CallbackToken": "..."
  }
}
```

Set `CallbackUrl` per deployment — that is the point of it. Leave it unset only if none of your tools are caller-hosted.

## Seeing which host answered

Every tool call records the endpoint it was actually placed against, plus where that origin came from. A run served by a tunnel is distinguishable from one served by the deployed app at a glance:

* **Invocation log** — each tool-call step shows the endpoint and a badge reading *tool definition*, *environment binding*, or *caller-supplied origin*.
* **`get_run` (MCP)** — a `toolCalls` array with `targetUrl` and `originSource` per call, and `callbackOrigin` on the run itself.
* **Streaming events** — `tool.started`, `tool.completed` and `tool.failed` carry `targetUrl` and `originSource`.

## Header reference

| Header                     | Required                | Description                                                                                            |
| -------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `X-Shuttle-Callback-Url`   | For caller-hosted tools | Origin serving this caller's tools, e.g. `https://app.example.com`. Any path is ignored.               |
| `X-Shuttle-Callback-Token` | No                      | Secret presented back on caller-origin calls. Never stored.                                            |
| `X-Shuttle-Debug-Url`      | No — deprecated         | Legacy alias of `X-Shuttle-Callback-Url`. Also attaches a `DebugUrl:` tag. Prefer the callback header. |

## Migrating an existing tool

1. Register your origins under **Settings → Callback origins** — including production.
2. Set `CallbackUrl` (or send `X-Shuttle-Callback-Url`) everywhere the app runs. Deploy that first.
3. Change the tool's `webUrl` from `https://app.example.com/api/v1/search` to `/api/v1/search`.

Steps 1 and 2 change nothing on their own: while the tool's URL is still absolute, a callback origin only redirects the host, which is the behaviour the old `X-Shuttle-Debug-Url` header already had. Step 3 is the switch, and it is reversible.
