Tool examples
Ten tools to copy, each with its risk level and the refusals it returns.
| Tool | Risk | Refuses with |
|---|---|---|
orderStatus | low | not_signed_in, not_found |
subscription | low | not_signed_in |
usageThisMonth | low | not_signed_in |
sendPasswordReset | medium | not_signed_in |
resendInvoice | medium | not_signed_in, not_found |
flagForEngineering | medium | None |
refundSmallOrder | reviewed | not_signed_in, not_found, already_refunded, over_limit |
cancelSubscription | high | not_signed_in |
refundOrder | high | not_signed_in, not_found, already_refunded |
extendTrial | high | not_signed_in, out_of_range |
What each risk level means is in risk levels.
The config file
Every example below is an entry in tools in this file. The two helpers at
the top are shared by all of them.
import { defineConfig, p, type ToolContext } from "@mediansh/agent-tools";
import { auth, billing, db, metrics, payments, tracker } from "@/lib/server";
/** Your own id for the customer, or null when your server did not sign them in. */
function customerId(context: ToolContext): string | null {
return context.visitor.verified ? (context.visitor.externalId ?? null) : null;
}
/** The one refusal shape. Median reads ok: false as the tool saying no. */
function refuse(reason: string, detail?: string) {
return { ok: false, reason, ...(detail ? { detail } : {}) };
}
export default defineConfig({
tools: {
orderStatus: {
description:
"Look up one of the signed in customer's orders: where it is and when it should arrive.",
input: {
orderNumber: p.string("The order number, like ORD-1042."),
},
async execute({ orderNumber }, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const order = await db.orders.findForCustomer(id, orderNumber);
if (!order) return refuse("not_found", "No order with that number on this account.");
return {
status: order.status,
carrier: order.carrier,
trackingUrl: order.trackingUrl,
eta: order.eta,
};
},
},
},
});Serve it from one route. See Custom tools.
How the examples behave
context.visitor.verifiedistrueonly when your server signed the customer's identity.externalIdis only present then.nameandemailare claims. See Identity.- A result with
ok: false,known: false,allowed: false, oroutcomeset to"blocked"or"unknown"is a refusal. The agent readsreason(defaulttool_refused) anddetail, tells the customer what it means, and does not call again. Do not use those fields for data. - Any other result is data. The agent answers from it in the customer's language, so return fields, not sentences.
context.toolCallIdis unique per call. A second request for the same action gets a new id, so check your own state before a write that must not happen twice. The refund examples do.
What execute receives and may return is in the
config reference.
Order status
In the config file above. It reads one order, scoped to the signed in
customer. findForCustomer(id, number) cannot return someone else's order.
findByNumber(number) could.
Subscription and plan
subscription: {
description: "The signed in customer's plan, seat count, and renewal date.",
async execute(_input, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const account = await db.accounts.findByUserId(id);
return {
plan: account.plan,
seats: { used: account.seatsUsed, included: account.seatsIncluded },
renewsOn: account.renewsOn,
status: account.status,
};
},
},A tool with no input takes no arguments. risk defaults to low.
Usage this month
usageThisMonth: {
description: "How much of their monthly quota the customer has used.",
async execute(_input, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const usage = await metrics.monthToDate(id);
return {
requests: usage.requests,
included: usage.included,
overageCost: usage.overageCents / 100,
resetsOn: usage.resetsOn,
};
},
},Password reset link
sendPasswordReset: {
description: "Email a fresh password reset link to the address on the account.",
risk: "medium",
async execute(_input, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
await auth.sendPasswordReset(id, { idempotencyKey: context.toolCallId });
return { sent: true };
},
},medium because a new link invalidates the last one. The agent asks the
customer first.
:::danger Never take the address as an input. Send to the address on the account. Otherwise anyone can ask for a reset link to be mailed to an address they choose. :::
Resend an invoice
resendInvoice: {
description: "Email a copy of one invoice to the account's billing address.",
risk: "medium",
input: {
invoiceNumber: p.string("The invoice number, like INV-2031."),
},
async execute({ invoiceNumber }, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const invoice = await billing.findInvoice(id, invoiceNumber);
if (!invoice) return refuse("not_found", "No invoice with that number on this account.");
await billing.email(invoice.id, { idempotencyKey: context.toolCallId });
return { sent: true, to: invoice.billingEmail };
},
},Flag for engineering
flagForEngineering: {
description: "File a bug report for something in this conversation that looks broken.",
risk: "medium",
input: {
title: p.string("One line, as an engineer would title it."),
detail: p.string("What breaks, what should happen, and any error text."),
severity: p.enum(["low", "normal", "urgent"]),
},
async execute({ title, detail, severity }, context) {
const issue = await tracker.createIssue({
title,
severity,
body: `${detail}\n\nMedian conversation: ${context.conversationId}`,
labels: ["from-support"],
});
return { filed: true, reference: issue.key };
},
},The conversation id in the issue body links your tracker back to the thread.
:::tip The agent can file bugs and feature requests into Signal with no code, and Signal can open them in Linear or GitHub. Write this tool only for a tracker Median does not connect to. See Built-in abilities. :::
Refund a small order
refundSmallOrder: {
description: "Refund an order of $50 or less in full, back to the original payment method.",
risk: "reviewed",
input: {
orderNumber: p.string("The order number to refund."),
reason: p.enum(["damaged", "late", "wrong_item"]),
},
async execute({ orderNumber, reason }, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const order = await db.orders.findForCustomer(id, orderNumber);
if (!order) return refuse("not_found", "No order with that number on this account.");
if (order.refundedAt) return refuse("already_refunded");
if (order.totalCents > 5000) {
return refuse("over_limit", "Orders over $50 go through refundOrder.");
}
const refund = await payments.refund(order.paymentId, {
reason,
approvedBy: context.approvedBy,
idempotencyKey: context.toolCallId,
});
return { refunded: true, amount: refund.amount / 100 };
},
},reviewed waits for the customer's yes, then for the reviewer. The reviewer
approves, denies, or passes the call to your team. context.approvedBy is
"The reviewer". On a passed call it names the approver, as in
Cancel a subscription.
Enforce the cap in code as well as in the description. See reviewed calls.
Cancel a subscription
cancelSubscription: {
description: "Cancel the customer's subscription at the end of the current period.",
risk: "high",
input: {
reason: p
.enum(["too_expensive", "missing_feature", "switching", "other"])
.optional(),
},
async execute({ reason }, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const result = await billing.cancelAtPeriodEnd(id, {
reason,
requestedVia: "support",
approvedBy: context.approvedBy,
idempotencyKey: context.toolCallId,
});
return { cancelled: true, activeUntil: result.activeUntil };
},
},context.approvedBy is the approving teammate's name, "A teammate" when
they have none, or "the API" when a Median key approved it. Store it in
your own audit trail.
Refund an order
refundOrder: {
description: "Refund an order in full, back to the original payment method.",
risk: "high",
input: {
orderNumber: p.string("The order number to refund."),
reason: p.enum(["damaged", "late", "wrong_item", "goodwill"]),
},
async execute({ orderNumber, reason }, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
const order = await db.orders.findForCustomer(id, orderNumber);
if (!order) return refuse("not_found", "No order with that number on this account.");
if (order.refundedAt) return refuse("already_refunded");
const refund = await payments.refund(order.paymentId, {
reason,
approvedBy: context.approvedBy,
idempotencyKey: context.toolCallId,
});
return { refunded: true, amount: refund.amount / 100 };
},
},Each request is decided once. A refund can still run twice if the agent asks
again after a request settles, or a teammate runs the tool by hand. The
refundedAt check stops both.
Extend a trial
extendTrial: {
description: "Give the customer more trial days.",
risk: "high",
input: {
days: p.number("How many days to add. Fourteen at most."),
},
async execute({ days }, context) {
const id = customerId(context);
if (!id) return refuse("not_signed_in", "Ask the customer to sign in first.");
if (!Number.isInteger(days) || days < 1 || days > 14) {
return refuse("out_of_range", "Trials can be extended by 1 to 14 days.");
}
const trial = await billing.extendTrial(id, days, {
approvedBy: context.approvedBy,
idempotencyKey: context.toolCallId,
});
return { extended: true, endsOn: trial.endsOn };
},
},Check the bounds in code. The description tells the agent the limit, and the
refusal's detail tells it again if it asks for more.
Rules
- Take the customer from
customerId(context), never from an input. A tool that accepts an account id will be handed someone else's. - Refuse with
refuse(reason, detail). Return data as fields. - Keep documentation, policies and error tables in your knowledge base. See what not to build.
Test each tool against your real endpoint, signed in and not:
median tools test orderStatus --input '{"orderNumber":"ORD-1042"}'
median tools test orderStatus --input '{"orderNumber":"ORD-1042"}' --as user_123The first should return not_signed_in. Every flag is in the
CLI reference.