Tools with input
A form is a tool with an inputSchema. The cursor types the agent's values into your form's fields, your form library hears it, and fill shows what it cannot type.
A form is not a special kind of tool. It is a tool with an inputSchema: the agent sends values, the cursor types
each one into the field it belongs to, and your form library hears the typing like a person’s. execute is the
function the form already submits to. There is no form option, no adapter and no second hook.
A complete example
shadcn/ui’s Form on React Hook Form, with one zod schema for the agent’s call and the person’s submit. To follow
along, add the components this page uses (form brings React Hook Form, its zod resolver and zod with it):
npx shadcn@latest add form input select labelpnpm dlx shadcn@latest add form input select labelyarn dlx shadcn@latest add form input select labelbunx shadcn@latest add form input select labelnubx shadcn@latest add form input select labelaube dlx shadcn@latest add form input select labelimport { zodResolver } from "@hookform/resolvers/zod";
import { useWebTool } from "@webmcp-agents/react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cart } from "@/lib/cart";
const SIZES = ["S", "M", "L"] as const;
// One schema checks the agent's call and the person's submit.
const schema = z.object({
size: z.enum(SIZES).describe("The size: S, M or L"),
quantity: z
.number()
.int()
.min(1)
.max(10)
.describe("How many to add, e.g. 2"),
});
type AddToCartInput = z.infer<typeof schema>;
export function AddToCart() {
const form = useForm<AddToCartInput>({
resolver: zodResolver(schema),
defaultValues: { size: "M", quantity: 1 },
});
const add = ({ size, quantity }: AddToCartInput) => {
cart.add({
id: `tee-${size}`,
name: `Ocean Tee (${size})`,
quantity,
price: 28,
});
return `Added ${quantity} × Ocean Tee, size ${size}.`;
};
// An agent calls this.
useWebTool({
id: "add_to_cart",
title: "Add to cart",
description:
"Add the Ocean Tee to the cart. On its product page only. " +
"Next: get_cart, or go_to cart.",
inputSchema: schema,
// The size is a custom Select, with nothing to type into: put
// the agent's values in the form before the cursor presses.
fill: (values) => form.reset(values),
execute: add,
});
// A person submits this.
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(add)}
className="flex items-end gap-2"
>
<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItem>
<FormLabel>Size</FormLabel>
<Select
value={field.value}
onValueChange={(size) => size && field.onChange(size)}
>
<FormControl>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{SIZES.map((size) => (
<SelectItem key={size} value={size}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input
type="number"
min={1}
className="w-20"
{...field}
// A number input hands the form a string.
onChange={(e) =>
field.onChange(e.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Add to cart</Button>
</form>
</Form>
);
}"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useWebTool } from "@webmcp-agents/react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cart } from "@/lib/cart";
const SIZES = ["S", "M", "L"] as const;
// One schema checks the agent's call and the person's submit.
const schema = z.object({
size: z.enum(SIZES).describe("The size: S, M or L"),
quantity: z
.number()
.int()
.min(1)
.max(10)
.describe("How many to add, e.g. 2"),
});
type AddToCartInput = z.infer<typeof schema>;
export function AddToCart() {
const form = useForm<AddToCartInput>({
resolver: zodResolver(schema),
defaultValues: { size: "M", quantity: 1 },
});
const add = ({ size, quantity }: AddToCartInput) => {
cart.add({
id: `tee-${size}`,
name: `Ocean Tee (${size})`,
quantity,
price: 28,
});
return `Added ${quantity} × Ocean Tee, size ${size}.`;
};
// An agent calls this.
useWebTool({
id: "add_to_cart",
title: "Add to cart",
description:
"Add the Ocean Tee to the cart. On its product page only. " +
"Next: get_cart, or go_to cart.",
inputSchema: schema,
// The size is a custom Select, with nothing to type into: put
// the agent's values in the form before the cursor presses.
fill: (values) => form.reset(values),
execute: add,
});
// A person submits this.
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(add)}
className="flex items-end gap-2"
>
<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItem>
<FormLabel>Size</FormLabel>
<Select
value={field.value}
onValueChange={(size) => size && field.onChange(size)}
>
<FormControl>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{SIZES.map((size) => (
<SelectItem key={size} value={size}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input
type="number"
min={1}
className="w-20"
{...field}
// A number input hands the form a string.
onChange={(e) =>
field.onChange(e.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Add to cart</Button>
</form>
</Form>
);
}import { zodResolver } from "@hookform/resolvers/zod";
import { useWebTool } from "@webmcp-agents/react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cart } from "@/lib/cart";
const SIZES = ["S", "M", "L"] as const;
// One schema checks the agent's call and the person's submit.
const schema = z.object({
size: z.enum(SIZES).describe("The size: S, M or L"),
quantity: z
.number()
.int()
.min(1)
.max(10)
.describe("How many to add, e.g. 2"),
});
type AddToCartInput = z.infer<typeof schema>;
export function AddToCart() {
const form = useForm<AddToCartInput>({
resolver: zodResolver(schema),
defaultValues: { size: "M", quantity: 1 },
});
const add = ({ size, quantity }: AddToCartInput) => {
cart.add({
id: `tee-${size}`,
name: `Ocean Tee (${size})`,
quantity,
price: 28,
});
return `Added ${quantity} × Ocean Tee, size ${size}.`;
};
// An agent calls this.
useWebTool({
id: "add_to_cart",
title: "Add to cart",
description:
"Add the Ocean Tee to the cart. On its product page only. " +
"Next: get_cart, or go_to cart.",
inputSchema: schema,
// The size is a custom Select, with nothing to type into: put
// the agent's values in the form before the cursor presses.
fill: (values) => form.reset(values),
execute: add,
});
// A person submits this.
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(add)}
className="flex items-end gap-2"
>
<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItem>
<FormLabel>Size</FormLabel>
<Select
value={field.value}
onValueChange={(size) => size && field.onChange(size)}
>
<FormControl>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{SIZES.map((size) => (
<SelectItem key={size} value={size}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input
type="number"
min={1}
className="w-20"
{...field}
// A number input hands the form a string.
onChange={(e) =>
field.onChange(e.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Add to cart</Button>
</form>
</Form>
);
}import { zodResolver } from "@hookform/resolvers/zod";
import { useWebTool } from "@webmcp-agents/react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cart } from "@/lib/cart";
const SIZES = ["S", "M", "L"] as const;
// One schema checks the agent's call and the person's submit.
const schema = z.object({
size: z.enum(SIZES).describe("The size: S, M or L"),
quantity: z
.number()
.int()
.min(1)
.max(10)
.describe("How many to add, e.g. 2"),
});
type AddToCartInput = z.infer<typeof schema>;
export function AddToCart() {
const form = useForm<AddToCartInput>({
resolver: zodResolver(schema),
defaultValues: { size: "M", quantity: 1 },
});
const add = ({ size, quantity }: AddToCartInput) => {
cart.add({
id: `tee-${size}`,
name: `Ocean Tee (${size})`,
quantity,
price: 28,
});
return `Added ${quantity} × Ocean Tee, size ${size}.`;
};
// An agent calls this.
useWebTool({
id: "add_to_cart",
title: "Add to cart",
description:
"Add the Ocean Tee to the cart. On its product page only. " +
"Next: get_cart, or go_to cart.",
inputSchema: schema,
// The size is a custom Select, with nothing to type into: put
// the agent's values in the form before the cursor presses.
fill: (values) => form.reset(values),
execute: add,
});
// A person submits this.
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(add)}
className="flex items-end gap-2"
>
<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItem>
<FormLabel>Size</FormLabel>
<Select
value={field.value}
onValueChange={(size) => size && field.onChange(size)}
>
<FormControl>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{SIZES.map((size) => (
<SelectItem key={size} value={size}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="quantity"
render={({ field }) => (
<FormItem>
<FormLabel>Quantity</FormLabel>
<FormControl>
<Input
type="number"
min={1}
className="w-20"
{...field}
// A number input hands the form a string.
onChange={(e) =>
field.onChange(e.target.valueAsNumber)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Add to cart</Button>
</form>
</Form>
);
}An agent calls add_to_cart({ size: "L", quantity: 2 }):
- The schema checks the input. Input that fails it never reaches the page: the agent gets the schema’s messages back, and the cursor does not move.
- The cursor types what it can. It goes to the button named “Add to cart” (the tool’s
title), then types 2 into the field namedquantity. React Hook Form’sonChangehears it. fillputs the rest on screen. The Size is a shadcnSelect, a custom widget with nothing to type into, soform.reset(values)shows L.- The cursor presses the button, and
addruns. Its return value is the agent’s answer.
A person’s submit goes through form.handleSubmit, which validates and calls add. An agent’s call reaches add
directly, after the tool’s own schema: the action is written once and runs once.
How the cursor fills a form
The cursor finds a parameter’s field by the field’s name, then by a label that matches the schema’s title for it
or the parameter read as words (firstName is “first name”). It looks outward from the tool’s button, so the closest
field wins. Then it writes the value the way the control expects:
| Control | What happens |
|---|---|
| Text, email, tel, search, textarea | Typed a character at a time |
number, date, range, a native <select> |
Written once |
| Checkbox, radio | Clicked to match the value (in a radio group, the one whose value is the parameter’s) |
A custom widget: shadcn Select, Combobox, a date picker |
Visited and highlighted, never written: use fill |
fill runs after the cursor typed what it could and before it presses or parks. It may return a promise, which is
awaited. With the cursor off, with reduced motion, or after the person moved the mouse, the values the cursor did not
type are written in one step, and fill still runs.
A number input hands React Hook Form a string, and a z.number() schema rejects it: pass
e.target.valueAsNumber to field.onChange, as the example does.
When the fields are not named like the parameters
A parameter with no field is skipped: nothing is typed, and execute still gets the value. When the field has another
name, say where it is with fields, by parameter:
useWebTool({
id: "search_flights",
description: "Search one-way flights between two airports.",
inputSchema: searchSchema,
fields: {
from: "Leaving from", // the field labelled "Leaving from"
to: { role: "combobox", name: "Going to" }, // a locator
cabin: false, // never type this one
},
execute: search,
});
A field elsewhere on the page is typed there. A tool with no control at all types nothing unless fields names a
field.
Validation errors are the answer
Two layers, and both reach the agent as readable text with an error code:
- The schema. Input that fails it is
invalid_input, with each bad field named, before anything is typed. The agent fixes its call and retries. - Your own rules, inside the action: throw a
WebToolErrorwith a code, and put the same words on the page, so the person sees what the agent read.
const add = ({ size, quantity }: AddToCartInput) => {
if (!inStock(size, quantity)) {
const message = `Only ${stockOf(size)} left in size ${size}.`;
form.setError("root", { message });
throw new WebToolError("invalid_input", message);
}
// …
};
Show form.formState.errors.root?.message under the form. A person’s submit runs the same function, so a throw there
is a rejected handleSubmit; catch it in the submit handler if you would rather not see it in the console. A tool that
has to ask the person something (“which size?”) calls waitingForUser and answers
with needs_user.
TanStack Form
The same hook works with any form library: the cursor writes a field the way a person does (the native setter, then
input and change events), so any controlled input hears it. Here it is with TanStack Form:
npm install @tanstack/react-formpnpm add @tanstack/react-formyarn add @tanstack/react-formbun add @tanstack/react-formnub add @tanstack/react-formaube add @tanstack/react-formimport { useForm } from "@tanstack/react-form";
import { useWebTool } from "@webmcp-agents/react";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const schema = z.object({
email: z
.email()
.describe("The address to subscribe, e.g. [email protected]"),
});
type SubscribeInput = z.infer<typeof schema>;
export function Subscribe() {
const [done, setDone] = useState("");
const subscribe = ({ email }: SubscribeInput) => {
setDone(`Subscribed ${email}.`);
return `Subscribed ${email}.`;
};
const form = useForm({
defaultValues: { email: "" },
validators: { onSubmit: schema },
onSubmit: ({ value }) => subscribe(value),
});
// An agent calls this.
useWebTool({
id: "subscribe_newsletter",
title: "Subscribe",
description:
"Subscribe an email address to the newsletter. Use when the " +
"user asks for news by email.",
inputSchema: schema,
execute: subscribe,
});
// A person submits this.
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex items-end gap-2"
>
<form.Field name="email">
{(field) => (
<div className="grid gap-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
// TanStack Form sets no name: the cursor needs one.
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<Button type="submit">Subscribe</Button>
{done && <p>{done}</p>}
</form>
);
}"use client";
import { useForm } from "@tanstack/react-form";
import { useWebTool } from "@webmcp-agents/react";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const schema = z.object({
email: z
.email()
.describe("The address to subscribe, e.g. [email protected]"),
});
type SubscribeInput = z.infer<typeof schema>;
export function Subscribe() {
const [done, setDone] = useState("");
const subscribe = ({ email }: SubscribeInput) => {
setDone(`Subscribed ${email}.`);
return `Subscribed ${email}.`;
};
const form = useForm({
defaultValues: { email: "" },
validators: { onSubmit: schema },
onSubmit: ({ value }) => subscribe(value),
});
// An agent calls this.
useWebTool({
id: "subscribe_newsletter",
title: "Subscribe",
description:
"Subscribe an email address to the newsletter. Use when the " +
"user asks for news by email.",
inputSchema: schema,
execute: subscribe,
});
// A person submits this.
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex items-end gap-2"
>
<form.Field name="email">
{(field) => (
<div className="grid gap-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
// TanStack Form sets no name: the cursor needs one.
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<Button type="submit">Subscribe</Button>
{done && <p>{done}</p>}
</form>
);
}import { useForm } from "@tanstack/react-form";
import { useWebTool } from "@webmcp-agents/react";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const schema = z.object({
email: z
.email()
.describe("The address to subscribe, e.g. [email protected]"),
});
type SubscribeInput = z.infer<typeof schema>;
export function Subscribe() {
const [done, setDone] = useState("");
const subscribe = ({ email }: SubscribeInput) => {
setDone(`Subscribed ${email}.`);
return `Subscribed ${email}.`;
};
const form = useForm({
defaultValues: { email: "" },
validators: { onSubmit: schema },
onSubmit: ({ value }) => subscribe(value),
});
// An agent calls this.
useWebTool({
id: "subscribe_newsletter",
title: "Subscribe",
description:
"Subscribe an email address to the newsletter. Use when the " +
"user asks for news by email.",
inputSchema: schema,
execute: subscribe,
});
// A person submits this.
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex items-end gap-2"
>
<form.Field name="email">
{(field) => (
<div className="grid gap-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
// TanStack Form sets no name: the cursor needs one.
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<Button type="submit">Subscribe</Button>
{done && <p>{done}</p>}
</form>
);
}import { useForm } from "@tanstack/react-form";
import { useWebTool } from "@webmcp-agents/react";
import { useState } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const schema = z.object({
email: z
.email()
.describe("The address to subscribe, e.g. [email protected]"),
});
type SubscribeInput = z.infer<typeof schema>;
export function Subscribe() {
const [done, setDone] = useState("");
const subscribe = ({ email }: SubscribeInput) => {
setDone(`Subscribed ${email}.`);
return `Subscribed ${email}.`;
};
const form = useForm({
defaultValues: { email: "" },
validators: { onSubmit: schema },
onSubmit: ({ value }) => subscribe(value),
});
// An agent calls this.
useWebTool({
id: "subscribe_newsletter",
title: "Subscribe",
description:
"Subscribe an email address to the newsletter. Use when the " +
"user asks for news by email.",
inputSchema: schema,
execute: subscribe,
});
// A person submits this.
return (
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex items-end gap-2"
>
<form.Field name="email">
{(field) => (
<div className="grid gap-2">
<Label htmlFor={field.name}>Email</Label>
<Input
id={field.name}
// TanStack Form sets no name: the cursor needs one.
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
<Button type="submit">Subscribe</Button>
{done && <p>{done}</p>}
</form>
);
}Give each input the field’s name (name={field.name}): TanStack Form does not set it, and it is how the cursor finds
the field. For a custom widget, fill calls form.setFieldValue(…). Skyline’s traveler form
(examples/flights/src/webmcp-agents/hooks/use-traveler-details.ts) is a larger one, with a field per traveler.
Try it
Render <AddToCart /> on a page, then in the browser console:
await agent.invoke("add_to_cart", { size: "L", quantity: 2 });
The cursor types 2 into Quantity, the Size shows L, the cursor presses Add to cart, and the call answers “Added 2 ×
Ocean Tee, size L.” Try quantity: 0: the cursor stays put, and the call rejects with invalid_input, naming the bad
field.
Options you’ll use most
| Option | What it is for |
|---|---|
inputSchema |
The parameters, as a zod (or any Standard Schema) object or JSON Schema. Validated before anything moves. |
fill |
Put values on screen yourself, for widgets the cursor cannot type into. |
fields |
Where each parameter is typed, when the field is not named like it: a label, a locator, or false. |
target |
Which button the cursor presses, when the title does not name it. |
execute |
The action, shared with the form’s submit. Its return value is the answer. |
Every option: useWebTool and defineWebTool.