Page Tools (Beta) — Let the Agent Act on Your Website
Your site registers JavaScript functions as tools; the widget agent can call them — in the visitor's own browser tab, inside their existing session.
Until now the widget agent could only talk about your website. With page tools it can act on it: look up the visitor's cart, highlight a product, fill a form, check an order — using your site's own JavaScript, running as the signed-in visitor. Their login, their cart, their prices, their account. No OAuth, no API keys, no backend integration: the tools you register are the entire permission surface. If you don't register it, the agent can't do it.
A few things follow from that design:
- Tools belong to one visitor's tab. They exist only inside that visitor's widget session and run with exactly the access the visitor's browser already has. Phone and API sessions never see them.
- The agent does not see your screen. Only the text a tool explicitly returns enters the conversation. Navigating or highlighting changes what the visitor sees; the agent learns only what the tool's return value says.
- Tool lists are live. The widget discovers tools when a session opens and picks up changes mid-conversation — single-page apps can swap the toolset on every route change.
:::info Standards-compatible
Page tools speak the emerging WebMCP draft (navigator.modelContext) and the @mcp-b polyfill ecosystem. If your site already registers WebMCP tools for other agents, the BESS widget consumes them as-is — there is nothing BESS-specific to add beyond the opt-in below.
:::
Enable it (two switches)
Page tools are off by default and require both sides to opt in:
- Dashboard — edit the widget and tick Allow page tools (WebMCP).
- Snippet — add
data-page-tools="1"to the embed tag on your page:
<script src="https://api.bess-ai.com/embed.js"
data-widget-id="bess_pk_live_XXXXXXXXXXXX"
data-page-tools="1"
async></script>
Without both, nothing changes: the widget behaves exactly as before and page registrations are ignored.
Register tools
Below is a complete, dependency-free registration you can paste into any page and adapt. It has two parts: a small bridge (the wire protocol — write it once, or use @mcp-b/global instead) and your tools (the part you actually maintain).
It registers two tools: a read (get_cart_summary, marked readOnlyHint: true, so it runs silently) and an action (add_to_cart, which the agent must confirm with the visitor before executing).
<script>
/* ---- 1. Bridge: a minimal in-page tool server (write once) ---------------
Speaks the @mcp-b tab wire format the widget listens for. If you already
use @mcp-b/global or navigator.modelContext, skip this block and register
your tools there instead — the widget understands both. */
(function () {
var CHANNEL = "mcp-default";
var tools = {};
function send(payload) {
window.postMessage(
{ channel: CHANNEL, type: "mcp", direction: "server-to-client", payload: payload },
"*"
);
}
window.addEventListener("message", function (e) {
var d = e.data;
if (e.source !== window || !d || d.channel !== CHANNEL ||
d.type !== "mcp" || d.direction !== "client-to-server") return;
var p = d.payload;
if (p === "mcp-check-ready") { send("mcp-server-ready"); return; }
if (!p || typeof p !== "object") return;
handle(p);
});
function handle(msg) {
function reply(result) { send({ jsonrpc: "2.0", id: msg.id, result: result }); }
function fail(code, message) {
send({ jsonrpc: "2.0", id: msg.id, error: { code: code, message: message } });
}
if (msg.method === "initialize") {
reply({
protocolVersion: (msg.params && msg.params.protocolVersion) || "2025-06-18",
capabilities: { tools: { listChanged: true } },
serverInfo: { name: "my-site", version: "1.0.0" },
});
} else if (msg.method === "tools/list") {
reply({
tools: Object.keys(tools).map(function (name) {
var t = tools[name];
return { name: t.name, description: t.description,
inputSchema: t.inputSchema, annotations: t.annotations };
}),
});
} else if (msg.method === "tools/call") {
var tool = tools[msg.params && msg.params.name];
if (!tool) return fail(-32602, "Unknown tool: " + (msg.params && msg.params.name));
Promise.resolve()
.then(function () { return tool.execute((msg.params && msg.params.arguments) || {}); })
.then(function (text) { reply({ content: [{ type: "text", text: String(text) }] }); })
["catch"](function (err) {
reply({ content: [{ type: "text", text: String(err) }], isError: true });
});
} else if (msg.id !== undefined && msg.method !== "notifications/initialized") {
fail(-32601, "Method not found: " + msg.method);
}
}
// Call again whenever the toolset changes (e.g. on SPA route changes).
window.registerPageTools = function (list) {
tools = {};
list.forEach(function (t) { tools[t.name] = t; });
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
};
send("mcp-server-ready"); // in case the widget is already listening
})();
/* ---- 2. Your tools (the part you maintain) ------------------------------ */
registerPageTools([
{
name: "get_cart_summary",
description: "Read the visitor's current cart: item names, quantities and the total.",
inputSchema: { type: "object", properties: {} },
annotations: { readOnlyHint: true }, // read-only -> runs without confirmation
execute: function () {
// Replace with your real cart lookup.
return JSON.stringify({ items: [{ sku: "RUG-114", qty: 1 }], total: "4800 TRY" });
},
},
{
name: "add_to_cart",
description: "Add a product to the visitor's cart by SKU.",
inputSchema: {
type: "object",
properties: {
sku: { type: "string", description: "Product SKU, e.g. RUG-114" },
quantity: { type: "integer", description: "How many to add (default 1)" },
},
required: ["sku"],
},
// No readOnlyHint -> the agent must confirm with the visitor first.
execute: function (args) {
// Replace with your real add-to-cart call.
return "Added " + (args.quantity || 1) + " x " + args.sku + " to the cart.";
},
},
]);
</script>
Open your site, start a widget conversation, and ask something like "what's in my cart?" — the agent discovers the tools automatically at session start.
:::note Single-page apps
Call registerPageTools([...]) again whenever the relevant toolset changes — for example on route changes, so an add_to_cart tool only exists on product pages. The agent's tool list updates mid-conversation, and it can call a tool that didn't exist when the session started.
:::
Design patterns from production
Everything below comes from running page tools on our own properties — bess-ai.com and the BESS console's built-in copilot. Steal liberally.
Register only what's on screen. Don't ship one giant toolset. Keep a per-route (or per-step) registry and re-register on navigation, so a checkout tool exists only at checkout and a form-fill tool only while its form is visible. The agent's tool list updates mid-conversation — and an LLM cannot misuse a tool it cannot see:
var COMMON = [getPageContext];
var BY_ROUTE = {
"/product": [addToCart, highlightProduct],
"/checkout": [getCartSummary, applyCoupon],
};
function onRouteChange(path) {
registerPageTools(COMMON.concat(BY_ROUTE[path] || []));
}
Always ship a context tool. A read-only get_page_context() returning where the visitor is and what state the page is in ("checkout, step 2, 3 items in cart, coupon field visible") is the single highest-value tool you can register. The agent orients itself with it before acting — without one, it acts blind. Return structure, not private data dumps.
Mirror forms as typed tools. To let the agent fill a form, don't expose a generic set_field(name, value) — register one tool per form whose inputSchema mirrors the form's fields (enums for selects, required for required). LLMs read schemas natively: the agent collects missing values conversationally, calls once, and your execute fills the real inputs while the visitor watches. Leave submission to the human — fill everything, click nothing.
Write return values for the agent's ears. The return string is all the agent learns. "Added 2 × RUG-114 — cart total 9600 TRY" lets it answer the visitor's next question; "OK" forces another tool call. Short, human-readable, state-bearing.
Tag your source if you're a plugin or agency. An optional annotations: { "x-bess-source": "plugin:my-theme" } shows in the widget owner's dashboard tool table as the tool's origin — so owners always know which tools came from your integration and which from their own code.
For AI coding assistants
Building the registration with Claude Code, Cursor, or another coding agent? Paste this contract into its context together with this page (assistants connected to the BESS MCP server can also fetch it live via read_guide("guides/widget-page-tools")):
BESS page-tools contract (v1):
- Transport: @mcp-b tab wire format on channel "mcp-default" (bridge block in the
BESS Page Tools guide), or navigator.modelContext where available. The BESS
widget probes both.
- Names: ^[a-zA-Z][a-zA-Z0-9_]{0,63}$ ; snake_case; unique (first wins). Never
define a parameter named "user_confirmed" (reserved by the consent gate).
- Caps: <=32 tools per page; tool description <=1024 chars; schema-internal
descriptions <=256; inputSchema <=16KB; results <=8KB; execution <=10s.
- Consent: annotations.readOnlyHint=true -> runs silently; anything else ->
platform-enforced visitor confirmation. Never mark a state-changing tool
readOnlyHint.
- SPA: re-register the current route's toolset on navigation and emit
notifications/tools/list_changed. Register only tools valid for the current
screen.
- Always include one read-only get_page_context() tool returning route + page
state (structure, never data dumps).
- Form fill: one typed tool per form, schema mirroring the fields; fill inputs
visibly; never auto-submit.
- Returns: short human-readable strings carrying the resulting state.
- Test: widget setting "Allow page tools" ON + data-page-tools="1" on the embed
snippet; start a NEW widget session; discovered tools appear in the widget's
dashboard tool table and in the browser console's [bess-page-tools] log line.
The rules
Tool definitions cross from your page into an AI conversation, so the platform validates them structurally. A tool that breaks a rule is dropped with a logged warning — never silently rewritten:
| Requirement | Limit |
|---|---|
| Tool name | must match ^[a-zA-Z][a-zA-Z0-9_]{0,63}$ — starts with a letter; letters, digits, underscores; max 64 chars. No hyphens, no Python reserved words. Duplicate names: first one wins. |
| Tool description | max 1024 characters (control characters stripped). |
| Parameter names | identifier-style (snake_case works). The name user_confirmed is reserved for the consent gate — a tool declaring it is dropped. |
Descriptions inside inputSchema | max 256 characters each. |
inputSchema size | max 16 KB serialized. |
| Tools per page | max 32. |
| Tool result | max 8 KB serialized — larger results are truncated with an explicit marker. |
| Execution time | a call times out after ~10 seconds if the page doesn't answer. Keep tools fast. |
Everything a tool returns is treated as untrusted page content — the agent is instructed to use it as data, never as instructions.
Consent: silent reads, confirmed actions
Every page tool falls into one of two tiers:
- Silent — tools annotated
readOnlyHint: true. The agent calls them freely, like reading the page. - Confirm — everything else. Before executing, the agent must tell the visitor exactly what it is about to do — including the exact argument values — and get an explicit yes. This is enforced by the platform, not just prompted: a confirm-tier call without recorded visitor approval is rejected before it ever reaches your page.
The default tier comes from your annotations. You can override it per widget with page_tools_config, set via PATCH /v1/widgets/{widget_id} (see the API reference):
{
"allow_page_tools": true,
"page_tools_config": {
"silent": ["highlight_product"],
"confirm": ["get_cart_summary"]
}
}
Override lists beat annotations; if a name appears in both lists, confirm wins. Tools with no annotation and no override are confirm-tier — the safe default.
:::warning Keep real actions on confirm Don't silent-list tools with effects that reach beyond the visitor's screen — submissions, bookings, payments, deletions. During the Beta especially, anything irreversible should stay behind confirmation. :::
Troubleshooting
The agent doesn't see my tools.
- Both switches are on: Allow page tools (WebMCP) on the widget and
data-page-tools="1"on the snippet. - The dashboard toggle can take up to ~30 seconds to reach your live pages (the widget's public config is briefly cached) — reload the page and start a new widget session; tools are discovered at session start.
- Check the browser console: with the attribute set, the loader logs a
[bess-page-tools]line listing the tools it discovered. An empty list means your registration didn't reach it — verify the bridge block runs before or shortly after the widget loads (both orders work; the widget re-probes for a few seconds). - Page tools currently require a regular prompt-based agent — agents built with the visual conversation-flow builder don't support them yet.
A tool is missing from the list. It was probably dropped for breaking one of the rules — most commonly a hyphen in the name, a description over the cap, or a user_confirmed parameter. Fix the definition; drops are logged, never repaired automatically.
The agent refuses to run an action. Working as intended: confirm-tier tools require the visitor's explicit yes in that conversation. If a genuinely read-only tool keeps asking for confirmation, add annotations: { readOnlyHint: true } to its registration (or silent-list it in page_tools_config).
Beta notes
Page tools are new — the wire protocol is stable, but expect the rough edges of a fresh feature. Keep irreversible actions behind the confirm tier, test your registrations with real conversations before relying on them, and if the agent misuses a tool or a registration behaves unexpectedly, tell us — Beta feedback directly shapes what ships next.