Skip to content
React 19No peer dependencies

Agent Surface

Wrap a page in <AgentSurface> and every form field and data grid in it describes itself to a browser agent — what it holds, what it accepts — and can be operated through its own validator, with no tool definitions to write.

Found something wrong, or missing? Open an issue on the bug tracker.

On this page

Browser agents operate pages today by reading pixels or the accessibility tree and guessing. Neither tells them what a control will accept: a date field reads as "textbox", not "a day in 2026". Neither stops a wrong click.

The components on your page already know. A DatePicker knows its min and max, a Select knows which options are disabled, an Input knows its maxLength. <AgentSurface> collects that into one live description of the page, and sends every action an agent takes back through the component it is aimed at — the same checks a person's input gets.

tsx
import { AgentSurface } from "@/component-lib/shared";
 
<AgentSurface>
  <Input label="Name" maxLength={80} />
  <Select label="Team" options={teams} />
  <DatePicker label="Due date" min="2026-01-01" max="2026-12-31" />
  <DataGrid aria-label="Customers" data={rows} columns={columns} />
</AgentSurface>

That is the whole integration. No tool definitions, no schema written by hand, nothing to keep in step with the UI: each component describes itself from its own props, as it is rendered now.

What an agent sees

In a browser with WebMCP, the surface registers one tool, operate_page, with two actions.

describe returns every operable component on the page:

json
{
  "surfaceVersion": 1,
  "members": [
    {
      "id": "due-date",
      "kind": "field",
      "component": "DatePicker",
      "label": "Due date",
      "constraints": { "holds": "date", "min": "2026-01-01", "max": "2026-12-31" },
      "operations": { "setValue": { "input": { "...": "..." } }, "clear": {}, "open": {} }
    },
    {
      "id": "customers",
      "kind": "grid",
      "component": "DataGrid",
      "columns": [{ "id": "region", "label": "Region", "type": "string", "filterable": true, "operators": ["contains", "equals", "startsWith"] }],
      "operations": ["search", "filter", "sort", "setPage", "export", "undo"]
    }
  ]
}

operate sends intents to one of them, by id:

json
{ "action": "operate", "target": "due-date", "intents": [{ "action": "setValue", "value": "2026-03-15" }] }

Ids come from the label ("Due date" → due-date), so an agent can quote them back and a person reading a log can tell what was touched.

What it refuses

Every request is checked by the component it targets before anything changes. A refusal names the reason, which check made it, and the nearest valid value when there is one:

RequestAnswer
team → "Legal", a disabled optionrefused: option-disabled
team → "enginering"refused, with Did you mean "eng" (Engineering)?
due-date → "2027-01-10"refused: after the field's max
tags → three values, max={2}refused: more than the field allows
passwordrefused: unknown-target — never offered

Nothing is repaired or retried, and a refused request changes nothing. A grid operation that needs confirmation — a large export — returns needs-confirmation instead of running.

Password, hidden and file inputs are never offered, and a component that is disabled or read-only is described as such and refuses writes.

What it writes

A write reaches your application the way a person's would: your onChange, onValueChange or onCheckedChange fires, and a controlled component's state updates. That is what the browser test for this feature checks — not that the DOM changed, but that the page's own state did.

Some components are written through their own logic rather than the DOM, because the DOM would get it wrong:

  • NumberInput takes a number, not text. Written as text, 1.5 would be "1.5", which a de-DE field reads as fifteen.
  • Select and MultiSelect are offered their option values as text, and the write maps them back, so a numeric value reaches onChange as a number.
  • RadioGroup is one choice among its options, not one radio element.
  • DatePicker's isDateDisabled cannot be described, so it is checked at the write: a disabled day is refused, not chosen.

What joins

ComponentHolds
Input, Textareatext, with maxLength and pattern
NumberInputa number, with min, max, and step when snapping
Checkbox, Switchtrue or false
RadioGroup, Selectone of its options
MultiSelectseveral of its options, up to max
DatePickera day, within min and max
DateRangePickera range, within min and max
DataGridits columns and operations — see Asking in Words

A component outside an <AgentSurface> is unchanged: it pays for one useContext and nothing more.

Your own agent

Without WebMCP nothing is registered and nothing breaks. If your application runs its own agent loop, useAgentSurface() hands you the same surface:

tsx
import { useAgentSurface } from "@/component-lib/shared";
 
const surface = useAgentSurface();
 
const manifest = surface.manifest();           // put this in front of the model
const result = await surface.run(id, intents); // act on its answer

result is the same payload the WebMCP tool returns, refusals included.

Props

PropTypeDefault
webmcpbooleantrueRegister operate_page when the browser has WebMCP
toolNamestring"operate_page"One name per page: WebMCP cannot unregister a tool
onCall(entry) => voidObserve every tool call, for a log or a test
WebMCP is a Draft Community Group Report, not a W3C standard, and is implemented in Chromium only, behind a flag or an origin trial. Where it is absent the surface still works through useAgentSurface().

Cost

<AgentSurface> adds about 7 KB gzip, and only to an application that renders it. A component outside a surface pays for one useContext. A DataGrid builds its agent — which walks the data for column statistics — only inside a surface or with ai.