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

# Build tenant custom tools for AI agents

> Register your own HTTPS webhook as a tool your AI agents can call: own the endpoint, give it a JSON-Schema input contract and an HMAC secret, dry-fire it, attach it per agent, and rotate the secret safely.

# Build custom tools for AI agents

A custom tool is **your** HTTPS endpoint wired into the agent's tool list.
The LLM decides to call it the same way it decides to call a built-in
(`lookup_contacts`, `send_sms`) or a marketplace tool — but the run forwards
the call to a webhook **you own, on your infrastructure**. Custom tools are
the way agents read your order system, write into your CRM, or reach any
internal API without you shipping code into the runtime. This guide walks
the create–test–attach–rotate lifecycle and the failure modes to guard.

The endpoint paths below are relative. Send them against
`https://api.orbit.devotel.io/api/v1`. The dashboard surface for everything
below lives at **`/agents/custom-tools`**.

## 1. What a custom tool is (and isn't)

Every tool call the LLM emits has a **name**, a **JSON-Schema input
contract** the model fills, and an **executor** that runs it. The three
classes differ only in who owns the executor:

* **Built-in / marketplace tools** — the executor is Orbit-hosted code. You
  flip them on per agent; you can't edit what they do.
* **Custom tools** — the executor is an **HTTPS webhook you register** in
  the tenant-wide registry. You supply the URL, the schema, an optional
  HMAC secret, and a timeout. Orbit validates the LLM's args against your
  schema, POSTs `{ tool_name, args, agent_id, agent_run_id, conversation_id }`
  to the webhook, and returns the response body to the agent as the tool
  result.
* **Per-agent attach** — the registry is tenant-wide; each agent opts into
  specific tools from its **Tools tab** on the agent detail page
  (`/agents/:id`). The page header cites the Twilio Functions-vs-Service
  separation: the registry holds the function definition; the agent chooses
  which of them are callable. Deleting a registry item strips it from every
  agent list.

## 2. Create the registry item

One tool = one POST to the registry. Required: a slug, a description (the
LLM uses it to decide when to call), the executor HTTPS URL. Optional:
schema (recommended), secret, timeout, confirmation mode.

* `name` — lowercase snake\_case, 3–64 chars starting with a letter, and
  **immutable** (it's the id the LLM sees; renaming would break trained
  prompts). Verified: `/^[a-z][a-z0-9_]{1,62}[a-z0-9]$/`.
* `json_schema` — an object-root JSON-Schema with `properties` + `required`
  (works for both Claude and OpenAI tool-calling), ≤ 32 KB.
* `executor_url` — `https://` only. Private IPs, link-local, and cloud
  metadata endpoints are rejected at write **and again at dispatch** (DNS
  re-resolved on every call).
* `executor_secret` — if set, every dispatch carries an HMAC signature.
  Min 8 chars; encrypted at rest and never returned by any GET. The
  response exposes only `executor_secret_set: true`.
* `timeout_ms` — 1–60 s; 30 s default. Must stay under the agent turn's
  own deadline.
* `confirmation` — `"never"` (default) executes immediately; `"always"`
  parks each call in the approval queue for a human.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents/custom-tools \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "lookup_order_status",
    "description": "Fetch the live status of a customer order in the tenant CRM.",
    "executor_url": "https://tools.example.com/lookup-order",
    "executor_secret": "wh_sec_...",
    "timeout_ms": 15000,
    "confirmation": "never",
    "json_schema": {
      "type": "object",
      "properties": {
        "order_id": { "type": "string" }
      },
      "required": ["order_id"]
    }
  }'
```

| Field             | Rule                            | Why                                                                |
| ----------------- | ------------------------------- | ------------------------------------------------------------------ |
| `name`            | slug regex; unique per tenant   | LLM-visible id; collision with a built-in returns `422`            |
| `description`     | ≤ 1000 chars                    | the model reads it to decide when to call — write it for the model |
| `executor_url`    | `https://`; public host         | SSRF-checked at write and dispatch                                 |
| `executor_secret` | ≥ 8 chars; write-only           | signs the payload; omit → unsigned dispatch                        |
| `timeout_ms`      | 1000–60000                      | bound a slow webhook before the turn deadline                      |
| `json_schema`     | object root, `required` correct | the dispatch is rejected when the model's args don't validate      |

Roles: create/update/delete need `owner`, `admin`, or `developer`; the
read list is fine for the dashboard session. Every mutation publishes a
tenant-wide invalidation so agents pick it up on their next turn rather
than on the runtime's cache TTL.

## 3. Validate the schema and dry-fire before enabling

Create the tool with `enabled: false` (or flap the switch off in the
builder) until a live test passes. The builder dialog's **Test request**
panel fires a real dispatch without persisting anything — hit it once with
a stub args object (the form can generate one from `required`):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/agents/custom-tools/:id/test \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "args": { "order_id": "sample" } }'
```

```json theme={null}
{
  "ok": true,
  "status": 200,
  "duration_ms": 132,
  "response_body": { "order_id": "sample", "status": "out-for-delivery" },
  "error": null
}
```

The payload your endpoint receives for a test is
`{ tool_name, args, agent_run_id, test: true }`; for a real call it's
`{ tool_name, args, agent_id, agent_run_id, conversation_id }`. Return a
*small* body — it goes straight to the LLM as the tool result. Non-2xx,
timeouts, and unreachable hosts land in `error` with `ok: false`; the
result panel in the dashboard shows the same detail.

## 4. Attach it per agent (registry is tenant-wide)

The tool now exists for the whole tenant but is callable by **no agent**
until you opt in on that agent's **Tools tab** (`/agents/:id`). Per-agent
attach is a separate GET/PATCH on the agent — it controls
`config.custom_tool_ids`, an id list the runtime resolves at executor
build. Toggle `enabled` in the registry to cut the tool off for **every**
agent at once (the per-agent list still names it; the runtime skips it).

Because the registry is tenant-wide you can stage a tool in one dashboard,
attach it to your test agent, and promote by flipping `enabled: true` —
the same separation the page header calls out between the definition and
the per-service agent's allowlist.

## 5. Rotate the HMAC secret without downtime

The webhook rotation playbook applies one tool at a time: overlap the old
and new secrets for a short window, then drop the old.

1. Deploy the receiver change so **both** secrets verify (`validSecrets = [new, old]` in the verifier below).
2. PATCH the tool with the new `executor_secret`.
3. Wait one drain window (60 s, the longest timeout you allow).
4. Cut the old secret from the accept-list and redeploy. To revoke
   entirely, PATCH `executor_secret: null` — further dispatches go out
   unsigned; a receiver that *requires* a signature then rejects them.

```bash theme={null}
curl -X PATCH https://api.orbit.devotel.io/api/v1/agents/custom-tools/:id \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "executor_secret": "wh_sec_NEW_..." }'
```

A PATCH can change every field except `name`, so rotate during business
hours without touching the slug the prompts trained on.

## 6. Receive and verify the payload (receiver reference)

Each dispatch is a POST with
`X-Devotel-Tool-Signature: t=<unix-seconds>,v1=<sha256-hmac>` over
`<unix-seconds>.<raw-body>`, exactly the Stripe-style scheme so Stripe
verifier libraries work. Overlap-friendly verifier:

```js theme={null}
import express from "express";
import crypto from "node:crypto";

const SECRETS = [process.env.TOOL_SECRET_NEW, process.env.TOOL_SECRET_OLD]
  .filter(Boolean);
const app = express();

// Keep the RAW body — HMAC signs `<timestamp>.<exact-bytes>`, so any
// JSON re-serialization (even reorder) breaks the verify.
app.post(
  "/lookup-order",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.get("X-Devotel-Tool-Signature");
    const [t, v1] = (sig ?? "").split(",").map((p) => p.split("=")[1]);
    if (!t || !v1) return res.status(401).send("missing signature");
    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
      return res.status(401).send("stale signature");
    }
    const payload = `${t}.${req.body.toString("utf8")}`;
    const ok = SECRETS.some((s) =>
      crypto.timingSafeEqual(
        Buffer.from(crypto.createHmac("sha256", s).update(payload).digest("hex")),
        Buffer.from(v1),
      ),
    );
    if (!ok) return res.status(401).send("bad signature");

    const { args } = JSON.parse(req.body.toString("utf8"));
    // ...your handler; return a SMALL body, it goes to the LLM
    res.json({ order_id: args.order_id, status: "shipped" });
  },
);
```

## 7. Failure modes and retries

* **Schema mismatch** — the model's args must satisfy `required` in your
  schema. A sloppy schema (missing `required`) makes the executor reject
  the dispatch before your webhook is ever touched. Re-test after every
  schema edit.
* **Non-2xx** — the agent sees `ok: false` with your `status` and the same
  body you returned, so treat 4xx/5xx as a *deterministic* refusal. The
  agent's spoken failure text (if you configured `messages`) covers this.
* **Timeout** — `timeout_ms` caps each call; a webhook that takes longer
  tells the agent the tool errored today. Set it well under the 60 s
  ceiling and keep your handler's own work inside that budget.
* **`on_error`** — registry fields let you pick `speak` (say a safe
  tenant-authored line) or `fallback_tool` (chain to another registry
  tool) instead of surfacing a raw failure to the caller.
* **Confirmation queue** — `confirmation: "always"` means a pending
  approval, not an error; see [Tool approvals](/guides/agent-tool-approvals).
* **Revocation** — everyone with `owner/admin/developer` can delete; the
  cascade strips the id from every agent's `custom_tool_ids` in the same
  mutation, so no dangling reference.

## Related reading

* [Tool approvals](/guides/agent-tool-approvals) — page the approval pause
  queue when a tool is gated.
* [Agents from a prompt](/guides/agent-from-prompt) — preset tools; this
  guide covers the registry you build yourself.
* [Buy numbers](/guides/buy-numbers) — endpoint docs for voice tools the
  agent uses alongside custom tools.
