Declarative forms
The browser's own way to write a tool: a form with toolname and tooldescription, answered through respondWith. The Build path is the recommended way; this is the alternative, and when it is still the right pick.
This is the declarative alternative to the way the Build path writes a tool. WebMCP has a second
way, built into the browser: put two attributes on a <form>, and the browser derives the input schema from its
controls, fills them in for the agent and fires submit with agentInvoked set. Your handler answers with respondWith.
WebMCP Agents animates these too: the cursor types the agent’s values into the real fields and clicks submit.
The Build path is the recommended way. A tool with an inputSchema returns its answer from
execute, keeps its form library’s own validation, and has nothing to keep rendered until the agent has been answered.
Reach for a declarative form when:
- the markup already is the whole feature, and a tool around it would repeat what the form says;
- you want the browser to build the schema from the controls and their labels;
- you are not in React, or the page has no JavaScript framework at all.
| Declarative form | A tool with an inputSchema |
|
|---|---|---|
| Written as | <form toolname …> |
useWebTool({ id, inputSchema, execute }) |
| Schema from | the form’s controls | a Standard Schema (zod, valibot, arktype) or JSON Schema |
| Answers by | event.respondWith(…), synchronously in the submit handler |
returning from execute |
| On while | the form is in the DOM | the component is mounted (enabled to switch it) |
| Confirmation | leave out toolautosubmit |
requireConfirmation: true |
A complete example
A promo form with the attributes, answered through useToolAnswer, and hiding itself once a code is applied. It uses
shadcn/ui’s button and input (any elements work the same):
npx shadcn@latest add button inputpnpm dlx shadcn@latest add button inputyarn dlx shadcn@latest add button inputbunx shadcn@latest add button inputnubx shadcn@latest add button inputaube dlx shadcn@latest add button inputimport { useToolAnswer } from "@webmcp-agents/react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ApplyPromo() {
const [promo, setPromo] = useState<string | null>(null);
const { answering, answer } = useToolAnswer();
// The form goes away once a code is applied, but only after
// the agent has its answer.
if (promo && !answering) return <p>Promo {promo} applied.</p>;
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = String(new FormData(event.currentTarget).get("code"));
setPromo(code);
// Answers an agent's submit; does nothing for a person's.
answer(
event.nativeEvent as SubmitEvent,
Promise.resolve(`Applied ${code}. The cart total updated.`),
);
}
return (
<form
toolname="apply_promo"
tooldescription={
"Apply a promo code the user gave you to the cart. " +
"Use before place_order. Never invent a code."
}
toolautosubmit=""
onSubmit={onSubmit}
className="flex items-end gap-2"
>
<Input
name="code"
required
toolparamdescription="The promo code, e.g. WELCOME10"
/>
<Button type="submit">Apply</Button>
</form>
);
}"use client";
import { useToolAnswer } from "@webmcp-agents/react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ApplyPromo() {
const [promo, setPromo] = useState<string | null>(null);
const { answering, answer } = useToolAnswer();
// The form goes away once a code is applied, but only after
// the agent has its answer.
if (promo && !answering) return <p>Promo {promo} applied.</p>;
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = String(new FormData(event.currentTarget).get("code"));
setPromo(code);
// Answers an agent's submit; does nothing for a person's.
answer(
event.nativeEvent as SubmitEvent,
Promise.resolve(`Applied ${code}. The cart total updated.`),
);
}
return (
<form
toolname="apply_promo"
tooldescription={
"Apply a promo code the user gave you to the cart. " +
"Use before place_order. Never invent a code."
}
toolautosubmit=""
onSubmit={onSubmit}
className="flex items-end gap-2"
>
<Input
name="code"
required
toolparamdescription="The promo code, e.g. WELCOME10"
/>
<Button type="submit">Apply</Button>
</form>
);
}import { useToolAnswer } from "@webmcp-agents/react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ApplyPromo() {
const [promo, setPromo] = useState<string | null>(null);
const { answering, answer } = useToolAnswer();
// The form goes away once a code is applied, but only after
// the agent has its answer.
if (promo && !answering) return <p>Promo {promo} applied.</p>;
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = String(new FormData(event.currentTarget).get("code"));
setPromo(code);
// Answers an agent's submit; does nothing for a person's.
answer(
event.nativeEvent as SubmitEvent,
Promise.resolve(`Applied ${code}. The cart total updated.`),
);
}
return (
<form
toolname="apply_promo"
tooldescription={
"Apply a promo code the user gave you to the cart. " +
"Use before place_order. Never invent a code."
}
toolautosubmit=""
onSubmit={onSubmit}
className="flex items-end gap-2"
>
<Input
name="code"
required
toolparamdescription="The promo code, e.g. WELCOME10"
/>
<Button type="submit">Apply</Button>
</form>
);
}import { useToolAnswer } from "@webmcp-agents/react";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ApplyPromo() {
const [promo, setPromo] = useState<string | null>(null);
const { answering, answer } = useToolAnswer();
// The form goes away once a code is applied, but only after
// the agent has its answer.
if (promo && !answering) return <p>Promo {promo} applied.</p>;
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = String(new FormData(event.currentTarget).get("code"));
setPromo(code);
// Answers an agent's submit; does nothing for a person's.
answer(
event.nativeEvent as SubmitEvent,
Promise.resolve(`Applied ${code}. The cart total updated.`),
);
}
return (
<form
toolname="apply_promo"
tooldescription={
"Apply a promo code the user gave you to the cart. " +
"Use before place_order. Never invent a code."
}
toolautosubmit=""
onSubmit={onSubmit}
className="flex items-end gap-2"
>
<Input
name="code"
required
toolparamdescription="The promo code, e.g. WELCOME10"
/>
<Button type="submit">Apply</Button>
</form>
);
}Nothing is registered: a declarative form is a tool as soon as it is in the DOM, and in a browser without WebMCP the
polyfill the provider loads finds it. @webmcp-agents/react types the attributes for JSX (JSX attributes):
any import from the package brings them in.
The attributes
| Attribute | On | What it does |
|---|---|---|
toolname |
<form> |
The tool’s name, unique on the page: [a-zA-Z0-9_.-], up to 128 characters. |
tooldescription |
<form> |
What the agent reads to decide when to call it. See Writing tool descriptions. |
toolautosubmit |
<form> |
Submit without waiting for the person. Leave it out to make them confirm. |
toolparamdescription |
a control | Describes that parameter. Without it, the control’s <label> is used. |
Outside React it is plain HTML, and nothing else is needed:
<form
toolname="contact_us"
tooldescription="Send the support team a message."
toolautosubmit
>
<input name="email" type="email" required toolparamdescription="The reply address" />
<textarea name="message" required toolparamdescription="What the user needs"></textarea>
<button type="submit">Send</button>
</form>
Confirmation
Leave out toolautosubmit and the browser fills the form, then pauses on the submit button until the person clicks.
The cursor parks there with “Click … to confirm”. Use it for orders, payments and deletions; add toolautosubmit for cheap,
reversible actions. A tool written with useWebTool gets the same pause from
requireConfirmation.
A form that hides itself: useToolAnswer
A declarative form answers the agent by calling event.respondWith(…) synchronously in its submit handler, and it must
stay rendered until the answer is delivered: Chrome cancels a call whose tool disappears. If submitting removes the form (a
promo form that vanishes once a code is applied), keep it until then with
useToolAnswer. It gives you answering, true from an agent’s submit until shortly
after the response settles, and answer(event, result), which wraps respondWith and does nothing for a person’s submit.
A form that navigates away should answer first and navigate after the grace period; a page whose own tool empties it
(checkout clearing the cart) keeps rendering until then. A tool written with useWebTool needs none of this, because
execute returns the answer and the registration outlives it (Tool lifecycle).
Errors
A form answers with text. Start it with the code and the agent and the chat read it the same way; a plain "Error: …" is
read as failed (Errors).
submit.respondWith(Promise.resolve("Error [needs_user]: Pick a size first: S, M or L."));
The earlier marker on a control
Before a tool could find its control by name, the attribute toolname="<id>" on an element told the cursor which element a
tool acts on. That still works, and is the fallback when a tool’s target matches nothing:
<button toolname="open_cart" onClick={openCart}>
Cart
</button>
New code does not need it: a tool’s title or a target names
the control, and nothing is added to the markup. The same goes for a CSS-selector target and the raw annotations
option, which keep working beside the plainly named flags.
Styling hooks
form:tool-form-active and button:tool-submit-active are native pseudo-classes, so CSS alone can show that an agent is
working in a form. The polyfill mirrors them as [data-tool-form-active] and [data-tool-submit-active];
cursor.css styles the attributes, and the optional
cursor-native.css the pseudo-classes. Keep the native and the attribute
rules in separate rules: a browser drops a whole rule it cannot parse.