Custom tools
Give the agent a function on your server, connect it, and check that it works.
import { defineConfig, p } from "@mediansh/agent-tools";
import { db } from "@/lib/db";
export default defineConfig({
tools: {
orderStatus: {
description: "Look up the status of one of the customer's orders.",
input: {
orderNumber: p.string("The order number, like ORD-1042."),
},
async execute({ orderNumber }, { visitor }) {
if (!visitor.verified) return { ok: false, reason: "not_signed_in" };
const order = await db.orders.find(visitor.externalId, orderNumber);
if (!order) return { found: false };
return { found: true, status: order.status, eta: order.eta };
},
},
},
});import { median } from "@mediansh/agent-tools";
import config from "@/median.config";
export const { GET, POST } = median(config);Set up a tool
Install the package
@mediansh/agent-toolsAdd your Median key
Create a key in Settings → API with Create key. Put it in your server environment:
MEDIAN_KEY=median_key_...The widget's public key is derived from this same key, so there is no second secret. Keep it on the server.
Write a tool
Put median.config.ts in your app, as above. Each key under tools is the name the agent calls.
| Field | Required | Default |
|---|---|---|
description | Yes | |
input | No | {} |
risk | No | "low" |
execute | Yes |
The agent decides when to call a tool by reading its description. It also finds the tool by its name and description: a turn starts with the tools that match what the customer said, and the agent searches for the rest. Name what the tool does in the words a customer would use. execute gets validated input and a context that says who the visitor is. Its return value is what the agent reads. Config reference has every field.
Serve the route
median() returns GET and POST handlers that take a Web Request and return a Response. Mount both on one path.
Next.js
import { median } from "@mediansh/agent-tools";
import config from "@/median.config";
export const { GET, POST } = median(config);Request and Response
import { median } from "@mediansh/agent-tools";
import config from "./median.config";
const { GET, POST } = median(config);
export default {
async fetch(request: Request): Promise<Response> {
const { pathname } = new URL(request.url);
if (pathname === "/api/median") {
if (request.method === "GET") return GET(request);
if (request.method === "POST") return POST(request);
}
return new Response("Not found", { status: 404 });
},
};This shape runs on Bun, Deno and Cloudflare Workers. In Hono, pass c.req.raw to GET and POST. Where process.env is not available, pass the key with median(config, { key }).
median() also takes the config inline, as in median({ tools: { ... } }).
Connect the route
Open the route once with your key:
curl -H "Authorization: Bearer $MEDIAN_KEY" https://acme.com/api/median{
"connected": true,
"endpoint": "https://acme.com/api/median",
"tools": 1,
"message": "Connected. Median is syncing 1 tool."
}The sync runs after this response. See Check it synced.
Connect a route
There are three ways to connect a route. Connecting a URL that is already connected syncs it again.
| Way | How | Endpoint signs with |
|---|---|---|
| Open the route | GET it with Authorization: Bearer $MEDIAN_KEY | The key you sent |
| Dashboard | Agent → Tools, then Add an endpoint. Once one exists, use Add endpoint under Endpoints. | The newest Median key for a new endpoint. A URL that is already connected, or one set with Change endpoint, keeps the key it signs with |
| CLI | median tools endpoint add https://acme.com/api/median | The newest Median key for a new endpoint. A URL that is already connected keeps the key it signs with |
The dashboard and the CLI need an Admin or Owner and an existing Median key. Without a key they fail with "Create a Median key in Settings under API before connecting tools."
Your route checks every call against the MEDIAN_KEY on your server. If that key is not the one the endpoint signs with, syncs fail with "The endpoint refused the signature." After you rotate the key, open the route again.
The bearer header is required on every connect, including on localhost and with MEDIAN_TOOLS_URL set. Without it the route answers 401 unauthorized and connects nothing. Keep the key in server or CI secrets, never in browser code or a URL.
Set the public address
Without a setting, the route registers the URL the request arrived at, minus the query string. Set MEDIAN_TOOLS_URL, or the url option, when the server sits behind a proxy or a tunnel, or answers on more than one hostname.
MEDIAN_TOOLS_URL=https://acme.comMEDIAN_TOOLS_URL | Route | Registers |
|---|---|---|
https://acme.com | /api/median | https://acme.com/api/median |
https://acme.com/api/median | Any | https://acme.com/api/median |
An origin gets the route's path added, so one value serves several routes. A value with a path is used as written for every route. A value that is not a URL answers 400 median_endpoint_unknown.
URL rules
| Rule | Detail |
|---|---|
| Scheme | https. http only for localhost and 127.0.0.1 |
| Missing scheme | Added for you. https, or http for localhost |
| Private hosts | Refused with "That address points inside a network, not at the internet." |
| Length | 512 characters |
| Redirects | Never followed. The sync fails, and names the new address when it can |
| Endpoints | 10 per organization |
| Identity | One URL is one endpoint. A different URL, even for the same route, is a new endpoint |
Check it synced
The tools count in the connect response is the number of tools in your local config. The sync runs after the response, so check the result:
median tools listIt lists each endpoint with its status and last error, and each tool with its risk and whether it is on.
On Agent → Tools, each row under Endpoints shows one of these:
| Row | Meaning |
|---|---|
| Syncing | A sync is running and the last one did not fail |
| 3 tools · synced just now | The last sync worked |
| Sync failed, then the reason, in red | The last sync failed. The agent keeps the last good tools |
| Not synced yet | No sync has finished |
A failed row keeps showing Sync failed until a sync succeeds, even while a new one runs.
Tool errors lists every sync message and its fix.
When a sync runs
| Trigger | What happens |
|---|---|
| Connecting a route | A sync starts right away |
| A new conversation | Before the agent's first reply, Median syncs every endpoint and waits up to about 12 seconds |
| On demand | Sync in the endpoint row's menu, median tools endpoint sync, or the API |
Tools you deploy reach the next new conversation with no extra step. On-demand syncs are rate limited. See Rate limits. With more than one endpoint, median tools endpoint sync needs --url.
A sync changes the tool list like this:
- New tools arrive switched on.
- A tool's on or off switch survives later syncs.
- A tool that leaves the manifest is deleted.
- A failed sync keeps the last good tool set.
After a sync, each tool gets a readable name and summary on Agent → Tools. The page shows Writing tool descriptions... while that runs. The agent still reads your description.
Risk levels
risk decides what happens when the agent calls a tool.
| Risk | Badge on Agent → Tools | When the agent calls it | Who decides |
|---|---|---|---|
low | None | Runs right away. This is the default | The agent |
medium | Agent asks first | Runs right away. The agent is told to get the customer's explicit yes in the conversation first. Median does not check that it did | The customer, through the agent |
reviewed | Requires review | The agent is told to get the customer's yes first. The call then waits while an automated reviewer reads the conversation and approves or denies it. Calls it is unsure about, or cannot review, go to a teammate | The reviewer, or a teammate |
high | Team approval | The call waits as a request in the conversation until a teammate picks Approve and run or Deny. Unanswered requests expire after 24 hours | A teammate |
- Median adds the confirmation or sign-off instruction to your description. Do not write it yourself.
mediumdepends on the agent following its instructions. Usereviewedorhighfor any call that must not run unchecked.- A teammate can also run any switched-on tool from a conversation, at any risk, with no sign-off.
How tool calls run covers approvals, reviews and per-turn limits. Approvals and tool runs covers deciding them in the inbox.
The agent already searches your knowledge base. Keep documentation and policies there, not in a tool. Built-in abilities lists what the agent does without your code.
Test a tool
Call a tool with no conversation behind it:
median tools test orderStatus --input '{"orderNumber":"ORD-1042"}' --as user_123| Flag | What it does |
|---|---|
--input <json> | The tool's arguments. Checked against the synced schema before the call |
--as <id> | Calls as one of your customers, by the id you sign into the widget. The tool sees verified: true and that externalId |
--conversation <id> | Uses the stored visitor of a real conversation. Cannot be combined with --as |
--json | Prints the whole response as JSON, with ok, result, error, risk and conversationId |
- The call goes to the URL of the endpoint that serves the tool, signed the way the agent signs. Nothing appears in the inbox.
- Without
--asor--conversation, the visitor is unverified and has no id. Run an account tool this way once to check that it refuses. context.toolCallIdistest_and a UUID.context.conversationIdis the same value, or the real thread's id with--conversation.context.approvedByis"A test from the CLI".- The command exits with code 1 when
okis false, including refusals. - It reaches switched-on tools only, and needs an Admin or Owner. See Roles.
:::warning
high and reviewed tools run immediately in a test, with no approval. Test tools that change data against test accounts.
:::
A synced manifest does not prove a tool works. Test each tool with real input and read the fields it returns.
To run a tool on a real conversation as a teammate, use median tools run <conversation> <tool>. See How tool calls run and the CLI reference.
Local development
Median calls your route from the cloud, so a local route needs a tunnel.
Start a tunnel
ngrok http 3000Set the public address
MEDIAN_KEY=median_key_...
MEDIAN_TOOLS_URL=https://your-tunnel.ngrok.appRestart the dev server. median() reads the variable from the server's environment, not from the shell you run curl in.
Open the route
curl -H "Authorization: Bearer $MEDIAN_KEY" http://localhost:3000/api/medianThe route registers https://your-tunnel.ngrok.app/api/median.
A new tunnel hostname is a new endpoint. Its tools clash with the old endpoint's tools of the same name, and the sync fails with The tool name "orderStatus" already comes from <old url>. Tool names have to be unique across every endpoint. Remove the old endpoint first, or use Change endpoint in its menu. A fixed tunnel domain avoids this.
A plain http://localhost URL does not work against Median's cloud. The sync fails with "http://localhost:3000 is this deployment's own machine, not yours."
Next steps
Config reference
Fields, inputs, context, results, errors and route options.
Pages and highlights
Offer the customer a page, or point at something on it.
How tool calls run
Syncs, approvals, reviewed calls and limits.
Examples
Tools worth writing, and the risk each one gets.
Tool errors
Every connect and sync error, and the fix.
Tool endpoint reference
The signed requests Median sends to your route.