Page tools
WebMCP-shaped tools the AI agent calls on your page (registerTool, tools, useHelpAITool, <form toolname>), run once in the browser.
Last updated
Give the AI agent tools that act on your own page: open your booking form, read the visitor's cart, jump to a section, fill in a form. You describe each tool and write its function; the agent decides when to call it, the widget runs the function in the visitor's browser, and the agent continues the conversation with what it returned. Every page can offer different tools, so the agent can do on each page what that page is for.
A tool has the WebMCP (opens another site) shape (the W3C draft for tools a web page offers to AI agents), so a tool you write for the widget also works with a browser's own WebMCP support, and the other way round.
chat.registerTool({
name: 'open_booking',
description: 'Open the booking form on this page for a meeting type. Use it whenever the visitor wants to book a call.',
inputSchema: {
type: 'object',
properties: { meetingType: { type: 'string', enum: ['sales', 'support'] } },
required: ['meetingType'],
},
execute({ meetingType }) {
bookingForm.open(meetingType);
return { opened: true };
},
});A visitor writes "I'd like to talk to sales"; the agent calls open_booking with { meetingType: 'sales' }, your
booking form opens, and the agent answers "The booking form is open, pick a time that suits you."
Adding tools#
registerTool adds the tool for every widget on the page. Pass an AbortSignal to remove it again, for example when
a single-page app leaves the route that owns it:
const route = new AbortController();
chat.registerTool(addToCart, { signal: route.signal });
// …when the visitor leaves the product page:
route.abort();useHelpAITool does this for you: the tool is registered while the component is mounted, execute always runs the
latest render's function, and React's StrictMode double mount still leaves one registration.
A tool#
The ModelContextTool and ToolAnnotations types are exported for TypeScript; ModelContextTool<{ sku: string }>
types execute's input. WebMCP's other annotations (readOnlyHint, untrustedContentHint) are accepted and not used
yet.
Nothing about a tool can break the chat: a tool without a name or an execute function is left out with a warning
in the browser console, and every message still goes through.
Forms as tools#
A form becomes a tool with two attributes, in every browser:
<form toolname="request_demo" tooldescription="Request a product demo for the visitor." toolautosubmit>
<label for="email">Work email</label>
<input id="email" name="email" type="email" required />
<input name="company" toolparamdescription="The visitor's company name" />
<select name="plan">
<option value="starter">Starter</option>
<option value="pro">Pro</option>
</select>
<button>Request demo</button>
</form>- Each named field is an input the agent can fill: a
selector radio group takes one of its values, a checkboxtrueorfalse, a number field a number, anything else text. Hidden, disabled and file fields are left out, and so are passwords: the visitor types those, never the chat. - A field's description is its
toolparamdescription, else its label, else itsaria-description;requiredfields are required. - The agent fills the fields the way a visitor would (your framework's change handlers run). With
toolautosubmitthe form is then submitted; without it the visitor checks the form and presses its button. When a field still blocks the submit (a password, or a value the browser rejects), the form isn't submitted and the agent is told which field needs the visitor.
WebMCP browsers#
In a browser with WebMCP (document.modelContext: in Chrome, turn on chrome://flags/#enable-experimental-web-platform-features
or join the origin trial), the tools the page registered with the browser are offered to the agent too, and the widget
runs them through the browser. A <form toolname> is the exception: such a browser lists it too, but the widget fills
it itself, as in every other browser, so your form's own submit handler keeps working and passwords stay the
visitor's. Elsewhere, nothing changes.
Which tools a message offers#
The widget reads the page's tools when the visitor sends each message: the browser's WebMCP tools, then forms, then
the tools option, then registerTool. For a name in more than one place, the later one wins. When the agent calls a
tool, the widget reads them again and runs the current function of that name, so a single-page app's route change
applies straight away, with nothing to keep in sync. A call to a tool the page no longer has tells the agent the page
doesn't offer it right now. So does a call to a tool the message didn't offer at all (the agent repeating an earlier
call after a route change): the server answers it with "This page doesn't offer that action right now.", tells the agent
not to call it again this turn, and the agent carries on from what it knows or asks the visitor.
What the agent gets back#
- A return value becomes the tool's result, as JSON: dates become strings,
undefinedbecomesnull, functions are dropped, a page element is named ("[dialog]"), a repeated object reads"[circular]", and a result over 30,000 characters is sent as{ truncated: true, preview }. Return what the agent needs to answer well, such as{ opened: true }or{ items: 2, total: '$48' }. - A thrown error (or a rejected promise) reaches the agent as the tool's error, with its message, so it can tell
the visitor or try another way. For an expected miss ("that product is out of stock") a normal result such as
{ ok: false, reason: 'out of stock' }gives the agent more to work with.
With several calls in one step, the agent waits for all of them, and for any approvals, then continues once.
Each call runs once#
Tabs. Only the tab where the visitor sent the message runs the calls in its reply; another tab of the same conversation shows them running, then the agent's answer.
Reloads and navigation. A call that hadn't started when the page reloaded runs once after the reload. A call that was running is never run again: the agent is told the page reloaded, so it may or may not have taken effect, and checks with the visitor; its card reads "Not completed". A result that was ready but not yet sent is sent after the reload. The widget keeps this note per tab in
sessionStorage, so it also covers a navigation to another page of your site.Leaving the page. Results go out with
keepalive, so they arrive even when the page navigates straight after. A tool that navigates should return first and leave after:tsexecute({ path }) { setTimeout(() => location.assign(path), 0); // navigate after the result is sent return { navigating: path }; },The script loaded twice, or two widgets. The call still runs once.
No time limit. The agent waits for the result. While a tool runs, the chat shows Stop. If the visitor presses it, or sends another message first, the call's
signalaborts, its result is never sent, and its card reads "Not completed". A stopped call never runs again, not even after a reload.
When sessionStorage is blocked (some private modes), the note lives in memory, so a reload can't tell a call that
ran from one that didn't; everything else works the same.
What the visitor sees#
A tool that runs on its own appears in the transcript only when the deployment shows tool calls
(behavior.showToolCalls), with its input and result: "Running", then "Completed" or "Error" (the tool failed), or
"Not completed" when the visitor stopped it or moved on, or a reload cut it short. A tool with consequentialHint always shows an approval card with the input; Reject tells
the agent the visitor declined, and the function never runs.
Events#
Each call this page settles fires toolResult with { toolCallId, name, status, durationMs }: status is
completed, failed, declined (the visitor rejected it), stopped (the visitor pressed Stop) or interrupted (a
reload cut it short). It never carries the input or the result.
chat.on('toolResult', ({ name, status, durationMs }) => analytics.track('help_ai_tool', { name, status, durationMs }));Security#
- Tools run with your page's full privileges, as code you wrote. Anyone can also call your functions from the browser's developer tools, so a tool may do only what the visitor could do on the page themselves.
- The agent chooses the input. Check it in
executeas you would a form submission, and mark tools with real consequences (payments, deletions, messages to other people)consequentialHint: true, or confirm in your own UI. - The definitions are sent with each message and the results are stored in the conversation, like any tool's result, so never put secrets in a description or a result.
Testing#
testing/page-tools-manual.md walks through every case above in a real browser
on the dev/page-tools.html playground, with a real model or the mock backend.
Troubleshooting#
- The agent never uses the tool: write the description as an instruction ("Use it whenever the visitor wants to book a call"), and check the console for a "skipped a tool" warning.
- The chat waits after a tool: the function never settled (a promise that doesn't resolve). Return, or throw.
- "This page doesn't offer … right now": the call came after the tool was removed (a route change aborted its signal), or the agent tried a tool this page doesn't register. Keep a tool registered while the agent may still use it.