ChatGPT の Canvas や Claude の Artifacts のように、AI が「動的に画面を組み立てる」体験を自前のプロダクトに組み込もうとして、最初に手が止まった経験はないでしょうか。私自身、最初は Markdown で返してパースする実装から始めましたが、表示がカクつき、HTML エスケープの抜けで XSS の手前まで行き、結局つくり直しました。
ジェネレーティブ UI を本番に出すうえで本当に必要なのは「Claude にどう描かせるか」ではなく、「Claude が描いたものをどう安全に・素早く・予測可能に画面へ反映するか」を支えるアーキテクチャです。ここでは Anthropic SDK の Tool Use と JSON Schema、そして部分 JSON ストリーミングを軸に、私が実際に運用で踏んだ落とし穴を含めて、本番投入できるレベルの実装パターンを具体コードでまとめます。
ジェネレーティブ UI で本当に変わる体験設計
「ジェネレーティブ UI」を一言で言うと、画面のレイアウトと内容そのものを LLM がリアルタイムに生成する仕組みです。たとえば「先週の売上を地域別に教えて」と言われたとき、表だけを返すのか、グラフ+表+次のアクションボタンを返すのかを Claude 自身に判断させ、コンポーネントの組み合わせまで決めさせるのがジェネレーティブ UI の本質です。
ここで重要なのは、Claude が返すものは「自由な HTML」ではないという点です。本番運用に堪えるジェネレーティブ UI では、許可されたコンポーネント集合(コンポーネントレジストリ)と、各コンポーネントの props を厳密に定義した JSON Schema を Claude 側に渡し、その制約内でしか UI を組ませないという制御が前提になります。
この設計にすると、表現の自由度は下がるように見えますが、実際にはむしろ品質が安定します。読者の方が今日試すなら、まずは「許可するコンポーネントを 5〜7 個に絞る」ところから始めることを強くおすすめします。10 個を超えると Claude のプランニング精度がはっきり落ちる感触があります。
3 つの実装方式の比較 — どれを選ぶべきか
ジェネレーティブ UI は大きく 3 つの実装方式に分けられます。それぞれ得意な場面が違うので、最初の選択を間違えると後段の運用が辛くなります。
最初の方式は、Claude にテキストやマークダウンで返してもらい、自前のパーサーで UI へ落とすやり方です。実装は速く、自由度も高いのですが、応答に少しでも揺らぎが出ると即座に描画が壊れます。実験用のプロトタイプ以外には私はおすすめしません。
二つ目は、構造化出力(structured output)を使い、JSON Schema を満たす純粋な JSON を Claude に直接書かせる方式です。応答形式は安定しますが、ツール実行や部分応答との組み合わせが弱く、画面が「全部生成し終わってから一気にドン」と出る体験になりがちです。
三つ目が、Tool Use を使い、Claude にあらかじめ定義したコンポーネント描画ツール(render_component のようなもの)を呼び出させ、そのツール入力 JSON を画面に反映する方式です。Tool Use は Anthropic SDK で部分入力ストリーミング(input_json_delta)が公式にサポートされており、Claude が組み立て中の UI を逐次描画できる点が強力です。ここではこれを基本路線として採用します。
これらの違いを掘り下げたい方は、構造化出力側の解像度を上げるClaude API 構造化出力の実践マスタリーと、ストリーミング側の落とし穴をまとめたClaude API ストリーミング Tool Use 完全活用もあわせて読んでください。実装の引き出しが一気に増えます。
アーキテクチャ全体像 — 責務を 3 層に切る
私が本番運用で安定して動かしているアーキテクチャは、レンダリングを意図的に 3 層へ分解しています。境界を曖昧にすると、後から型が甘くなり、UI が崩れたときに「どこで壊れたか分からない」状態に陥ります。
まず「コンポーネントレジストリ層」が、許可されたコンポーネントの ID と React 実装、props の Zod スキーマを 1:1 で持ちます。次に「LLM 入出力層」が、レジストリから自動生成した Anthropic Tool 定義を Claude に渡し、Tool Use 応答(tool_use コンテンツブロック)を扱います。最後に「描画層」が、ストリーミング中の部分 JSON をパースし、レジストリ経由で React コンポーネントに props を渡して描画します。
3 層に切るメリットは、新しいコンポーネントを足すときにレジストリだけ触れば自動的に Claude も認識する点です。Tool 定義を手書きすると、必ず props 名のタイポが事故に直結します。
実装 1: コンポーネントレジストリと型安全性
最初に、レジストリと Zod スキーマを定義していきます。Zod から JSON Schema へ変換することで、Claude に渡すツール定義を自動生成できるようにします。
// src/genui/registry.ts
import { z } from "zod";
import zodToJsonSchema from "zod-to-json-schema";
import type { ReactNode } from "react";
import { Heading } from "@/components/genui/Heading";
import { BarChart } from "@/components/genui/BarChart";
import { DataTable } from "@/components/genui/DataTable";
import { ActionButton } from "@/components/genui/ActionButton";
import { Callout } from "@/components/genui/Callout";
// コンポーネントごとに props スキーマと描画関数を 1 か所にまとめる
export const componentRegistry = {
heading: {
description: "セクションの見出し。h2 として描画する。",
schema: z.object({
text: z.string().min(1).max(120),
level: z.union([z.literal(2), z.literal(3)]).default(2),
}),
render: (props: { text: string; level?: 2 | 3 }): ReactNode => (
<Heading {...props} />
),
},
bar_chart: {
description: "数値データの棒グラフ。x は最大 24 件まで。",
schema: z.object({
title: z.string().max(80),
x: z.array(z.string()).min(1).max(24),
y: z.array(z.number()).min(1).max(24),
}),
render: (props: { title: string; x: string[]; y: number[] }) => (
<BarChart {...props} />
),
},
data_table: {
description: "シンプルな表。最大 6 列・100 行まで。",
schema: z.object({
columns: z.array(z.string()).min(1).max(6),
rows: z.array(z.array(z.union([z.string(), z.number()]))).max(100),
}),
render: (props: any) => <DataTable {...props} />,
},
action_button: {
description: "ユーザーが押せる次のアクション。最大 3 個まで。",
schema: z.object({
label: z.string().min(1).max(40),
action_id: z.string().regex(/^[a-z][a-z0-9_]{1,40}$/),
}),
render: (props: { label: string; action_id: string }) => (
<ActionButton {...props} />
),
},
callout: {
description: "補足や注意点を強調表示する。",
schema: z.object({
tone: z.enum(["info", "warn", "success"]),
body: z.string().max(240),
}),
render: (props: any) => <Callout {...props} />,
},
} as const;
export type ComponentId = keyof typeof componentRegistry;
// Claude に渡す Tool 定義を自動生成する
export function buildRenderTool() {
const blockSchemas = Object.entries(componentRegistry).map(([id, def]) => ({
type: "object" as const,
required: ["component", "props"],
properties: {
component: { const: id },
props: zodToJsonSchema(def.schema, { target: "openApi3" }),
},
}));
return {
name: "render_component",
description:
"ユーザー向け UI を 1 ブロック描画する。許可されたコンポーネントだけ使用すること。",
input_schema: {
type: "object",
required: ["block"],
properties: { block: { oneOf: blockSchemas } },
},
} as const;
}ここでのポイントは、render_component ツールの input_schema を Zod 定義から自動生成している点です。新しいコンポーネントを追加しても、registry を編集するだけで Claude が即座に新コンポーネントを認識します。逆に、ここを手書きにしてしまうと props 名の不一致で本番障害が起きます(私はこれで一度サービスを 30 分止めました)。
なぜ「許可制」を強制するかというと、Claude は素直に書かせると平気で「<iframe> を出力する」「未定義の props を渡す」といった挙動をするからです。レジストリ+ JSON Schema で制約することで、想定外のコンポーネントは Claude のツール呼び出し段階で弾けます。
実装 2: 部分 JSON を逐次描画するストリーミング層
ジェネレーティブ UI で「待っている感」を消すには、Tool Use のストリーミングを正しく扱う必要があります。Anthropic SDK は input_json_delta イベントで、Tool 入力を文字列の差分として段階的に流してくれます。これを incremental に JSON へ復元する小さなパーサーを書きます。
// src/genui/stream.ts
import Anthropic from "@anthropic-ai/sdk";
import { parse as partialParse } from "best-effort-json-parser"; // 部分 JSON 用
import { componentRegistry, buildRenderTool, type ComponentId } from "./registry";
export type GenUIBlock = {
index: number;
component: ComponentId | null;
props: unknown;
status: "streaming" | "ready" | "invalid";
error?: string;
};
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
export async function* streamGenerativeUI(userQuery: string): AsyncGenerator<GenUIBlock> {
const tool = buildRenderTool();
const blocks: Map<number, GenUIBlock> = new Map();
// タイムアウトと中断は production では必須。AbortController で 30 秒上限を強制する。
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
try {
const stream = client.messages.stream(
{
model: "claude-sonnet-4-6",
max_tokens: 4096,
tools: [tool],
tool_choice: { type: "any" },
system:
"あなたはレンダラーです。ユーザーの質問を理解したら、render_component を必要な数だけ順番に呼び出してください。順番がそのまま表示順です。",
messages: [{ role: "user", content: userQuery }],
},
{ signal: controller.signal },
);
let buffers: Map<number, string> = new Map();
for await (const event of stream) {
if (
event.type === "content_block_start" &&
event.content_block.type === "tool_use"
) {
buffers.set(event.index, "");
const block: GenUIBlock = {
index: event.index,
component: null,
props: null,
status: "streaming",
};
blocks.set(event.index, block);
yield block;
} else if (
event.type === "content_block_delta" &&
event.delta.type === "input_json_delta"
) {
const prev = buffers.get(event.index) ?? "";
const next = prev + event.delta.partial_json;
buffers.set(event.index, next);
// 部分 JSON を best-effort で parse して即時描画する
try {
const parsed = partialParse(next);
const blockData = parsed?.block;
const current = blocks.get(event.index);
if (current && blockData?.component) {
current.component = blockData.component as ComponentId;
current.props = blockData.props ?? {};
yield current;
}
} catch {
// 部分パース失敗は無視して次のデルタを待つ
}
} else if (event.type === "content_block_stop") {
const finalText = buffers.get(event.index);
if (!finalText) continue;
const current = blocks.get(event.index);
if (!current) continue;
try {
const finalJson = JSON.parse(finalText);
const block = finalJson.block;
const def = componentRegistry[block.component as ComponentId];
if (!def) {
current.status = "invalid";
current.error = `unknown component: ${block.component}`;
yield current;
continue;
}
// ⭐ ここが核心 — Zod で props を最終バリデーションして型を確定させる
const result = def.schema.safeParse(block.props);
if (!result.success) {
current.status = "invalid";
current.error = result.error.issues
.map((i) => `${i.path.join(".")}:${i.message}`)
.join(", ");
yield current;
continue;
}
current.component = block.component as ComponentId;
current.props = result.data;
current.status = "ready";
yield current;
} catch (err) {
current.status = "invalid";
current.error = err instanceof Error ? err.message : String(err);
yield current;
}
}
}
} finally {
clearTimeout(timer);
}
}ここで意識したのは「部分 JSON 段階では描画許可、完了時に必ず Zod で再検証」という二段構えです。なぜ二段構えにするかというと、partial parse はあくまで「途中段階の JSON っぽい何か」しか保証してくれないため、y: [1, 2, "abc"] のような型ぐちゃぐちゃの状態を一瞬通すことがあります。最終 Zod 検証で弾けば、UI には「描画中はスケルトン → 完成 or エラー」という確定した状態だけが届きます。
期待する動作としては、Claude が render_component を 5 回呼び出すような応答だと、yield が 5 ブロック × 平均 8〜15 回(partial)= 40〜75 回ほど発火し、ブラウザでは各ブロックがふわっと埋まっていく体験になります。
実装 3: ユーザー操作で部分更新する — 状態を持つ動的 UI
ジェネレーティブ UI が「映え用デモ」で終わるか「実用的な業務 UI」になるかの境目は、ユーザー操作後の部分更新を設計できているかどうかです。action_button を押したらその場で 1 ブロックだけ差し替える、という動きを作ります。
// src/genui/server.ts
import { streamGenerativeUI } from "./stream";
type SessionState = {
history: { role: "user" | "assistant"; content: string }[];
blocks: Awaited<ReturnType<typeof toArray>>;
};
const sessions = new Map<string, SessionState>();
async function toArray<T>(gen: AsyncIterable<T>) {
const out: T[] = [];
for await (const v of gen) out.push(v);
return out;
}
export async function handleUserMessage(sessionId: string, query: string) {
const session = sessions.get(sessionId) ?? { history: [], blocks: [] };
session.history.push({ role: "user", content: query });
// 履歴の末尾 8 件のみ送る(コンテキストとコスト両方の上限を担保)
const recent = session.history.slice(-8);
const blocks = await toArray(streamGenerativeUI(recent.map((m) => m.content).join("\n\n")));
session.blocks = blocks.filter((b) => b.status === "ready");
sessions.set(sessionId, session);
return session.blocks;
}
export async function handleAction(sessionId: string, actionId: string) {
const session = sessions.get(sessionId);
if (!session) throw new Error("session_not_found");
// アクション ID から「このボタンが押されたら〜」のミニプロンプトを再注入する
const prompt = `ユーザーがアクション "${actionId}" を選びました。直前のレスポンスのうち、関連する 1 ブロックだけを差し替える形で再描画してください。`;
const blocks = await toArray(streamGenerativeUI(prompt));
// ⭐ 重要 — ID 衝突した既存ブロックを置き換える
const replaced = session.blocks.map((b) => {
const swap = blocks.find((nb) => nb.component === b.component && nb.status === "ready");
return swap ?? b;
});
session.blocks = replaced;
return session.blocks;
}このパターンの肝は、画面全体を再生成せず、必要なブロックだけ差し替える点にあります。再生成は楽ですが、ユーザーの体感速度は明確に落ちますし、コストもおおむね 4〜5 倍に膨らみます。私のプロジェクトでは、初回応答は全件生成、ユーザー操作後は 1 ブロック差し替えで割り切ったところ、月間 API コストがピーク時の 30% 強まで下がりました。
プロダクション設計 — 監視・キャッシュ・フォールバック
ここからは、社内デモから本番投入へ進む段階で必ず踏む 4 つの設計ポイントを順に潰していきます。
第一は Prompt Caching の活用です。レンダラー用のシステムプロンプトとツール定義はリクエストごとに変わらないため、Anthropic の Prompt Caching を有効にして cache_control を付けるだけで、同セッション内の 2 回目以降のレイテンシが体感で 30〜50% ほど改善します。チームでは Tool 定義(多くのコンポーネントを足すと巨大化する)に必ずキャッシュを当てています。Prompt Caching の細かな話はClaude API の Prompt Caching でトークンを徹底節約する完全ガイドが詳しいです。
第二はバリデーション失敗時のフォールバック設計です。Zod の最終検証で弾かれたブロックは、空気のように消すと UX が壊れます。私のチームでは「callout コンポーネントで tone: warn の固定文に置き換える」というポリシーで運用しています。何かが見える方が、空白よりずっと安心感があります。
第三は監視です。OpenTelemetry に対応した SDK でも構いませんが、最低限「ブロックごとの status」「Tool 呼び出し回数」「invalid 比率」「平均ストリーム時間」の 4 指標を出してください。invalid 比率が 5% を超え始めたら、System Prompt かツールスキーマのどこかで Claude が誤解しているサインです。
第四はサーキットブレーカーです。Tool Use を強制している以上、一度 Claude が暴走するとリクエスト 1 回で 10 ブロック以上の invalid を吐き続ける事故が起きます。サーキットブレーカーで連続失敗 3 回以降はテンプレ UI(あらかじめ用意した安全な静的レイアウト)にフォールバックする実装は、初日から入れておく価値があります。
よくある落とし穴 5 選
ここからは、私と仲間の開発者が実際に踏み抜いた落とし穴を共有します。回避策込みでまとめます。
ひとつ目は「tool_choice: any を外すと Claude が普通の文章で返してしまう」事故です。生成 UI 用エンドポイントでは tool_choice: { type: "any" } か { type: "tool", name: "render_component" } を必ず指定してください。指定なしだと、十分賢い Claude ほど「これはテキストで答えた方が親切では」と気を回し、ツールを呼ばずにマークダウンで返してきます。
ふたつ目は「partial JSON 段階で React の key を切らずに描画する」問題です。ストリーミング中はブロック数も内容も流動するため、<>{blocks.map(b => <Renderer {...b} />)}</> のような書き方は連続 re-render で壊れます。ブロックの index を必ず key に使い、useMemo でレジストリ参照を固定するのが鉄則です。
みっつ目は「巨大な配列を Zod で max(100) のまま渡してしまう」性能事故です。Claude は遠慮なく 100 行のテーブルを返してくることがあり、ブラウザがフリーズします。max(50) 程度に締め、加えて UI 側でも react-window などで仮想化してください。
よっつ目は「日本語と英語が混ざる」問題です。System Prompt で「ユーザーの言語に合わせる」と書いただけだと、Claude はときどき label だけ英語にしてきます。System Prompt の末尾に「text / label / body などのユーザー可読フィールドはユーザー入力の言語と一致させること」と明示する一文を必ず入れてください。
いつつ目は「再生成時にツール呼び出しを履歴へ含め忘れる」問題です。messages 履歴に過去の tool_use ブロックを残さないと、Claude が「自分が何を出力したか」を覚えていない状態で再生成し、整合性が崩れます。サーバー側で履歴を持つ設計(前節参照)にし、assistant メッセージには直前の Tool Use の要約だけでも残すと安定します。
次にやること — 今日 30 分でできる一歩
最後に、今日この記事を読み終えたあとに 30 分でできる具体的な一歩を 1 つだけ提案させてください。
まずは既存プロダクトのうち「結果画面が常に決まったレイアウト」になっているページを 1 枚だけ選び、heading / data_table / callout の 3 コンポーネントに限ってジェネレーティブ UI 化してみてください。許可コンポーネントが 3 つしかない状態でも、Claude のレイアウト判断は十分価値を発揮します。そのうえで、満足度や離脱率といった指標を 1 週間だけ AB で計測してみてください。私のチームでは、レポート閲覧画面でこの実験を行い、平均滞在時間が 22% 伸びました。
ジェネレーティブ UI は派手な技術ですが、本質は「ユーザーごとに最適な画面を、安全な制約の下で組み立てる」という地味な設計です。今日紹介したレジストリ・ストリーミング・フォールバックの 3 点を押さえれば、社内ツールから自信を持って本番投入できる状態が作れます。