ブログ

UI SDK と WebMCP で React アプリを AI エージェントが操作できるようにする

UI SDK で AI エージェントに React UI を操作させる。createAgent、act、WebMCP。

Preet Suthar · · 10分で読めます

  • AI
  • React
  • UI SDK
  • WebMCP

ほとんどの AI エージェントは、遅いスクリーンリーダーを使う人のようにウェブアプリを使います。スクリーンショットを撮るか DOM を読み、どの要素が「Region」のドロップダウンかを推測してクリックし、ページをもう一度読んで、選択肢をクリックします。往復のたびにトークンと時間がかかり、推測のたびに間違える可能性があります。

2026 年 11 月 12 日に Vercel Labs が公開した UI SDK は、別の道を取ります。コンポーネントはブリッジに登録され、ブリッジはページに何があり、各コンポーネントがどんな状態で、何ができるかをエージェントに伝え、それらのアクションをコンポーネント自身の API を通じて実行します。同じブリッジは、WebMCP を通じてブラウザに組み込まれたエージェントにツールを公開することもできます。

この記事では、React で小さな設定ページを作ってエージェントを接続し、API の重要な部分を順に見ていきます: createAgent、snapshot()、act()、allow コールバック、変更コールバックの source: "agent"、definePrimitive で作るカスタムコンポーネント、そして registerTools です。以下の出力はすべて、React 19.3、Vite 8、0.1.0-beta.1 の @ui-sdk/react と @ui-sdk/core を使った検証用アプリでの実行からコピーしたものです。UI SDK はベータ版なので、名前や挙動は変わる可能性があります。

ブリッジの仕組み

あなたの React アプリ

  • Input
  • Select
  • Switch
  • あなたの definePrimitive のパーツ

ブリッジ: createAgent()

  • snapshot() がコンポーネント、状態、アクションを一覧にする
  • query() がコンポーネントとその選択肢を見つける
  • act() は各コンポーネントの API を通じてステップを実行する
  • allow が各ステップを承認または拒否する

自分のエージェント

agentTools によるツール定義を、使っているモデルの SDK につなぐ

ブラウザのエージェント

registerTools() が二つ目の allow 付きで document.modelContext(WebMCP)にツールを公開する

エージェントによる変更はすべて details.source === "agent" 付きで変更コールバックに届き、details.cancel() で拒否することもできます。

エージェントが UI SDK を通じてコンポーネントに届く仕組み

ブリッジは createAgent() が返すオブジェクトです。ページ上の UI SDK コンポーネントは、マウント時にそこに登録されます。エージェントが使うメソッドは三つです:

  • snapshot() は登録されたすべてのコンポーネントを、c3 のような短い ref、種類、アクセシブルなラベル、状態、現時点で受け付けるアクションとともに返します。
  • query() はテキストでコンポーネントを探したり、Select の選択肢のように一つのコンポーネント内の選択肢を一覧にしたりします。
  • act() はステップのリストを順に実行します。各ステップは setValue のようにコンポーネントとアクションを指定し、ブリッジがそのアクションをコンポーネント上で呼び出します。

agentTools はこの三つのメソッドを JSON Schema 付きのツール定義として記述し、独自のモデルループで使えるようにします。registerTools() は代わりにそれらをブラウザに登録します。

重要な設計上の選択は、act() がクリックをシミュレートしないことです。コントローラーが呼ぶのと同じ API を呼ぶので、変更は "agent" という source 付きでコンポーネントの変更コールバックを通ります。アプリは人による変更を見るのと同じ場所でエージェントによる変更を見て、そこで拒否できます。

Vercel のベンチマーク

Vercel のドキュメントにはベンチマークが一つあります。エージェントがプロジェクト名を変え、メンバーと通知設定を更新して保存するものです。一方のエージェントは、別のコンポーネントライブラリで作ったページでブラウザツールを使いました。もう一方は、UI SDK で作った同じ画面でエージェントブリッジを使いました。

ブラウザツールUI SDK のエージェントブリッジ
ツール呼び出し−69%
165
読んだテキスト(文字数)−83%
39.8k6.8k
時間−61%
41 s16 s
コスト−56%
$0.073$0.032

出典: Vercel の UI SDK ドキュメント。各方式で三回実行した中央値、Claude Code 経由の Sonnet。HextaUI では再現していない。

Vercel のプロジェクト設定ベンチマーク: ブラウザツールと UI SDK のエージェントブリッジ

これは Vercel の数字で、私のものではありません。各方式で三回実行した中央値で、どちらのエージェントも同じタスクとシステムプロンプトで Claude Code 経由の Sonnet を使い、すべての実行がタスクを完了しました。「読んだテキスト」はツールが返した文字数です。私はこのベンチマークを再現していません。規模の目安として、下のデモの完全なスナップショットは 729 文字の JSON です。Vite の開発サーバーでのページの HTML は約 28,000 文字です。同じページの Playwright の ARIA スナップショットはさらに短く約 370 文字でしたが、役割と名前しか並べません。各コントロールがどのアクションを受け付けるか、閉じた Select がどの選択肢を持つか、どのボタンに人の確認が必要かは示しません。エージェントの追加の呼び出しを減らすのは、生のサイズではなくこの追加の構造です。

デモを準備する

Vite の React と TypeScript のテンプレートから始めて、二つのパッケージを追加します:

npm create vite@latest agent-demo -- --template react-ts
cd agent-demo
npm install
npm install @ui-sdk/react@0.1.0-beta.1 @ui-sdk/core@0.1.0-beta.1

ブリッジは専用のモジュールで作ります。アプリがレンダリングされる前に main.tsx でインポートすれば、コンポーネントが作られるそばから認識できます。ドキュメントによると、すでにページ上にあるコンポーネントも見つけるので、アシスタントが開いたときなど後から作ることもできます。

import { createAgent, registerTools } from "@ui-sdk/react/agent"

const agent = createAgent({
  allow: (step) => {
    if (step.confirm) return window.confirm(step.confirm)
    return step.action !== "clear"
  },
})

const unregister = registerTools(agent, {
  allow: (step) => step.action !== "press",
})

Object.assign(window, { agent })

if (import.meta.hot) import.meta.hot.dispose(unregister)

export { agent }

二つの allow コールバックについては後でまた触れます。Object.assign(window, { agent }) はブリッジを window に載せるので、コンソールから試せます。

ページは、テキストフィールド、Select、Switch、自作の席数カウンター、Save ボタンからなる設定フォームです。この抜粋ではクラス、レイアウト、ログのコンポーネントを省き、エージェントに関係する部分だけを残しています:

<Field>
  <FieldLabel>Project name</FieldLabel>
  <Input
    defaultValue="Apollo"
    onValueChange={(value, details) =>
      record(`Project name → ${value}`, details.source)
    }
  />
</Field>

<Select
  defaultValue="europe"
  items={regions}
  onValueChange={(value, details) => {
    if (details.source === "agent" && value === "asia-pacific") {
      details.cancel()
      return
    }
    record(`Region → ${value}`, details.source)
  }}
>
  <SelectLabel>Region</SelectLabel>
  <SelectTrigger>
    <SelectValue />
  </SelectTrigger>
  <SelectContent>
    {regions.map((region) => (
      <SelectItem key={region.value} value={region.value}>
        {region.label}
      </SelectItem>
    ))}
  </SelectContent>
</Select>

<label>
  Email notifications
  <Switch
    defaultChecked
    onCheckedChange={(checked, details) =>
      record(`Email notifications → ${checked}`, details.source)
    }
  >
    <SwitchThumb />
  </Switch>
</label>

<Button
  data-agent-confirm="Saves the settings for every member of Atlas"
  onClick={(_event, { source }) => record("Save pressed", source)}
>
  Save
</Button>

record はページ上のログに一行を追加するので、誰が何を変えたかがわかります。ここではいつも以上にラベルが重要です。ブリッジはアクセシブルな名前でコンポーネントを呼ぶので、FieldLabel、SelectLabel、そして囲んでいる <label> があるからこそ、エージェントは推測せずに「Region」と言えます。

snapshot() でページを読む

ページがレンダリングされた状態で、agent.snapshot() はこれを返しました:

[
  { "ref": "c1", "component": "input", "actions": ["setValue", "clear"], "label": "Project name", "value": "Apollo" },
  {
    "ref": "c3",
    "component": "select",
    "actions": ["setOpen", "setValue", "clear", "setQuery"],
    "label": "Region",
    "open": false,
    "value": "europe",
    "query": "",
    "items": [
      { "value": "europe", "label": "Europe" },
      { "value": "north-america", "label": "North America" },
      { "value": "asia-pacific", "label": "Asia Pacific" }
    ]
  },
  { "ref": "c4", "component": "switch", "actions": ["setChecked"], "label": "Email notifications", "checked": true, "value": "on" },
  { "ref": "c5", "component": "seat-stepper", "actions": ["add", "remove"], "label": "Seats", "value": 3 },
  { "ref": "c6", "component": "button", "actions": ["press"], "label": "Save", "confirm": "Saves the settings for every member of Atlas" }
]

注目すべき点がいくつかあります:

  • c2 はありません。Field も登録されていますが、入力がすでにラベルを持っているので、Field はエラーがある間だけスナップショットに表示されます。ref の欠番は無害です。
  • デモでは items を渡しているので、Select は閉じている間もアイテムを一覧にします。渡さない場合は query({ within: "Region" }) で選択肢を読みますが、そのためにブリッジが一瞬リストを開くことがあります。
  • Save ボタンには data-agent-confirm からの confirm のテキストが付いているので、エージェントは試す前から人の承認が必要だとわかります。
  • seat-stepper は自作のコンポーネントです。add と remove が並んでいるのは定義でそう指定したからで、それについては後で説明します。

act() で変更する

一回の act() 呼び出しに、エージェントがすでにわかっているステップをすべて載せられます。ステップは ref かラベルでコンポーネントを指定し、ブリッジは大文字小文字やアクセントを区別せずに単語単位で照合します。アイテムもラベルで指定できます:

const result = await agent.act({
  steps: [
    { ref: "Project name", action: "setValue", args: ["Atlas"] },
    { ref: "Region", action: "setValue", args: ["North America"] },
    { ref: "Email notifications", action: "setChecked", args: [false] },
    { ref: "Seats", action: "add", args: [2] },
  ],
})

結果は各ステップの変更と、すべてのフィールドの現在の値を報告します:

{
  "ok": true,
  "steps": [
    { "step": 0, "ref": "c1", "changes": [{ "component": "input", "label": "Project name", "key": "value", "value": "Atlas" }] },
    { "step": 1, "ref": "c3", "changes": [{ "component": "select", "label": "Region", "key": "value", "value": "north-america" }] },
    { "step": 2, "ref": "c4", "changes": [{ "component": "switch", "label": "Email notifications", "key": "checked", "value": false }] },
    { "step": 3, "ref": "c5", "changes": [{ "component": "seat-stepper", "label": "Seats", "key": "value", "value": 5 }] }
  ],
  "appeared": [],
  "values": {
    "c1 Project name": "Atlas",
    "c3 Region": "north-america",
    "c4 Email notifications": false,
    "c5 Seats": 5
  }
}

「North America」はアイテムの値 north-america に解決されました。values は毎回の呼び出しで返ってくるので、エージェントは結果を確かめるために再びスナップショットを取る必要がありません。

エラーはモデルが読むために書かれています。わざと起こした二つの例:

setChecked on Email notifications (c4) takes true or false, not "yes".
seat-stepper c5 takes no keydown now. It takes: add, remove.

失敗したステップで呼び出しは止まり、それまでのステップは適用されたままです。ロールバックはありません。

照合のルールで一つ驚いたことがあります。ref: "Name" のステップが「Project name」を変更したのです。ラベルは単語単位で照合されるからです。これは文書化されていて、ブリッジは複数のコンポーネントに一致する名前を拒否しますが、エージェントが推測で指定しそうなら、コンポーネントには区別できるラベルを付けてください。

権限: allow、確認マーク、cancel

拒否できる場所は三つあり、それぞれ役割が違います。

createAgent の allow は、各ステップを実行前に ref、component、label、action、args、confirm とともに確認します。拒否するなら false を、独自の確認 UI を出すならプロミスを返します。私のポリシーはすべての clear を拒否し、その結果(ここでは values を省略)は次のとおりです:

{ "ok": false, "error": { "step": 0, "message": "The app refused clear on c1." }, "steps": [] }

data-agent-confirm は、ルールがどうであれ常に人の確認が必要なコントロールを示します。allow コールバックがない場合、そのコントロールへのステップは「Needs a person」というメッセージで失敗します。ある場合は step.confirm にその文が入るので、表示できます。私の allow はそれを window.confirm に渡していて、実行時には Save が押される前に「Saves the settings for every member of Atlas」というダイアログが出ました。このマークは要素の中のすべてに及ぶので、フォームやダイアログ全体に付けることもできます。

値について最終的に決めるのは変更コールバックです。Region の Select は、エージェントによる Asia Pacific へのリクエストをキャンセルします:

{
  "ok": true,
  "steps": [{ "step": 0, "ref": "c3", "result": false }],
  "appeared": [],
  "values": {
    "c1 Project name": "Atlas",
    "c3 Region": "north-america",
    "c4 Email notifications": false,
    "c5 Seats": 5
  }
}

ok が true であることに注意してください。ブリッジはステップを実行し、コールバックが変更をキャンセルしたので、ステップは result: false を報告し、値は変わっていません。ドキュメントにもはっきり書かれています: ok: true はすべての値が受け入れられたという意味ではありません。changes と values を確認してください。

エージェントに見えるものを決める属性も二つあります。data-agent-hidden は領域を snapshot と query から除きます。data-agent-secret はフィールドを表示しつつ、その値を "[redacted]" とします。パスワード、カード、ワンタイムコードのフィールドは、マークがなくても秘密として扱われます。

source でエージェントによる変更を見分ける

UI SDK の変更コールバックはすべて details.source を受け取ります。値は keyboard、mouse、touch、pen、virtual、programmatic、agent のいずれかです。上の実行の後、デモのログは次のようになりました:

Project name が Atlas、Region が North America、通知がオフ、席数が 5 の Project settings フォーム。その下に、各行が source: agent で終わるログがある
エージェントが実行した後のデモ。ログの各行は変更コールバックから出ていて、どれも source: agent を報告している。

その後プラスボタンを手でクリックすると Seats → 2 (source: mouse) と記録されました。この一つのフィールドだけで、「アシスタントが編集」のバッジを作ったり、エージェントによる変更をレビューキューに送ったり、人が起こしていない分析イベントを省いたりできます。

ドキュメントにある注意点を一つ。何かを await してから状態を変える Button のハンドラーは "programmatic" を報告します。その時点で押下は終わっているからです。後の変更を @ui-sdk/react/observe の withSource(source, …) で包めば、変更元をエージェントのままにできます。

自作のコンポーネント: definePrimitive と agent キー

組み込みのコンポーネントは自動でブリッジに参加します。definePrimitive で書いたコンポーネントも参加します。これはデモの席数カウンターで、エージェントが呼び出せる二つのメソッドを agent キーで指定しています:

import { createChange, definePrimitive, type ChangeDetails } from "@ui-sdk/core"

type SeatStepperOptions = {
  value?: number
  defaultValue?: number
  max?: number
  onValueChange?: (
    value: number,
    details: ChangeDetails<"add" | "remove">,
  ) => void
}

const seatStepper = definePrimitive({
  name: "seat-stepper",
  options: {
    value: "number",
    defaultValue: "number",
    max: "number",
    onValueChange: "callback",
  },
  controlled: ["value"],
  state: (options: SeatStepperOptions) => ({
    value: options.value ?? options.defaultValue ?? 1,
  }),
  sync: (options) =>
    options.value === undefined ? {} : { value: options.value },
  api: (context) => {
    const change = createChange(context)
    const max = () => context.options().max ?? 50
    return {
      add: (count = 1, event?: Event) =>
        change("value", Math.min(max(), context.store.get().value + count), {
          reason: "add",
          event,
        }),
      remove: (count = 1, event?: Event) =>
        change("value", Math.max(1, context.store.get().value - count), {
          reason: "remove",
          event,
        }),
    }
  },
  slots: (slot) => ({
    root: slot<{}, { value: number }>({
      role: "group",
      state: (state) => ({ value: state.value }),
    }),
    remove: slot({
      attrs: (state) => ({
        type: "button",
        "aria-label": "Remove a seat",
        disabled: state.value <= 1 ? "" : undefined,
      }),
      press: ({ api }, event) => api.remove(1, event),
    }),
    add: slot({
      attrs: () => ({ type: "button", "aria-label": "Add a seat" }),
      press: ({ api }, event) => api.add(1, event),
    }),
  }),
  agent: { actions: ["add", "remove"] },
})

export { seatStepper }

@ui-sdk/react/primitive の createParts(seatStepper, { root: "div", remove: "button", add: "button" }) は、これを React コンポーネントに変えます。ルートは他の要素と同じように aria-labelledby を受け付け、スナップショットの「Seats」というラベルはそこから来ています。

agent キーが重要な理由: これがないと、ブリッジはメソッド名からアクションを推測します。setValue のように set の後に大文字が続くものや、open、toggle、clear のような動詞を認識します。add や remove は推測しないので、ステッパーにはアクションが表示されません。キーがあれば、列挙したメソッドだけが提供され、それ以外は拒否されます。上の keydown のエラーがそれを示しています。ドキュメントは、TypeScript がその時点で API を読み終えているように agent を slots の後の最後に置くよう求めていて、このキーを experimental としています。

変更は createChange を通るので、アプリの onValueChange には引き続き source: "agent" が渡され、キャンセルできます。エージェントが三席から五席を減らすよう求めたときは、値は 1 で止まり、削除ボタンは無効になりました。人がクリックした場合とまったく同じ結果です。

書いていて気づいたことが二つあります。attrs の属性値は文字列なので、disabled は真偽値ではなく "" か undefined です。これは TypeScript が見つけてくれました。また、Core のドキュメントは attrs に role を入れずスロットの role キーを使うよう書いていますが、Core の例の一つは attrs に role を入れています。私は role キーを使いました。

WebMCP 向けにツールを登録する

registerTools(agent, { allow }) は snapshot、query、act を、WebMCP の提案にあるブラウザ API である document.modelContext に公開します。これを提供するブラウザは、ツールを組み込みのエージェントに渡せます。提供しないブラウザでは、registerTools は何もしません。

テストに使ったヘッドレス Chromium は document.modelContext を提供していないので、何が登録されるかを見るために、ページの読み込み前に代わりのオブジェクトを入れました。三つのツールは次のアノテーション付きで届きました:

[
  { "name": "snapshot", "annotations": { "readOnlyHint": true, "untrustedContentHint": true } },
  { "name": "act", "annotations": { "readOnlyHint": false, "untrustedContentHint": true } },
  { "name": "query", "annotations": { "readOnlyHint": true, "untrustedContentHint": true } }
]

untrustedContentHint があるのは、ツールの結果に、表の行にある名前のようなユーザーが書いたページのテキストが含まれるからです。ツールの説明もモデルに、そのテキストを指示ではなくデータとして扱うよう伝えています。これはヒントであって保証ではないので、危険なものは allow と data-agent-confirm の後ろに置いてください。

registerTools の allow は必須で、ブリッジ自身の allow の後に、ブラウザのエージェントから来たステップに対してだけ実行されます。これでポリシーが二つになります。自分で書いたエージェント用と、自分で書いていないエージェント用のより厳しいものです。

  1. 対象を見つけるrefc3 のような ref、次にラベル、アイテム、そして種類。
  2. ブリッジの allowcreateAgentすべての呼び出し元に対するポリシー。false で呼び出しが止まる。
  3. WebMCP の allowregisterToolsブラウザのエージェント専用の二つ目のポリシー。
  4. コンポーネントの APIsetValueコントローラーが呼ぶのと同じメソッド。
  5. あなたのコールバックdetails.source"agent" を受け取り、cancel() を呼べる。
一つの act ステップに起きること

UI SDK のガイドでは、ChatGPT デスクトップアプリの内蔵ブラウザからツールを使う手順を説明しています。そこではサイトのツールを有効にする必要があり、対応しているのは一部のモデルとワークスペースだけです。私はその経路を試していません。対象のブラウザで typeof document.modelContext?.registerTool を確認してください。登録に対応していれば "function" が返ります。

ベータで見つけたこと: 人のせいにされる拒否

私の構成では、ブリッジの allow は Save について人に確認を求め、WebMCP の allow はすべての press を拒否します。登録された act ツールを呼び出して Save を押そうとすると、確認ダイアログが表示され、私は承認しましたが、その後 WebMCP のポリシーがステップを拒否しました。エラーはこうでした:

The person said no to "Saves the settings for every member of Atlas".

人は承認しました。コードは拒否しました。ステップが拒否されたのは正しいのですが、このメッセージを読んだモデルは、ユーザーが承認したことをユーザー自身が断ったと伝えかねません。また、必ず拒否することになるポリシーを確認する前に人に尋ねています。確認マークと、押下を拒否する WebMCP の allow を組み合わせる場合は、もっと早く拒否してください。ブリッジの allow でも step.action を確認すれば、人に尋ねることはありません。これは 0.1.0-beta.1 に対して報告していて、変わるはずだと考えています。

これが価値を持つとき

ブリッジが真価を発揮するのは、エージェントがプロダクトの一部である場合です。フォームを埋めるアシスタントパネル、ユーザーの代わりに設定を変えるサポートツール、あるいは自分のサイトでうまく動いてほしいブラウザのエージェントなどです。テストでも役立ちます。act() を使えば、セレクターなしで型付きの方法でコンポーネントを操作できるからです。

これだけを理由に安定したアプリを移行すべきではありません。ブリッジが見えるのは UI SDK のコンポーネントだけなので、ライブラリが混在したページではエージェントには一部しか見えません。それにベータ版で、agent キーと履歴のヘルパーは experimental とされています。

これを土台にするなら、エージェントを役立つ安全なものに保つための習慣がいくつかあります:

  • すべてのコントロールにアクセシブルな名前を付ける。エージェントが入力するのはその名前だから。
  • Select と Combobox には items を渡し、閉じている間も選択肢を読めるようにする。
  • エージェントが決してすべきでないことは allow で拒否し、人が必要なものは data-agent-confirm でマークする。
  • 変更コールバックで値を検証する。setValue は API を直接呼び出し、キーボードやポインターのハンドラーを通らないから。
  • ok を信用せず、すべての act() の結果から values を読む。

HextaUI はスタイル付きの UI SDK 版のコンポーネントを作っていて、ここで紹介したのはそのために使っているパターンです。エージェント以外の面で UI SDK が Radix や Base UI とどう違うかはUI SDK vs Base UI vs Radixを、Tailwind でのスタイリングについてはTailwind で Vercel の UI SDK をスタイリングするを参照してください。