createWebAgents
The framework-free way to put an app's tools on a page.
The framework-free way to put an app’s tools on a page. It installs the agent cursor, registers every catalog tool that is available and whose match fits the current path, and keeps that true as the visitor navigates, following the browser’s URL itself. Where the browser has no WebMCP it loads the polyfill first. <WebAgentsProvider> is a thin wrapper over it; use it directly from Astro pages, Vue, Svelte or a plain script.
Import
import { createWebAgents } from "@webmcp-agents/core";
import type { WebAgents, WebAgentsOptions } from "@webmcp-agents/core";
Load the cursor’s stylesheet once beside it, from your entry or layout:
import "@webmcp-agents/core/cursor.css";
Example
A two-tool catalog. get_cart is on every page; list_orders only while the URL is /orders. Everything an agent does is shown by the cursor.
import { createWebAgents, defineWebTool } from "@webmcp-agents/core";
const getCart = defineWebTool({
id: "get_cart",
description: "Read the cart. Use before checking out.",
readOnly: true,
execute: () => ({ items: 2 }),
});
const listOrders = defineWebTool({
id: "list_orders",
description: "List past orders. Only on the orders page.",
readOnly: true,
match: "/orders",
execute: () => [{ id: "1002", total: "$128.00" }],
});
const tools = { getCart, listOrders };
// The cursor installs, and each tool registers while its `match`
// fits the URL (get_cart everywhere, list_orders on /orders).
export const agents = createWebAgents({
tools,
visuals: { name: "Assistant" },
});
// Only some of them, decided by your own state.
agents.setAvailableTools([tools.getCart]);
// Back to every tool of the catalog.
agents.setAvailableTools(undefined);
Options
createWebAgents(options) takes a WebAgentsOptions.
| Name | Type | Default | Description |
|---|---|---|---|
tools |
WebToolCatalog |
required | Everything the app has, as an object ({ getCart, listOrders }) or an array. A tool cannot be listed twice: two with one id throw, naming both (tools.getCart and tools.cart). See WebToolCatalog. |
availableTools |
AvailableTools |
every tool of tools |
What an agent may use right now, when that depends on your state (signed in, a plan, a feature flag). A tool’s match still applies on top. See AvailableTools. |
visuals |
AgentVisualsOptions | false |
{} |
Options for the agent cursor, read when it installs. false installs nothing: tools still register, without animation. See installAgentVisuals. |
context |
() => unknown |
not said | Read on every call and passed to execute as ctx.context. For apps written before tools closed over their own state; new code does not need it. |
polyfill |
"auto" | false |
"auto" |
Where the browser has no document.modelContext (Firefox, Safari, Chrome without WebMCP), "auto" loads the polyfill, a chunk of its own that browsers with WebMCP never download; the cursor and the tools wait for it. In production too, so every visitor gets the tools and the chat. false never loads it: tools then register only where the browser has WebMCP, and elsewhere registerWebTool warns once. Read on start(). |
start |
boolean |
true |
false: do nothing until start() is called (no listeners, no cursor, no registration). For a framework binding that creates this during a render it may throw away. |
WebToolCatalog
Readonly<Record<string, WebTool>> | readonly WebTool[]. An object lets code refer to tools.getCart and lists a tool once; an array is the same without keys. A key may be used in availableTools.
AvailableTools
readonly (WebTool | string)[]. Each entry is a tool by reference (tools.getCart), by its key in an object catalog, or by its id.
Returns
A WebAgents handle:
| Name | Type | Description |
|---|---|---|
setAvailableTools(tools) |
(tools: AvailableTools | undefined) => void |
Change what an agent may use right now; undefined for every catalog tool. A tool that leaves is released once its call in flight is answered. |
setTools(tools) |
(tools: WebToolCatalog) => void |
Swap the catalog. Only tools whose definition changed re-register. |
destroy() |
() => void |
Stop: no more URL tracking, every tool released (each after its call in flight is answered), the cursor removed a moment later. start() before then picks up where it left, without re-registering anything. |
start() |
() => void |
Start (again). Called for you unless start: false was passed. Does nothing on the server or when already started. |
setPathname(pathname) |
(pathname: string | undefined) => void |
Match tools against this path instead of the browser’s URL, for a router’s own idea of the path; undefined to follow the URL again. |
registry |
ToolRegistry |
The registry behind it: enable a catalog tool by hand while a part of the page is on screen (registry.enable([id])). |
visuals |
Pick<AgentVisuals, "setSpotlight" | "setYieldToUser"> |
Change the cursor’s spotlight and yielding at runtime. Before the cursor installs (the polyfill still loading) a change becomes its starting option; with visuals: false it does nothing. currentAgentVisuals() returns this object to UI that has no handle on it. |
Notes
- A tool is on when it is available and its
matchfits the path. Nomatchmeans every page. A tool that stops matching is released only after a call in flight is answered, so turning a tool off never cancels a call still answering (Tool lifecycle). - It follows the URL itself. The Navigation API where the browser has it, else
history.pushState/replaceStatepatched for as long as someone watches, pluspopstateandhashchange; seewatchPathname. Full page loads (Astro, classic sites) simply start again. A hash route (#/orders) counts as the path/orders; a plain#anchordoes not. matchturns a tool on as soon as the URL changes, which can be before the new route has rendered. A tool must not assume the page’s own state is there yet: do not read what the page sets in a mount effect. Write what a tool needs before you navigate (Skyline stores a search before it navigates to the results).- URL-change notices are delivered just after the navigation call returns, once per burst, not inside it: Next.js changes the URL inside a React insertion effect, where telling React (a store listener, a tool registering) is an error.
- The cursor is installed before the first tool registers, so tools are animated without any ordering rule in your app.
- Without WebMCP,
start()loads the polyfill and the tools wait for it. The cursor installs and the catalog registers oncedocument.modelContextexists, and a tool registered meanwhile (registerWebTool,useWebTool) registers then too, in the order it came; one released before then never registers. The page itself never waits. With native WebMCP nothing is loaded and everything registers at once. - Throws on a duplicate tool id. On the server, and until
start(), it returns the same object and does nothing: no DOM access at import, safe in SSR. destroy()is deferred a macrotask for the cursor, so a restart in the same tick (React StrictMode) changes nothing.- It does not install the devtools, unlike
<WebAgentsProvider>in development:@webmcp-agents/devtoolsis built on this package, so this package cannot load it. A page built oncreateWebAgentsadds one line after it in development,installWebAgentsDevtools(), whose Settings then drive this cursor.
See also
- Tools across your app, the page that teaches it
- Install without a framework
defineWebToolwatchPathnamecreateToolRegistryloadWebMCPPolyfill, whatpolyfill: "auto"calls- Browsers without WebMCP
<WebAgentsProvider>