Page tools
How to give the AI tools that run in your page, whichever provider answers.
import { defineTool } from "@leuria/client"
const searchProducts = defineTool<{ query: string }>({
name: "search_products",
description: "Search products; returns name, price and stock.",
inputSchema: { type: "object", properties: { query: { type: "string" } } },
execute: ({ query }, { signal }) => catalog.search(query, { signal }),
})
await ai.chat({ prompt: "Which mug is cheapest?", tools: [searchProducts] }).text()
How tools run
defineTool<Args>()only types the arguments; a tool is plain data:name([a-zA-Z0-9_-], unique in a request),description,inputSchema(JSON Schema),execute, and optionalannotations.executeruns in the page, whichever provider answers: the visitor's agent calls it through the engine, the browser model and your server through the SDK. The result goes to the model as JSON.- A tool that throws is reported to the model as an error. The model can retry or explain.
maxStepscaps tool calls per turn (default 10). Past it, the model is told the budget is spent and to answer with what it has.submit_resultis a reserved name (see Structured output).- A conversation's tools are set when you create it. To offer other tools, start a new conversation (pass it the history with
messages).
A request with tools only goes to a provider that has the tools capability (see Providers).
Tool context
execute(args, ctx) gets the following:
ctx.context: the turn's context fromsend(text, { context })(see Conversations). The model never sets it, so a tool bound to an account can't be pointed at another one.ctx.callCount: how many times this tool has run in this turn, this call included, for bounded retries: e.g. reject twice, then stop.ctx.callIdandctx.turnId.ctx.signal: aborted when the turn is cancelled, times out or ends.ctx.endTurn(outcome): for terminal tools (such asrender_view). The agent is stopped, the turn resolves at once withresult.outcome, and late output is dropped. The session stays, so the next turn is fast.
const renderView = defineTool<{ view: string }>({
name: "render_view",
description: "Show the result to the visitor. Call it last.",
inputSchema: { type: "object", properties: { view: { type: "string" } }, required: ["view"] },
execute: ({ view }, ctx) => {
ctx.endTurn({ view })
return { shown: true }
},
})
const { outcome } = await convo.send("Show my late invoices").result()
Tools the visitor answers
Leave out execute, and the call waits for your UI:
- its part is in
awaiting-inputstate, atool-inputevent is emitted, and the call is listed inconversation.getState().pendingInputs({ callId, turnId, name, args }); - the UI answers with
submitToolResult(callId, value)or refuses withrejectToolCall(callId, error?)(default "The visitor declined."); - stopping or timing out the turn dismisses it, so nothing is left hanging.
const confirmOrder = defineTool<{ total: number }>({
name: "confirm_order",
description: "Ask the visitor to confirm the order. Returns { confirmed }.",
inputSchema: { type: "object", properties: { total: { type: "number" } }, required: ["total"] },
})
convo.subscribe(() => {
for (const input of convo.getState().pendingInputs) showConfirm(input, (ok) =>
ok ? convo.submitToolResult(input.callId, { confirmed: true }) : convo.rejectToolCall(input.callId))
})
Middleware
createLeuria({ middleware }) and conversation({ middleware }) wrap every tool call, global layers first. A middleware gets the call ({ callId, turnId, name, args, context }) and next(), and returns the outcome ({ ok: true, result } or { ok: false, error }):
const redact: ToolMiddleware = async (call, next) => {
const outcome = await next()
return outcome.ok ? { ok: true, result: summarize(outcome.result) } : outcome
}
Use it to observe, redact, block, or add a "data, not instructions" wrapper. A middleware that throws turns into an error for the model.
Annotations
annotations are hints for the browser's agents (from the WebMCP spec), passed on by exposeTools:
readOnlyHint: the tool only reads, so it is safe to call without asking;consequentialHint: it does something that matters (orders, sends, deletes), so the agent should confirm first;untrustedContentHint: its result may contain text the page doesn't control (reviews, messages).
WebMCP: the same tools for the browser's agents
exposeTools(tools) offers page tools to the agents the browser itself runs, through WebMCP (document.modelContext, or navigator.modelContext before Chromium 150). The same defineTool objects serve the page's own assistant through Leuria, so a tool is written once.
import { exposeTools } from "@leuria/client"
const takeBack = exposeTools([searchProducts, addToCart])
- Tools without
execute(the visitor answers them) are left out. - A string result is passed as is, anything else as JSON, and a thrown error as
{ error }. - The tool gets no turn:
ctx.contextisundefinedandendTurndoes nothing. - The returned function takes the tools back;
exposeTools(tools, { signal })does it when the signal aborts. - Where the browser has no WebMCP, nothing happens.
modelContext()returns the browser's entry point, if any.
In React, useExposedTools(tools) from @leuria/react does it while a component is mounted (see React).
Chrome ships WebMCP behind chrome://flags/#enable-webmcp-testing, and to sites through an origin trial from Chrome 149.