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
| Name | Type | Description |
|---|---|---|
tools | Record<string, Tool> | Your tools. Each key is the name the agent calls. Optional, so a config can hold only plugins. |
plugins | MedianPlugin[] | Bundles of ready-made tools, served beside your own. See Plugins. |
diagnostics | () => unknown | Promise<unknown> | Called when a report is filed. See Server diagnostics. |
defineConfig returns a MedianConfig with your tools' defaults filled in. Pass it to median() or createMedianHandler().
Tool fields
| Name | Type | Description |
|---|---|---|
description | string | What the tool does. The agent decides when to call it by reading this. Up to 500 characters. |
risk | "low" | "medium" | "reviewed" | "high" | What happens when the agent calls it. See Risk levels on the Custom tools page. Default: "low". |
input | Record<string, Field> | The parameters, built with p. 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. |
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.
| Name | Type | Description |
|---|---|---|
conversationId | string | The conversation the call came from. |
toolCallId | string | Unique per call. Use it as an idempotency key. |
risk | "low" | "medium" | "reviewed" | "high" | The tool's risk in your config. |
visitor | { verified: boolean; externalId?: string; email?: string; name?: string } | Who the agent is talking to. |
approvedBy | string | undefined | Who let the call run. Absent when the agent ran it directly. |
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.
riskandinputare 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.
| Name | Type | Description |
|---|---|---|
key | string | Your Median key. Pass it where process.env is not available. Default: process.env.MEDIAN_KEY. |
url | string | The route's public address. An origin gets the route's path added. Default: process.env.MEDIAN_TOOLS_URL. |
apiUrl | string | Where the connect request goes. An origin gets /v1 added. Also read from MEDIAN_API_URL. 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. Default: 300000. |
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.