AG-UIとは?AIエージェントとUIを繋ぐ新プロトコルの仕組みと実装方法を解説

- AG-UIはAIエージェントとフロントエンドの通信を標準化するオープンなイベントベースのプロトコル
- AG-UIはUIを自動生成するツールではなく、エージェントと画面をつなぐ共通の通信規格
- MCPはツール接続、A2Aはエージェント間、AG-UIはエージェントとユーザーという役割分担
AG-UIは、AIエージェントとフロントエンドの通信を標準化するオープンなイベントベースのプロトコルです。公開は2025年5月、GitHubのスターは約15,900(2026年9月時点)。UIを作るツールではなく、接続を作り直す手間をなくす規格ですが、従来のAPIと何が違うのか、ピンとこない方も多いのではないでしょうか。

この記事では、なぜ必要か・何ができるか・MCPとの違い・実装方法を解説します。読めば、自社への組み込みを判断できますよ。
\生成AIを活用して業務プロセスを自動化/
AG-UIとは
| 項目 | 内容 |
|---|---|
| 正式名称 | Agent-User Interaction Protocol |
| 開発主体 | CopilotKit(Tawkit, Inc.) |
| 公開時期 | 2025年5月 |
| ライセンス | MIT(商用利用可) |
| 通信方式 | HTTP+Server-Sent Events(SSE)が基本。 protobufなどのバイナリ形式や、WebSocketなど別チャネルの利用は任意 |
| 公式サイト | https://ag-ui.com/ |
| リポジトリ | https://github.com/ag-ui-protocol/ag-ui (スター約15,900) |
AG-UIの正式名称はAgent-User Interaction Protocol。AIエージェントと、ユーザーが触るアプリとの通信を標準化するオープンソースのイベントベースプロトコルです。名前に「UI」と入っていますが、画面を自動で作るツールではありません。エージェントが何を実行し、どんな結果を返したのかを、決まった形式のイベントとしてフロントエンドへ流す規格です。
開発を主導するのは、AIコパイロット向けライブラリを手がけるCopilotKit(Tawkit, Inc.)。LangChain・CrewAIとの提携から生まれ、2025年5月12日に公開されました。
AG-UIが生まれた背景
LangGraph・CrewAI・Mastraは、それぞれ独自のイベント形式で応答を返します。乗り換えのたびにアダプタを作り直すことになり、場当たり的な処理が積み上がっていました。長時間動き続けるエージェントを、REST APIの前提では受け止めきれないわけですね。
同じころ、ツール接続を担うMCPとエージェント間連携を担うA2Aが登場。残された「エージェントとユーザー」をつなぐ規格が、AG-UIでした。
AIエージェントそのものの仕組みから理解したい方は、以下の記事もあわせてご覧ください。

AG-UIを使うと何が実現できるのか

AG-UIを挟むと、エージェントの動きをリアルタイムに映すUIを、フレームワークに依存しない形で作れます。従来のREST APIは、リクエストを投げて結果を1回受け取る形式でした。数十秒から数分かけて思考し、途中でツールを呼ぶエージェントとは相性がよくありません。
AG-UIはこの通信をイベントのストリームとして流します。「実行が始まった」「テキストの続きが届いた」「ツールを呼び出した」といった出来事が、標準化された形式で次々と画面に届く仕組みです。
受け取る側は決まったイベント名だけ見ていればよく、バックエンドを差し替えても画面のコードは動き続けます。
従来のAPIとの違い
決定的な違いは、通信が1往復で終わるか、流れ続けるかです。
REST APIはリクエスト1回につきレスポンスが1回。処理が終わるまで画面には何も出せず、ユーザーはローディング表示を眺めることになります。
対してAG-UIは、HTTPのPOSTで実行を開始したあと、SSE(Server-Sent Events)でイベントを連続的に返します。HTTPで実装する場合はSSEへの対応が必須で、protobufなどのバイナリ形式は任意です。WebSocketなど別のチャネルで運ぶことも認められています。
さらに互換性を吸収するミドルウェア層もあります。イベント形式を厳密に合わせなくても、AG-UI互換であれば接続できます。この緩やかな適合が、対応フレームワークの広がりを支えています。
リアルタイムストリーミングとツール呼び出しの可視化
AG-UIでは、エージェントの応答をトークン単位で画面に流せます。テキストはTEXT_MESSAGE_STARTで開始、TEXT_MESSAGE_CONTENTで差分、TEXT_MESSAGE_ENDで終了を伝える3段構え。自前の処理は要りません。
ツール呼び出しも同じ考え方。TOOL_CALL_START・TOOL_CALL_ARGS・TOOL_CALL_ENDが順に届き、「いま社内データベースを検索中」と作業状況をそのまま画面に出せます。
結果のTOOL_CALL_RESULTは、サーバー側のツールなら同じ実行の中で届く仕組みです。エージェントが黙り込む時間を減らせるわけです。
状態同期とユーザー承認(Human-in-the-loop)
AG-UIは、エージェント側とフロントエンド側が同じ状態を共有する仕組みを備えています。
使うのはSTATE_SNAPSHOTとSTATE_DELTAの2つ。前者は状態の全体像を送り、後者はRFC 6902のJSON Patch形式で差分だけを送ります。毎回すべてを送り直さずに済む設計です。
もうひとつの柱がHuman-in-the-loop。2026年9月時点の1.0ドラフト仕様では、実行を中断状態で一度終え、ユーザーの承認を受けてから再開できます。メール送信や決済のように取り返しがつかない操作の前に、人の確認を挟めます。
WEELへの相談から見えるエージェント連携ニーズ
自社サービスにAIエージェントを組み込みたい、という相談は弊社WEELにも数多く寄せられます。話を聞くと、詰まっているのはエージェントの頭脳部分ではないケースが目立ちます。チャットUIの設計・会話状態の持ち方・フロントとバックのつなぎ方といった、周辺の作り込みに工数が吸われるパターンです。
しかもこの部分は、フレームワークを乗り換えるたびに作り直しになります。検証用に使っていたフレームワークを本番用に差し替えた途端、画面側の改修が必要になる。よくある話です。
AG-UIのような標準規格を挟んでおけば、接続部分の実装コストを抑えられる可能性があります。
MCP・A2A・A2UIの違い

AIエージェント関連のプロトコルは短期間で増えましたが、役割はきれいに分かれています。
AG-UI公式の整理は明快です。MCPはエージェントをツールにつなぎ、A2Aはエージェント同士をつなぎ、AG-UIはエージェントをユーザーにつなぐ。競合ではなく、層が違う補完関係にあります。
社内の問い合わせ対応エージェントなら、データベース参照にMCP、専門エージェントへの委譲にA2A、画面表示にAG-UIという組み合わせです。
どれかを選ぶのではなく、必要な層を重ねて使うものと捉えてください。
MCP(Model Context Protocol)との違い
MCPとAG-UIは、エージェントの「どちら側」をつなぐかが違います。
MCPはAnthropicが2024年11月に公開した規格。エージェントを外部のツールやデータソースに接続する、公式いわく「AIアプリにとってのUSB-Cポート」にあたります。
一方のAG-UIがつなぐのは、ユーザーが操作する画面。MCPが後ろ側の配線なら、AG-UIは前側の配線にあたります。なおMCPは2025年12月、Linux Foundation傘下のAgentic AI Foundationへ寄贈されました。
A2A(Agent2Agent Protocol)との違い
A2AはAIエージェント同士、AG-UIはAIエージェントと人をつなぎます。
A2AはGoogleが2025年4月に発表したオープン規格。エージェントが別のエージェントに仕事を依頼するための共通の話し方を定めています。
2025年6月にLinux Foundationへ寄贈され、2026年3月にv1.0へ到達。2026年8月にはMCPと同じAgentic AI Foundation傘下へ移りました。
複数のエージェントを組み合わせる構成では、両者が同時に動きます。窓口役がA2Aで処理を振り、進捗をAG-UIで画面に返す、といった具合ですね。
A2UIとの違い
名前は似ていますが、A2UIとAG-UIは担当する領域がまったく違います。
A2UI(Agent to UI)はGoogleが2025年12月に公開した規格。2026年9月時点も開発はGoogleが主導しており、エージェントがUIの中身を宣言的なJSONで記述するための仕様です。アプリ側が用意した部品に対応づけて描画します。
対してAG-UIが担うのは、UIの中身ではなく通信とイベントの標準化。
両者は競合しません。A2UI公式も、AG-UIを輸送路としてA2UIのデータ形式を運べると明記しています。なお2026年9月時点のA2UIはv0.9.1で、早期プレビュー段階。v1.0仕様はリリース候補の状態です。
対応ツール・フレームワーク
| 区分 | 対応フレームワーク・SDK |
|---|---|
| 提携 | LangChain(LangGraph)・CrewAI |
| 1st Party | Microsoft Agent Framework・Google ADK・AWS Strands Agents・Mastra・Pydantic AI・Agno・LlamaIndex・AG2 |
| コミュニティ | Claude Agent SDK・Claude Managed Agents SDK・Langroidほか |
2026年9月時点で、AG-UIには20を超えるフレームワーク・SDKが対応しています。公式リポジトリは対応状況を区分ごとに整理しています。提携関係にあるLangChain(LangGraph)とCrewAI、公式が直接サポートする1st Party、そしてコミュニティ提供のものです。
1st PartyにはMicrosoft Agent Framework・Google ADK・AWS Strands Agentsなどが並びます。大手クラウドベンダーの基盤が揃っている点は、技術選定の安心材料になるでしょう。
TanStack AI(AG-UI対応フレームワークの一例)
TanStack AIはAG-UIではなく、AG-UI準拠のフレームワークの一例です。TanStack Query・Routerと同じチームの製品。公式サイトは「AG-UI compliant, in both directions」と明記しています。
強みは型安全性。設定が型で絞り込まれ、誤った値はエディタが弾きます。Query・Routerと同じ作法で書ける点も利点です。
実装は単純。AG-UI準拠のエンドポイントを立て、チャット用フックにURLを渡すだけ。イベント解釈はライブラリが担い、画面は配列を描くだけで済みます。なお2026年8月にRC入りしたばかりで、AG-UI公式の一覧に記載はありません。
その他の対応フレームワーク・SDK
代表的なものを補足します。CopilotKitは開発元のUIライブラリで、チャット画面ごと用意できるのが強み。MastraはTypeScript製のフレームワークで、AG-UI対応が1st Partyとして組み込まれています。LangGraphは処理の流れを細かく制御する用途向き。
公式リポジトリではTypeScript・Python向けSDKが提供されています。Java・Kotlin・Go・Dart・Rust・Ruby・C++・.NETは、2026年9月時点でコミュニティ提供のSDKとして対応済みです。
挙動はAG-UI Dojo(公式が用意した機能別のサンプル集)で確かめられます。対応状況の更新は速いため、採用前に公式ドキュメントで確認してください。
ほかのAIエージェントサービスも比較検討したい方は、以下の記事もあわせてご覧ください。

実装する方法
AG-UIの解説を読んでも、「結局、どのファイルを作れば動くのか分からない」と感じる方は多いでしょう。
AG-UIは、AIエージェントとユーザー向け画面をつなぐイベントベースのプロトコルです。ただし、プロトコルの説明だけでは、ブラウザに画面が表示されるところまで再現できません。
そこで本記事では、PythonとFastAPIを使い、AG-UI対応の最小アプリをゼロから作ります。LLMのAPIキーは不要です。決め打ちの回答を返すデモのため、AG-UIの通信部分だけに集中できます。
記事内のコードとコマンドは、筆者の環境で実際に動作を確認しました。最後まで進めると、次の処理をブラウザ上で確認できます。
- AG-UIエンドポイントへのPOST送信
- SSEによるイベントのストリーミング
- 文章が少しずつ表示される様子
- ツール名・引数・実行結果の表示
- エージェントと画面で共有する状態の更新
- 実行開始から終了までのイベント履歴
実装の基本的な流れ
既存のエージェントを流用できます。1st Party対応のフレームワークなら、公式アダプタがイベント変換を肩代わりしてくれます。
POSTで実行を受け取り、SSEでイベントを1件ずつ返す形にします。レスポンスのContent-Typeはtext/event-streamです。
イベント名ごとに分岐を書くだけです。RUN_STARTEDで表示を切り替え、TEXT_MESSAGE_CONTENTで文字を追記する、といった処理を並べます。
サンプルコードで見る実装イメージ
このあとの手順では、3段階をPythonとFastAPIで実際に作っていきます。完成する画面は、左側がユーザー向け表示、右側がAG-UIイベントのログです。

「エージェントを実行」を押すと、ブラウザからFastAPIの/agentへPOSTリクエストが送られます。サーバーは結果を一度に返さず、AG-UIイベントをSSEで順番に返します。
今回の構成は次のとおりです。
ブラウザ(index.html)
│ POST /agent
▼
FastAPI(server.py)
│ RUN_STARTED
│ TEXT_MESSAGE_CONTENT
│ TOOL_CALL_START / RESULT
│ STATE_DELTA
│ RUN_FINISHED
▼
ブラウザがイベントごとに画面を更新始める前に用意するもの
この手順では、Python 3.10以降、テキストエディタ(VS Codeなど)、ターミナルを使います。macOSまたはLinuxでは「ターミナル」、Windowsでは「PowerShell」を開いてください。
requirements.txt、server.py、index.htmlは、手順1で作るag-ui-reproductionフォルダへ同じ階層で保存します。コマンドはコードブロック内を上から1行ずつ実行し、「$」や「(.venv)」などターミナル側に表示される文字は入力しません。
Uvicornを起動したターミナルは閉じず、そのまま動かします。手順7のcurlは、2つ目のターミナルを新しく開いて実行してください。
動作確認した環境
本記事では、macOS 26.4(Apple Silicon)上の次のバージョンで動作を確認しています。
- Python 3.14.6
- ag-ui-protocol 0.1.22
- FastAPI 0.141.1
- Uvicorn 0.52.4
Python 3.10以降を目安にしてください。Pythonのバージョンは、ターミナルで次のコマンドを実行すると確認できます。
python3 --versionWindowsでpython3が見つからない場合は、以降のpython3をpythonまたはpyに読み替えてください。
作業フォルダと仮想環境を作る
最初に、今回のファイルだけを入れるフォルダを作ります。
mkdir ag-ui-reproduction
cd ag-ui-reproduction
python3 -m venv .venv続いて、仮想環境を有効にします。macOSまたはLinuxでは次のコマンドを実行します。
source .venv/bin/activateWindowsのPowerShellでは次のコマンドです。
.venv\Scripts\Activate.ps1コマンド行の先頭に(.venv)と表示されたら、仮想環境が有効です。
必要なパッケージをインストールする
次の内容でrequirements.txtを作成します。
ag-ui-protocol==0.1.22
fastapi==0.141.1
uvicorn[standard]==0.52.4作成後、次のコマンドを実行してください。
python -m pip install -r requirements.txtここで注意したいのが、パッケージ名とimport名の違いです。インストール時はag-ui-protocolですが、Pythonコードではag_uiと記述します。
from ag_ui.core import RunAgentInputModuleNotFoundError: No module named ‘ag_ui’が出た場合は、仮想環境が有効になっているか確認してください。そのうえで、もう一度インストールコマンドを実行します。
AG-UIイベントを返すサーバーを作る
作業フォルダの直下にserver.pyを作ります。以下のコードをserver.pyへ、そのまま貼り付けてください。
import asyncio
from pathlib import Path
from ag_ui.core import (
EventType,
RunAgentInput,
RunFinishedEvent,
RunStartedEvent,
StateDeltaEvent,
StateSnapshotEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from ag_ui.encoder import EventEncoder
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse
app = FastAPI(title="AG-UI minimal demo")
@app.get("/")
async def index():
html = Path(__file__).with_name("index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
async def stream_message(encoder, message_id, chunks):
yield encoder.encode(
TextMessageStartEvent(
type=EventType.TEXT_MESSAGE_START,
message_id=message_id,
role="assistant",
)
)
for chunk in chunks:
yield encoder.encode(
TextMessageContentEvent(
type=EventType.TEXT_MESSAGE_CONTENT,
message_id=message_id,
delta=chunk,
)
)
await asyncio.sleep(0.18)
yield encoder.encode(
TextMessageEndEvent(
type=EventType.TEXT_MESSAGE_END,
message_id=message_id,
)
)
@app.post("/agent")
async def agent_endpoint(input_data: RunAgentInput, request: Request):
encoder = EventEncoder(accept=request.headers.get("accept"))
async def event_generator():
yield encoder.encode(
RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
)
)
yield encoder.encode(
StateSnapshotEvent(
type=EventType.STATE_SNAPSHOT,
snapshot={"phase": "受付", "調査済み件数": 0},
)
)
async for event in stream_message(
encoder,
"msg_1",
["お問い合わせ", "内容を", "確認しています。", "社内規程を", "検索しますね。"],
):
yield event
yield encoder.encode(
ToolCallStartEvent(
type=EventType.TOOL_CALL_START,
tool_call_id="tool_1",
tool_call_name="search_knowledge_base",
parent_message_id="msg_1",
)
)
yield encoder.encode(
ToolCallArgsEvent(
type=EventType.TOOL_CALL_ARGS,
tool_call_id="tool_1",
delta='{"query": "有給休暇 繰り越し"}',
)
)
yield encoder.encode(
ToolCallEndEvent(
type=EventType.TOOL_CALL_END,
tool_call_id="tool_1",
)
)
yield encoder.encode(
ToolCallResultEvent(
type=EventType.TOOL_CALL_RESULT,
message_id="msg_tool_result",
tool_call_id="tool_1",
content="就業規則 第32条:未消化分は翌年度に限り繰り越せる(上限20日)",
)
)
yield encoder.encode(
StateDeltaEvent(
type=EventType.STATE_DELTA,
delta=[
{"op": "replace", "path": "/phase", "value": "回答生成"},
{"op": "replace", "path": "/調査済み件数", "value": 1},
],
)
)
async for event in stream_message(
encoder,
"msg_2",
["就業規則", "第32条によると、", "未消化の有給休暇は", "翌年度に限り", "20日まで繰り越せます。"],
):
yield event
yield encoder.encode(
RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
)
)
return StreamingResponse(
event_generator(), media_type=encoder.get_content_type()
)このサーバーには、2つのエンドポイントがあります。GET /はブラウザへindex.htmlを返し、POST /agentはAG-UIイベントをSSEで返します。重要なのは、POST /agentの中にあるevent_generator()です。この非同期ジェネレーターが、イベントを1件ずつyieldします。
@app.post("/agent")
async def agent_endpoint(input_data: RunAgentInput, request: Request):
encoder = EventEncoder(accept=request.headers.get("accept"))
async def event_generator():
yield encoder.encode(RunStartedEvent(
type=EventType.RUN_STARTED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
))
# この間にメッセージ、ツール、状態更新などのイベントを送る
yield encoder.encode(RunFinishedEvent(
type=EventType.RUN_FINISHED,
thread_id=input_data.thread_id,
run_id=input_data.run_id,
))
return StreamingResponse(
event_generator(),
media_type=encoder.get_content_type(),
)EventEncoderを使うと、PythonのイベントオブジェクトをAG-UIの通信形式へ変換できます。フィールド名も、Python側のthread_idから通信上のthreadIdへ変換されます。
今回の完成コードは、次の順番でイベントを送ります。
- RUN_STARTEDで実行開始を通知する
- STATE_SNAPSHOTで状態全体を送る
- TEXT_MESSAGE_*で文章を少しずつ送る
- TOOL_CALL_*でツール名・引数・結果を送る
- STATE_DELTAで状態の差分を送る
- 2通目のメッセージを送る
- RUN_FINISHEDで完了を通知する
イベントを送る順番
実サービスでは、固定文字列の代わりにLLMやLangGraphなどの出力をイベントへ変換します。まずは固定データで、通信と画面更新が正しく動くことを確認するのが安全です。
イベントを表示する画面を作る
server.pyと同じ場所にindex.htmlを作ります。以下のコードをindex.htmlへ、そのまま貼り付けてください。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AG-UI 最小デモ</title>
<style>
:root{font-family:system-ui,sans-serif;color:#1c2333;background:#edf1f7}*{box-sizing:border-box}
body{margin:0;padding:32px}.app{max-width:1100px;margin:auto;background:#fff;border-radius:16px;box-shadow:0 18px 50px #33415522;overflow:hidden}
header{display:flex;align-items:center;padding:18px 22px;border-bottom:1px solid #e2e8f0}header h1{font-size:18px;margin:0}header span{margin-left:auto;color:#138a5b;font-size:13px}
main{display:grid;grid-template-columns:1fr 1fr;min-height:580px}.panel{padding:22px}.panel+ .panel{border-left:1px solid #e2e8f0;background:#f8fafc}
h2{font-size:14px;margin:0 0 18px;color:#64748b}.state{display:flex;gap:10px;margin-bottom:18px}.chip{padding:7px 10px;border-radius:8px;background:#eef2ff;color:#4547a9;font-size:12px}
.messages{height:320px;overflow:auto}.message{padding:12px 14px;border-radius:12px;background:#eef2f7;margin-bottom:10px;line-height:1.55;font-size:14px;white-space:pre-wrap}.message.tool{background:#fff7df;border:1px solid #f3cf72}
.event-log{height:460px;overflow:auto;font:12px/1.5 ui-monospace,monospace}.event{padding:8px 10px;margin-bottom:7px;border-left:3px solid #6366f1;background:#fff;white-space:pre-wrap;word-break:break-all}.event b{color:#494bb8}
button{width:100%;padding:12px;border:0;border-radius:9px;background:#5558df;color:white;font-weight:700;cursor:pointer}button:disabled{opacity:.5;cursor:wait}
.hint{text-align:center;color:#64748b;font-size:12px;margin-top:10px}@media(max-width:760px){body{padding:12px}main{grid-template-columns:1fr}.panel+.panel{border-left:0;border-top:1px solid #e2e8f0}}
</style>
</head>
<body>
<div class="app">
<header><h1>社内問い合わせエージェント</h1><span id="status">● 接続準備完了</span></header>
<main>
<section class="panel"><h2>ユーザーに見える画面</h2><div class="state"><div class="chip">phase: <b id="phase">未実行</b></div><div class="chip">調査済み: <b id="count">0</b>件</div></div><div class="messages" id="messages"><div class="message">質問:有給休暇は翌年度に繰り越せますか?</div></div><button id="run">エージェントを実行</button><div class="hint">クリックするとAG-UIイベントをSSEで受信します</div></section>
<section class="panel"><h2>届いたAG-UIイベント</h2><div class="event-log" id="events"><div class="event">ここにイベントが順番に表示されます</div></div></section>
</main>
</div>
<script>
const runButton=document.querySelector('#run');
const messages=document.querySelector('#messages');
const eventLog=document.querySelector('#events');
const statusLabel=document.querySelector('#status');
let currentMessage=null;
function showEvent(event){
if(eventLog.textContent.includes('ここにイベント')) eventLog.innerHTML='';
const row=document.createElement('div'); row.className='event';
const title=document.createElement('b'); title.textContent=event.type;
row.append(title,document.createElement('br'),document.createTextNode(JSON.stringify(event,null,2)));
eventLog.append(row); eventLog.scrollTop=eventLog.scrollHeight;
}
function applyEvent(event){
if(event.type==='RUN_STARTED') statusLabel.textContent='● 実行中';
if(event.type==='STATE_SNAPSHOT'){document.querySelector('#phase').textContent=event.snapshot.phase;document.querySelector('#count').textContent=event.snapshot['調査済み件数']}
if(event.type==='TEXT_MESSAGE_START'){currentMessage=document.createElement('div');currentMessage.className='message';messages.append(currentMessage)}
if(event.type==='TEXT_MESSAGE_CONTENT'&¤tMessage) currentMessage.textContent+=event.delta;
if(event.type==='TOOL_CALL_START'){const box=document.createElement('div');box.className='message tool';box.id='tool';box.textContent='ツール実行中:'+event.toolCallName;messages.append(box)}
if(event.type==='TOOL_CALL_ARGS') document.querySelector('#tool').textContent+='\n引数:'+event.delta;
if(event.type==='TOOL_CALL_RESULT') document.querySelector('#tool').textContent+='\n結果:'+event.content;
if(event.type==='STATE_DELTA'){for(const op of event.delta){if(op.path==='/phase')document.querySelector('#phase').textContent=op.value;if(op.path==='/調査済み件数')document.querySelector('#count').textContent=op.value}}
if(event.type==='RUN_FINISHED'){statusLabel.textContent='● 完了';runButton.disabled=false;runButton.textContent='もう一度実行'}
messages.scrollTop=messages.scrollHeight;
}
runButton.addEventListener('click',async()=>{
while(messages.children.length>1)messages.lastChild.remove();runButton.disabled=true;runButton.textContent='実行中…';eventLog.innerHTML='';
const body={threadId:'thread-001',runId:crypto.randomUUID(),messages:[{id:'user-001',role:'user',content:'有給休暇は翌年度に繰り越せますか?'}],tools:[],context:[],state:{},forwardedProps:{}};
try{
const response=await fetch('/agent',{method:'POST',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify(body)});
if(!response.ok) throw new Error('HTTP '+response.status+' '+await response.text());
const reader=response.body.getReader(),decoder=new TextDecoder();let buffer='';
while(true){const {value,done}=await reader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});const blocks=buffer.split('\n\n');buffer=blocks.pop();for(const block of blocks){for(const line of block.split('\n')){if(line.startsWith('data: ')){const event=JSON.parse(line.slice(6));showEvent(event);applyEvent(event)}}}}
}catch(error){statusLabel.textContent='● エラー';const row=document.createElement('div');row.className='event';row.textContent=error.message;eventLog.append(row);runButton.disabled=false;runButton.textContent='再実行'}
});
</script>
</body>
</html>フォルダ構成は次のようになります。
ag-ui-reproduction/
├── .venv/
├── index.html
├── requirements.txt
└── server.pyフロントエンド側では、fetch()を使って/agentへPOSTします。
const response = await fetch('/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify(body)
});レスポンスはSSEのため、response.body.getReader()で読み取ります。受信したevent.typeに応じて、文章や状態、ツール結果を画面へ反映します。
本格的なReactアプリでは、@ag-ui/clientのHttpAgentを使う方法もあります。ただし、初回の検証では素のJavaScriptでイベントの中身を見ると、どこで何が起きているか把握しやすくなります。
サーバーを起動する
ターミナルで、次のコマンドを実行します。
uvicorn server:app --reload
正常に起動すると、次のようなURLが表示されます。
Uvicorn running on http://127.0.0.1:8000

ターミナルは閉じず、そのままにしてください。サーバーを終了するときはCtrl+Cを押します。
ブラウザでhttp://127.0.0.1:8000/docsを開くと、FastAPIが自動生成したAPIドキュメントを確認できます。GET /とPOST /agentが表示されれば、ルーティングは成功です。

次に、http://127.0.0.1:8000/を開きます。先ほど示した初期画面が表示されれば、HTMLの読み込みも完了しています。
ブラウザからエージェントを実行する
「エージェントを実行」を押してください。ボタンが「実行中…」に変わり、画面右側へイベントが追加されます。
ツール呼び出しまで進むと、左側にツール名、引数、実行結果が表示されます。同じタイミングで、右側にはTOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → TOOL_CALL_RESULTの順にイベントが届きます。

今回のsearch_knowledge_baseは、動作確認用の架空ツールです。実際の社内検索は行っていません。サーバーがツール実行を模したイベントを送っています。
処理が最後まで進むと、右上が「完了」に変わります。phaseは「回答生成」、調査済み件数は「1件」になります。最後のイベントがRUN_FINISHEDなら、AG-UIの一連の通信は成功です。

curlでSSEを直接確認する
画面に何も表示されないときは、ブラウザを介さずエンドポイントを直接確認します。サーバーを起動したまま、別のターミナルで次のコマンドを実行してください。
curl -N -X POST http://127.0.0.1:8000/agent \
-H 'Content-Type: application/json' \
-H 'Accept: text/event-stream' \
--data '{
"threadId":"thread-001",
"runId":"run-001",
"messages":[{
"id":"user-001",
"role":"user",
"content":"有給休暇は翌年度に繰り越せますか?"
}],
"tools":[],
"context":[],
"state":{},
"forwardedProps":{}
}'WindowsのPowerShellでは、curlではなくcurl.exeを使い、行末の「`」も含めて次のコマンドを実行します。行末のバッククォートの後ろに空白を入れないでください。
curl.exe -N -X POST http://127.0.0.1:8000/agent `
-H "Content-Type: application/json" `
-H "Accept: text/event-stream" `
--data '{
"threadId":"thread-001",
"runId":"run-001",
"messages":[{
"id":"user-001",
"role":"user",
"content":"有給休暇は翌年度に繰り越せますか?"
}],
"tools":[],
"context":[],
"state":{},
"forwardedProps":{}
}'-Nはcurlのバッファリングを無効にする指定です。これを付けると、イベントが届くたびに表示されます。
成功時は、次のような行が順番に出力されます。
data: {"type":"RUN_STARTED","threadId":"thread-001","runId":"run-001"}
data: {"type":"STATE_SNAPSHOT","snapshot":{"phase":"受付","調査済み件数":0}}
data: {"type":"TEXT_MESSAGE_START","messageId":"msg_1","role":"assistant"}出力の最後が次の内容なら正常です。
data: {"type":"RUN_FINISHED","threadId":"thread-001","runId":"run-001"}
422エラーが出る場合の確認ポイント
422 Unprocessable Entityが返る場合は、リクエスト本文の不足を疑ってください。
今回検証したag-ui-protocol 0.1.22では、forwardedPropsを省略すると422エラーになりました。レスポンスのdetailは次のとおりです(実際には送信したJSONがそのまま入るinputフィールドも含まれます)。
{
"detail": [
{
"type": "missing",
"loc": ["body", "forwardedProps"],
"msg": "Field required"
}
]
}値がない場合も、次のように空オブジェクトを送ります。
"forwardedProps": {}また、Pythonコードではスネークケース、通信するJSONではキャメルケースを使います。
- Python:thread_id・run_id・forwarded_props
- JSON:threadId・runId・forwardedProps
この違いを混同すると、イベントを受け取れない原因になります。
画面が表示されない場合の確認ポイント
問題を切り分けるときは、次の順番で確認します。
- ターミナルにUvicorn runningと表示されているか
- /docsにPOST /agentが表示されるか
- curlでRUN_STARTEDが返るか
- ブラウザの開発者ツールにエラーがないか
- index.htmlの接続先が/agentになっているか
curlでは成功するのに画面だけ動かない場合は、フロントエンド側の処理に問題があります。curlでも失敗する場合は、サーバー側またはリクエスト本文を確認してください。
今回の例では、FastAPIからHTMLも返しています。そのため、画面とAPIが同一オリジンになり、初回検証でCORS設定に悩まずに済みます。
実装してみて分かったこと
実際に手を動かすと、ドキュメントを読むだけでは見えない点がいくつかありました。筆者が印象に残った3つを挙げます。
まず体感速度です。ローカル環境で固定データを返す今回のデモでは、ボタンを押してから最初のRUN_STARTEDが届くまで約0.02秒、全イベントが出そろうまで約1.8秒でした。同じ1.8秒でも、REST APIのように黙って待つのとは別物だと分かります。
次にキーの命名です。筆者は当初、スネークケースで送るとエラーになると考えていました。
実際には、今回検証したag-ui-protocol 0.1.22ではスネークケースも受け付けます。一方で返ってくるイベントはキャメルケースのため、画面側はthreadIdの形で読んでください。
いちばん印象に残ったのは、画面側のコードの薄さです。今回掲載したコードでは、イベントを画面へ反映するapplyEvent関数は12行、分岐は9本だけでした。
server.pyとindex.htmlを合わせても200行ほどです。バックエンドを差し替えても画面が生き残るという説明は、この薄さを見て腑に落ちました。
実際のAIエージェントへ置き換えるには
ここまでのデモは、AG-UIのイベントを手作業で発生させています。実際の開発では、次のいずれかへ進みます。
- LangGraphなど、公式AG-UIアダプタがあるフレームワークを接続する
- LLMのストリーミング出力をTEXT_MESSAGE_CONTENTへ変換する
- 実ツールの開始・引数・結果をTOOL_CALL_*へ変換する
- 会話履歴と状態をデータベースへ保存する
- ユーザー認証、入力検証、エラー処理を追加する
対応フレームワークを使う場合は、イベント変換をすべて自作せず、公式アダプタを優先してください。たとえばLangGraph向けには、FastAPIエンドポイントを追加する統合パッケージが用意されています。
一方、独自エージェントを接続する場合も、画面側が見るのはAG-UIイベントです。
バックエンドの実装を変更しても、同じイベントを返せばフロントエンドの変更範囲を抑えられます。
自社でのAIエージェント開発を検討している方は、下記の記事もあわせてご確認ください。

AIエージェントのご相談はWEELへ
AG-UIは、従来のAPIでは扱いにくかったエージェントの挙動を、標準化されたイベントの流れとして受け止めるプロトコルです。ストリーミング表示・ツール実行の可視化・状態同期・ユーザー承認を、フレームワークに依存せず組み込めます。MCPはツール、A2Aはエージェント同士、AG-UIはユーザーとの接点。役割の違いさえ押さえておけば、自社プロダクトのどこに何を使うかを整理しやすくなります。
次の一手は、使っているフレームワークの対応状況を調べ、小さな画面ひとつでイベントの流れを試すことです。
「生成AIで新しいプロダクトを作りたい」「もっと本格的に生成AIを業務に組み込みたい」とお考えの方は、ぜひ株式会社WEELにご相談ください。
開発実績として、
・新規事業室での「リサーチ」「分析」「事業計画検討」を70%自動化するAIエージェント
・社内お問い合わせの1次回答を自動化するRAG型のチャットボット
・過去事例や最新情報を加味して、10秒で記事のたたき台を作成できるAIプロダクト
・お客様からのメール対応の工数を80%削減したAIメール
・サーバーやAI PCを活用したオンプレでの生成AI活用
・生徒の感情や学習状況を踏まえ、勉強をアシストするAIアシスタント
などの開発実績がございます。
生成AIを活用したプロダクト開発の支援内容は、以下のページでも詳しくご覧いただけます。
➡︎株式会社WEELのサービスを詳しく見る。
アイデア段階でも構いません。まずは無料相談でお気軽にご相談ください。
➡︎生成AIを活用したプロダクト開発・業務効率化について相談する

「生成AIを社内で活用したい」「生成AIの事業をやっていきたい」という方に向けて、生成AI社内セミナー・勉強会をさせていただいております。
セミナー内容や料金については、ご相談ください。
また、サービス紹介資料もご用意しておりますので、併せてご確認ください。

