Skip to content
Median
Esc
↑↓navigate↵open⌘Jpreview
On this page

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

npm install @mediansh/agent-tools
pnpm add @mediansh/agent-tools
yarn add @mediansh/agent-tools
bun add @mediansh/agent-tools

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

import { median } from "@mediansh/agent-tools";
import config from "@/median.config";

export const { GET, POST } = median(config);
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.com
MEDIAN_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 list

It 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.
  • medium depends on the agent following its instructions. Use reviewed or high for 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 --as or --conversation, the visitor is unverified and has no id. Run an account tool this way once to check that it refuses.
  • context.toolCallId is test_ and a UUID. context.conversationId is the same value, or the real thread’s id with --conversation.
  • context.approvedBy is "A test from the CLI".
  • The command exits with code 1 when ok is false, including refusals.
  • It reaches switched-on tools only, and needs an Admin or Owner. See Roles.

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 3000

Set the public address

MEDIAN_KEY=median_key_...
MEDIAN_TOOLS_URL=https://your-tunnel.ngrok.app

Restart 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/median

The 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

Was this page helpful?