Median

Config reference

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

Updated Oct 1, 20269 minute read
median.config.ts
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

NameTypeDescription
toolsRecord<string, Tool>Your tools. Each key is the name the agent calls. Optional, so a config can hold only plugins.
pluginsMedianPlugin[]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

NameTypeDescription
descriptionstringWhat 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".
inputRecord<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

RuleDetail
ShapeStarts with a letter, then letters, numbers and underscores, up to 64 characters. Input field names follow the same rule
UniqueAcross every endpoint in the organization. Two routes cannot both serve orderStatus
ReservedThe agent's own tool names. See Reserved names
PluginsgoToPage is taken while navigation() is in the config

Input builders

Builderexecute seesJSON 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:

InputMessage
A required field is missing or nullorderNumber is required.
Wrong typeorderNumber must be a string.
Value not in the enumreason must be one of: damaged, late.
A field the tool does not takeUnexpected field "note". This tool's input allows: orderNumber, reason.
Any field on a tool with no inputUnexpected field "note". This tool takes no input.
Input is not a JSON objectThe input must be a JSON object.

What execute receives

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

NameTypeDescription
conversationIdstringThe conversation the call came from.
toolCallIdstringUnique 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.
approvedBystring | undefinedWho let the call run. Absent when the agent ran it directly.

Visitor fields

FieldPresentTrust it for
verifiedAlwaystrue only when your server signed the visitor's identity. See Identity
externalIdOnly when verified is trueAuthorization. Scope every lookup to it
email, nameWhen knownDisplay 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.

RuleDetail
What the agent readsThe result as JSON text, cut at 4,000 characters
Response sizeUp to 100 KB. A larger response fails as not answering
Reserved keysmedianNavigateTo 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:

FieldValue
okfalse
knownfalse
allowedfalse
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 routeThe agent is told
Returns a resultThe result
Returns a refusalYour reason and detail, and not to retry
ThrowsYour error's message, and not to retry. The route answers 200 execution_failed. The stack stays on your server
Answers a non-2xx statusThe 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 KBSame 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:

median.config.ts
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:

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

When the config is checked

MistakeCaught
Tool name shape, empty description, unknown riskAt load
A field not built with p, missing executeAt load
Plugin name clashesAt load
Reserved name, or a name another endpoint already servesAt sync
Description over 500 charactersAt sync
Field name shape, more than 20 fields, schema over 8,000 charactersAt sync
More than 20 tools in the organizationAt 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.

median.config.ts
export default defineConfig({
  diagnostics: async () => ({
    version: process.env.GIT_SHA,
    database: (await db.ping()) ? "up" : "down",
    queueDepth: await jobs.depth(),
  }),
  tools: {
    // ...
  },
});
CaseWhat happens
Returns a valueAny JSON value. Attached, cut at 2,000 characters
Takes over 8 secondsRecorded as "The endpoint did not answer in time."
ThrowsIts message is attached instead
Not definedThe manifest says so, and Median does not ask. Nothing is attached
More than one endpointMedian 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.

NameTypeDescription
keystringYour Median key. Pass it where process.env is not available. Default: process.env.MEDIAN_KEY.
urlstringThe route's public address. An origin gets the route's path added. Default: process.env.MEDIAN_TOOLS_URL.
apiUrlstringWhere the connect request goes. An origin gets /v1 added. Also read from MEDIAN_API_URL. Default: "https://api.median.sh/v1".
toleranceMsnumberHow 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 GETYesNo. It answers 401 missing_signature
Other methodsNot exported405 method_not_allowed
Optionskey, url, apiUrl, toleranceMskey, toleranceMs

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

Environment variables

VariableUsed forOption that overrides it
MEDIAN_KEYVerifying signatures, and the bearer check on connectkey
MEDIAN_TOOLS_URLThe address a connect registersurl
MEDIAN_API_URLWhere the connect request goesapiUrl

Requests the route answers

RequestAnswer
GET with Authorization: Bearer $MEDIAN_KEY200 { connected, endpoint, tools, message }
Signed GET200 manifest { version, tools, diagnostics }
Signed POST with a tool call200 { 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.

StatusCodeWhen
401unauthorizedConnect without the right bearer key, or the server has no MEDIAN_KEY
400median_endpoint_unknownMEDIAN_TOOLS_URL is not a URL
502median_unreachableConnect could not reach Median
VariesMedian's codeMedian refused the connect, such as too_many_tool_endpoints. Otherwise median_refused_connection
500missing_secretNo MEDIAN_KEY on a signed request
500invalid_median_keyMEDIAN_KEY does not start with median_key_, or is malformed
401missing_signature, malformed_signature, stale_timestamp, invalid_signatureThe signature is absent, badly formed, more than toleranceMs (default 5 minutes) from the server's clock in either direction, or from a different key
400invalid_body, unknown_op, invalid_inputThe body is not JSON or not a tool call, the op is unknown, or the input does not fit
404unknown_toolNo tool by that name in this config

Tool endpoint reference has the wire format.

Limits

LimitValue
Tools per organization20
Endpoints per organization10
Description500 characters
Input fields per tool20
Input schema8,000 characters as JSON
Manifest500 KB
Call timeout10 seconds
Response body100 KB
Result the agent reads4,000 characters
Signature timestampWithin 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

ExportWhat it isDocs
defineConfigDeclares tools, plugins and diagnosticsThis page
pInput buildersThis page
medianThe route's GET and POST handlersThis page
createMedianHandlerOne handler for signed requestsThis page
navigationPlugin that offers any page of a Next.js appPages and highlights
navigateTo, navigationPathIssueOffer a page from a result, and check a pathPages and highlights
medianIdentity, signMedianUserSign the visitor's identityIdentity
publicKeyFromMedianKeyThe widget's public key from MEDIAN_KEYSupport widget
medianSecretThe signing secret for one purpose, such as "webhooks"Webhooks
medianKeyFromEnvReads 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.

Still need help?

    Esc