Median

Custom tools

Give the agent a function on your server, connect it, and check that it works.

Updated Oct 1, 20267 minute read
median.config.ts
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 };
      },
    },
  },
});
app/api/median/route.ts
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-tools

Add your Median key

Create a key in Settings → API with Create key. Put it in your server environment:

.env.local
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.

FieldRequiredDefault
descriptionYes
inputNo{}
riskNo"low"
executeYes

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

app/api/median/route.ts
import { median } from "@mediansh/agent-tools";
import config from "@/median.config";

export const { GET, POST } = median(config);

Request and Response

server.ts
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.

WayHowEndpoint signs with
Open the routeGET it with Authorization: Bearer $MEDIAN_KEYThe key you sent
DashboardAgent → 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
CLImedian tools endpoint add https://acme.com/api/medianThe 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.

.env
MEDIAN_TOOLS_URL=https://acme.com
MEDIAN_TOOLS_URLRouteRegisters
https://acme.com/api/medianhttps://acme.com/api/median
https://acme.com/api/medianAnyhttps://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

RuleDetail
Schemehttps. http only for localhost and 127.0.0.1
Missing schemeAdded for you. https, or http for localhost
Private hostsRefused with "That address points inside a network, not at the internet."
Length512 characters
RedirectsNever followed. The sync fails, and names the new address when it can
Endpoints10 per organization
IdentityOne 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:

RowMeaning
SyncingA sync is running and the last one did not fail
3 tools · synced just nowThe last sync worked
Sync failed, then the reason, in redThe last sync failed. The agent keeps the last good tools
Not synced yetNo 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

TriggerWhat happens
Connecting a routeA sync starts right away
A new conversationBefore the agent's first reply, Median syncs every endpoint and waits up to about 12 seconds
On demandSync 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.

RiskBadge on Agent → ToolsWhen the agent calls itWho decides
lowNoneRuns right away. This is the defaultThe agent
mediumAgent asks firstRuns right away. The agent is told to get the customer's explicit yes in the conversation first. Median does not check that it didThe customer, through the agent
reviewedRequires reviewThe 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 teammateThe reviewer, or a teammate
highTeam approvalThe call waits as a request in the conversation until a teammate picks Approve and run or Deny. Unanswered requests expire after 24 hoursA 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
FlagWhat 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
--jsonPrints 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.

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

Set the public address

.env.local
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

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.

Still need help?

    Esc