Skip to content

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.

ts
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#

Where you areHow
Script tagHelpAI('registerTool', tool). Works before the widget has loaded: the loader queues it.
npmchat.registerTool(tool, { signal }), or init({ tools: [...] }).
ReactuseHelpAITool(tool) in the component that owns the feature, or <HelpAI tools={[...]} />.
<web-ai-chat>element.registerTool(tool), or the tools property (functions can't be attributes).
Plain HTMLA form with toolname and tooldescription attributes (Forms as tools); no script needed.
A WebMCP browserTools registered with the browser's own document.modelContext.registerTool() are offered too (WebMCP).

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:

ts
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#

FieldWhat it is
nameLetters, digits, _ and -, 58 characters at most; other characters become _. The agent sees site__<name>. A name registered again replaces the earlier tool.
descriptionWhen the agent should use it and what it does, 1024 characters at most. The agent picks tools by their descriptions: say when, not only what. Without one, title (then name) is used.
titleOptional. A short human name.
inputSchemaOptional. JSON Schema (opens another site) of the input, an object. The agent fills it in; use enum and required to keep the input exact. Without one, the tool takes no input.
annotations.consequentialHintOptional. true for a tool with real consequences (an order, a booking, a message to someone): the visitor approves each call in the chat first, and the function runs only on Approve.
execute(input, { signal }) => result. Return a value or a promise of one; throw to report a failure. signal aborts when the visitor moves on before the call finishes.

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:

html
<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 select or radio group takes one of its values, a checkbox true or false, 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 its aria-description; required fields are required.
  • The agent fills the fields the way a visitor would (your framework's change handlers run). With toolautosubmit the 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, undefined becomes null, 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:

    ts
    execute({ 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 signal aborts, 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.

ts
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 execute as 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.

Still need help?

Ask the community