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

Config reference

Everything in @mediansh/agent-tools for serving tools, from defineConfig to the route handlers.

import { defineConfig, navigation, 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.",
      risk: "low",
      input: { orderNumber: p.string("The order number.") },
      async execute({ orderNumber }, context) {
        if (!context.visitor.verified) return { ok: false, reason: "not_signed_in" };
        const order = await db.orders.find(context.visitor.externalId, orderNumber);
        return order ? { status: order.status } : { found: false };
      },
    },
  },
  plugins: [navigation({ exclude: ["/admin/**"] })],
  diagnostics: async () => ({ version: process.env.GIT_SHA }),
});

defineConfig

PropType
tools?Record<string, Tool>

Your tools. Each key is the name the agent calls. Optional, so a config can hold only plugins.

TypeRecord<string, Tool>
plugins?MedianPlugin[]

Bundles of ready-made tools, served beside your own. See Plugins.

TypeMedianPlugin[]
diagnostics?() => unknown | Promise<unknown>

Called when a report is filed. See Server diagnostics.

Type() => unknown | Promise<unknown>

defineConfig returns a MedianConfig with your tools’ defaults filled in. Pass it to median() or createMedianHandler().

Tool fields

PropType
descriptionstring

What the tool does. The agent decides when to call it by reading this. Up to 500 characters.

Typestring
risk?"low" | "medium" | "reviewed" | "high"

What happens when the agent calls it. See Risk levels on the Custom tools page.

Type"low" | "medium" | "reviewed" | "high"
Default"low"
input?Record<string, Field>

The parameters, built with p.

TypeRecord<string, Field>
Default{}
execute(input, context) => unknown | Promise<unknown>

Your function. It gets validated input and the call's context. Its return value is the tool result.

Type(input, context) => unknown | Promise<unknown>

Median adds a line to the description for medium, reviewed and high tools, telling the agent to ask first or that the call waits for sign-off. Do not write that yourself. Risk levels has what each level does.

Names

Rule Detail
Shape Starts with a letter, then letters, numbers and underscores, up to 64 characters. Input field names follow the same rule
Unique Across every endpoint in the organization. Two routes cannot both serve orderStatus
Reserved The agent’s own tool names. See Reserved names
Plugins goToPage is taken while navigation() is in the config

Input builders

Builder execute sees JSON Schema
p.string("desc") string { "type": "string", "description": "desc" }
p.number() number { "type": "number" }
p.boolean() boolean { "type": "boolean" }
p.enum(["a", "b"]) "a" | "b" { "type": "string", "enum": ["a", "b"] }

Every builder takes an optional description as its last argument. .optional() makes a field optional in TypeScript and in the schema. Inputs are flat. A tool takes up to 20 fields, and its schema may be up to 8,000 characters as JSON.

The route validates input before execute runs. There is no coercion, so "12" is not a number. null counts as absent. Numbers must be finite. The first failure answers 400 invalid_input, and the agent is told its input was wrong so it can fix it and call again:

Input Message
A required field is missing or null orderNumber is required.
Wrong type orderNumber must be a string.
Value not in the enum reason must be one of: damaged, late.
A field the tool does not take Unexpected field "note". This tool's input allows: orderNumber, reason.
Any field on a tool with no input Unexpected field "note". This tool takes no input.
Input is not a JSON object The input must be a JSON object.

What execute receives

execute(input, context). input is typed from your input declaration.

PropType
conversationId?string

The conversation the call came from.

Typestring
toolCallId?string

Unique per call. Use it as an idempotency key.

Typestring
risk?"low" | "medium" | "reviewed" | "high"

The tool's risk in your config.

Type"low" | "medium" | "reviewed" | "high"
visitor?{ verified: boolean; externalId?: string; email?: string; name?: string }

Who the agent is talking to.

Type{ verified: boolean; externalId?: string; email?: string; name?: string }
approvedBy?string | undefined

Who let the call run. Absent when the agent ran it directly.

Typestring | undefined

Visitor fields

Field Present Trust it for
verified Always true only when your server signed the visitor’s identity. See Identity
externalId Only when verified is true Authorization. Scope every lookup to it
email, name When known Display only. They come from the page or from what the customer typed in chat

Authorize on externalId, and only when verified is true.

approvedBy and ids

approvedBy and toolCallId for each way a call can run are in What your endpoint receives.

Treat toolCallId and conversationId as opaque strings. Median sends each call once and does not retry it. A teammate running the tool again, or the agent calling it again, is a new call with a new toolCallId. Pass toolCallId as the idempotency key to any system you write to, so your own retries of one call apply once.

Return values

Return any JSON value. The route answers { "result": ... }, and undefined becomes null.

Rule Detail
What the agent reads The result as JSON text, cut at 4,000 characters
Response size Up to 100 KB. A larger response fails as not answering
Reserved keys medianNavigateTo and medianHighlight are removed before the agent reads the result. See Pages and highlights

Refusals

A result is a refusal when it has any of these:

Field Value
ok false
known false
allowed false
outcome "blocked" or "unknown"
return { ok: false, reason: "not_signed_in", detail: "Sign in to see orders." };

The agent reads reason and detail as the tool’s own words and is told not to retry. Without a reason it reads tool_refused. outcome: "unknown" also tells it the action may have completed and to check its state before retrying. A refusal’s medianNavigateTo and medianHighlight are ignored. median tools test reports a refusal as ok: false.

Other results, such as { found: false }, are data. Use one refusal shape across your tools.

Errors

Your route The agent is told
Returns a result The result
Returns a refusal Your reason and detail, and not to retry
Throws Your error’s message, and not to retry. The route answers 200 execution_failed. The stack stays on your server
Answers a non-2xx status The tool is not answering. It says what it knows and hands the customer to a person. Your message is not passed on
Takes over 10 seconds, redirects, or sends over 100 KB Same as a non-2xx status

The table covers calls the agent makes itself. When an approved call fails, the conversation shows a line saying it did not run, with the error, and the agent treats the action as not done.

Tool errors lists every error code with its fix.

Plugins

A plugin is a named bundle of tools.

type MedianPlugin = {
  name: string;
  tools: Record<string, Tool>;
};
  • Plugin tools are added first, then yours.
  • Plugin tools get no defaults. risk and input are required.
  • navigation() is the plugin this package ships. See Pages and highlights.

Split tools across files by giving each module its own defineConfig and passing its tools as a plugin:

import { defineConfig } from "@mediansh/agent-tools";
import account from "./median/account";
import orders from "./median/orders";

export default defineConfig({
  plugins: [
    { name: "account", tools: account.tools },
    { name: "orders", tools: orders.tools },
  ],
});

A name clash throws when the route module loads:

Clash Error
Two plugins Median: two plugins both define a tool called "x". Take one of them out.
A plugin and your tool Median: your tool "x" has the same name as one from the orders plugin. Rename yours, or drop the plugin.

When the config is checked

Mistake Caught
Tool name shape, empty description, unknown risk At load
A field not built with p, missing execute At load
Plugin name clashes At load
Reserved name, or a name another endpoint already serves At sync
Description over 500 characters At sync
Field name shape, more than 20 fields, schema over 8,000 characters At sync
More than 20 tools in the organization At sync

At load, the route module throws with a message naming the tool. At sync, the endpoint shows Sync failed and the reason, and the agent keeps the last good tool set.

Server diagnostics

diagnostics sits beside tools. It runs when a report is filed on Signal and at no other time. What it returns is attached to the report, next to what the customer’s browser collected.

export default defineConfig({
  diagnostics: async () => ({
    version: process.env.GIT_SHA,
    database: (await db.ping()) ? "up" : "down",
    queueDepth: await jobs.depth(),
  }),
  tools: {
    // ...
  },
});
Case What happens
Returns a value Any JSON value. Attached, cut at 2,000 characters
Takes over 8 seconds Recorded as “The endpoint did not answer in time.”
Throws Its message is attached instead
Not defined The manifest says so, and Median does not ask. Nothing is attached
More than one endpoint Median takes the organization’s 5 oldest endpoints. It skips any that is syncing, whose last sync failed, or whose manifest says it has no diagnostics, and asks the rest

The report is filed either way. Context and diagnostics covers the browser side.

Route handlers

median()

median(config, options?): { GET, POST }

config is a defineConfig result or the same object inline. GET without a signature connects the route. A signed GET answers the manifest. POST runs a tool.

PropType
key?string

Your Median key. Pass it where process.env is not available.

Typestring
Defaultprocess.env.MEDIAN_KEY
url?string

The route's public address. An origin gets the route's path added.

Typestring
Defaultprocess.env.MEDIAN_TOOLS_URL
apiUrl?string

Where the connect request goes. An origin gets /v1 added. Also read from MEDIAN_API_URL.

Typestring
Default"https://api.median.sh/v1"
toleranceMs?number

How far a signature's timestamp may be from the server's clock, in either direction, in milliseconds.

Typenumber
Default300000

createMedianHandler()

createMedianHandler(config, options?): (request: Request) => Promise<Response>

One handler for frameworks that want a single function. It takes a defineConfig result and the key and toleranceMs options.

median() createMedianHandler()
Returns { GET, POST } One handler
Connects on a bearer GET Yes No. It answers 401 missing_signature
Other methods Not exported 405 method_not_allowed
Options key, url, apiUrl, toleranceMs key, toleranceMs

Connect a createMedianHandler() route from Agent → Tools or with median tools endpoint add.

Environment variables

Variable Used for Option that overrides it
MEDIAN_KEY Verifying signatures, and the bearer check on connect key
MEDIAN_TOOLS_URL The address a connect registers url
MEDIAN_API_URL Where the connect request goes apiUrl

Requests the route answers

Request Answer
GET with Authorization: Bearer $MEDIAN_KEY 200 { connected, endpoint, tools, message }
Signed GET 200 manifest { version, tools, diagnostics }
Signed POST with a tool call 200 { result }, or 200 execution_failed when execute throws
Signed POST with { "op": "diagnostics" } 200 { result }, 200 diagnostics_unsupported when no function is defined, or 200 execution_failed when it throws

Errors wear { "error": { "code", "message" } }. Every response sets Cache-Control: private, no-store.

Status Code When
401 unauthorized Connect without the right bearer key, or the server has no MEDIAN_KEY
400 median_endpoint_unknown MEDIAN_TOOLS_URL is not a URL
502 median_unreachable Connect could not reach Median
Varies Median’s code Median refused the connect, such as too_many_tool_endpoints. Otherwise median_refused_connection
500 missing_secret No MEDIAN_KEY on a signed request
500 invalid_median_key MEDIAN_KEY does not start with median_key_, or is malformed
401 missing_signature, malformed_signature, stale_timestamp, invalid_signature The signature is absent, badly formed, more than toleranceMs (default 5 minutes) from the server’s clock in either direction, or from a different key
400 invalid_body, unknown_op, invalid_input The body is not JSON or not a tool call, the op is unknown, or the input does not fit
404 unknown_tool No tool by that name in this config

Tool endpoint reference has the wire format.

Limits

Limit Value
Tools per organization 20
Endpoints per organization 10
Description 500 characters
Input fields per tool 20
Input schema 8,000 characters as JSON
Manifest 500 KB
Call timeout 10 seconds
Response body 100 KB
Result the agent reads 4,000 characters
Signature timestamp Within 5 minutes of the server’s clock

Per-turn and approval limits are in How tool calls run.

Runtimes

The handlers use only the Web Request, Response and Web Crypto APIs. They run on Node, Bun, Deno, Next.js route handlers on either runtime, and edge platforms. navigation() reads your routes from disk, which needs Node 20.16 or later, or Bun. On edge runtimes pass routes.

Package exports

Export What it is Docs
defineConfig Declares tools, plugins and diagnostics This page
p Input builders This page
median The route’s GET and POST handlers This page
createMedianHandler One handler for signed requests This page
navigation Plugin that offers any page of a Next.js app Pages and highlights
navigateTo, navigationPathIssue Offer a page from a result, and check a path Pages and highlights
medianIdentity, signMedianUser Sign the visitor’s identity Identity
publicKeyFromMedianKey The widget’s public key from MEDIAN_KEY Support widget
medianSecret The signing secret for one purpose, such as "webhooks" Webhooks
medianKeyFromEnv Reads MEDIAN_KEY from the environment

The package also exports these types: MedianConfig, MedianConfigInput, MedianPlugin, DiagnosticsCollector, NavigationOptions, Field, InferInput, MedianHandlerOptions, MedianOptions, MedianIdentityOptions, MedianIdentityResolver, MedianIdentityRoute, MedianIdentityUser, MedianVisitor, ToolContext, ToolRisk.

Was this page helpful?