Pages and highlights
Let a tool offer the customer a page of your app, or point at something on the page.
import { defineConfig, navigation } from "@mediansh/agent-tools";
export default defineConfig({
plugins: [navigation({ exclude: ["/admin/**", "/internal/**"] })],
tools: {
// ...
},
});
"use client";
import { MedianSupport } from "@mediansh/widget";
import { useRouter } from "next/navigation";
export function Support({ publicKey }: { publicKey: string }) {
const router = useRouter();
return <MedianSupport apiKey={publicKey} onNavigate={router.push} />;
}
A tool result can carry a page, a highlight, or both. The page becomes a Take me there button under the agent’s reply. Nothing moves until the customer presses it. Without onNavigate on the widget there is no button. See Support widget.
| Way | Use it for |
|---|---|
navigation() |
Any page of a Next.js app, picked by the agent from a list |
navigateTo() |
A page your own tool works out, such as one order |
medianHighlight |
An element on the page to point at |
Offer any page
navigation() is a plugin that adds one tool, goToPage. It reads your Next.js routes when the route module loads and offers every page you do not exclude.
- The agent picks a page from the list and supplies a value for each bracketed segment.
/orders/[id]and1234become/orders/1234. - Values are URL encoded. A catch-all
[...slug]value may contain slashes. An optional[[...slug]]may be left empty. - Values are sent comma separated, in bracket order, so one value cannot contain a comma.
- The
pageinput only accepts pages on the list. The wrong number of values, or an empty value, returns{ offered: false, reason }so the agent can try again. goToPagecounts toward the 20 tools per organization. It shows on Agent → Tools like any tool and can be switched off.- A page on the list returns
navigateTo(path, { offered: true, page }). See Offer a page from your own tool.
exclude?string[]
Pages to leave out, as paths with * for one segment and ** for any number.
string[][]routes?string[]
Your pages, written like /orders/[id]. Skips the scan. Use it for other frameworks and edge runtimes.
string[]dir?string
Where your Next.js app is, if it is not the working directory.
stringprocess.cwd()risk?"low" | "medium" | "reviewed" | "high"
The risk of goToPage.
"low" | "medium" | "reviewed" | "high""low"Exclude pages
| Pattern | Matches |
|---|---|
/admin |
That page, and nothing under it |
/admin/** |
/admin and everything under it |
/orders/* |
/orders/[id], but not /orders/[id]/refund |
/*-internal |
/billing-internal |
/** |
Every page, which throws |
Patterns match pages as Next.js writes them, brackets included, so /orders/[id] excludes that one page. Nothing is excluded by default.
An excluded page is left out of the list the agent gets, so the agent cannot offer it or learn from the list that it exists.
An exclusion that matches no page logs a console warning, Median: navigation() has no pages matching "/admni/**", so it excludes nothing. Check the spelling if you meant to keep a page out.
Where the routes come from
| Source | Read when |
|---|---|
routes |
You pass it. Nothing else is read |
app/, src/app/, pages/, src/pages/ under dir |
The source is there, as in development or a server deployed from a checkout |
.next/server/app-paths-manifest.json and .next/server/pages-manifest.json |
No source was found, as on Vercel or anywhere else that ships the build output |
The scan goes 24 folders deep and reads up to 20,000 files in each folder it starts from. It skips node_modules, .git, .next, dist and build.
What counts as a page
| Router | Pages | Skipped |
|---|---|---|
| App | page.tsx, page.ts, page.jsx, page.js, page.mdx |
route.ts handlers, _private folders, @slot folders, interception routes like (.)photo. Route groups like (marketing) are dropped from the path |
| Pages | .tsx, .ts, .jsx, .js, .mdx files. index is its folder’s path |
api/, _app, _document, anything starting with _, 404, 500, and names with an extra dot like button.test.tsx |
Load errors
| Cause | What happens |
|---|---|
| The scan finds no pages | A console error. The plugin adds no tool and the rest of the config works |
exclude removes every page |
Throws |
A pattern that is empty, has a backslash, or does not start with / |
Throws |
routes: [] |
Throws |
A route that does not start with /, or has whitespace |
Throws |
| The page list is over 8,000 characters of schema | Throws. Exclude the sections no customer needs |
Your own tool is named goToPage |
Throws |
Edge runtimes
The scan reads the disk, which needs Node 20.16 or later, or Bun. On an edge runtime it finds nothing, so pass routes:
navigation({ routes: ["/", "/settings", "/orders/[id]"] })
Offer a page from your own tool
Wrap a result in navigateTo when the page depends on something only your server knows:
import { defineConfig, navigateTo, p } from "@mediansh/agent-tools";
export default defineConfig({
tools: {
openOrder: {
description: "Offer the customer one of their orders.",
input: { orderNumber: p.string("The order number.") },
async execute({ orderNumber }, { visitor }) {
if (!visitor.verified) return { ok: false, reason: "not_signed_in" };
const order = await findOrder(visitor.externalId, orderNumber);
if (order === null) return { found: false };
return navigateTo(`/orders/${order.id}`, { found: true });
},
},
},
});
navigateTo(path, facts?) returns facts with a medianNavigateTo key. The agent reads facts. Median removes the path before the agent reads the result, so the agent never sees it and cannot invent one.
| Path rule | Refused example |
|---|---|
Starts with a single / |
https://acme.com/orders, //acme.com |
| No backslash | /\acme.com |
No .. segment, including %2e%2e |
/orders/../admin |
| No spaces or control characters | /search?q=red shoes |
| Up to 2,048 characters |
Query strings are allowed, as in /orders/1234?tab=refunds.
On a bad path, navigateTo throws This tool cannot navigate there. followed by the reason. The call fails, and the agent is told the tool could not do it. Check a path first with navigationPathIssue(path), which returns the reason or null.
Median checks the path again when the result arrives, and drops one that is not a path on your site.
Point at something
Add medianHighlight, a CSS selector, to a result:
return {
...navigateTo(`/orders/${order.id}`, { found: true }),
medianHighlight: "#refund-button",
};
Without a destination it points at something already on screen:
return { exported: true, medianHighlight: "#export-button" };
| Rule | Detail |
|---|---|
| Selector | Up to 256 characters, no control characters. Otherwise dropped |
| With a destination | Drawn after the customer presses Take me there |
| Without one | Drawn when the reply arrives. Needs no onNavigate |
| Search | The widget looks for the element for 4 seconds, then gives up |
| Ring | Scrolls the element into view and rings it for about 2.6 seconds. Your page’s styles are not changed |
| Invalid selector | A console error, and nothing is drawn |
| The agent | Never sees the selector |
What the customer sees
| Case | Behavior |
|---|---|
| Button | Take me there, under the reply that explains it. Screen readers hear “Take me to” and the path |
No onNavigate |
No button. In development the console warns once |
| Pressing | The widget checks the path is on the current site, then calls onNavigate(path) |
| One per turn | A tool that returned a page or highlight cannot run again that turn. If two tools return a page in one turn, the first is kept |
| Approved or teammate-run calls | The page appears as a button above the message box for 5 minutes, and goes once pressed |
| Old highlights | A highlight is drawn only if it arrived in the last 5 minutes while the widget was open. Earlier ones are not replayed |
| Channels | Only the widget shows buttons and highlights |
Buttons under a reply do not expire.