CLAUDE LABEN
FORK — Claude Code 2.1.212で/forkの挙動が変わりました。会話を新しいバックグラウンドセッションへ複製し、作業を続けたまま並走できます。従来のセッション内サブエージェントは/subtaskに移りましたLIMITS — WebSearchの呼び出しがセッション単位で既定200回に制限されました。サブエージェントの起動も既定200回が上限で、暴走した検索・委譲のループを止められますMCPBG — 2分を超えるMCPツール呼び出しは自動的にバックグラウンドへ移り、セッションが固まらなくなりました。しきい値はCLAUDE_CODE_MCP_AUTO_BACKGROUND_MSで調整できますPLANFIX — プランモードがtouchやrmといったファイルを変更するBashコマンドを、許可プロンプトもcanUseToolコールバックも通さずに実行してしまう不具合が修正されましたSONNET5 — Claude Sonnet 5は導入価格として入力100万トークンあたり2ドル、出力10ドルで提供中です。8月31日を過ぎると3ドルと15ドルに戻りますIPO — Anthropicが早ければ10月の株式公開を視野に、引受銀行が投資家との面談を組み始めたと報じられていますFORK — Claude Code 2.1.212で/forkの挙動が変わりました。会話を新しいバックグラウンドセッションへ複製し、作業を続けたまま並走できます。従来のセッション内サブエージェントは/subtaskに移りましたLIMITS — WebSearchの呼び出しがセッション単位で既定200回に制限されました。サブエージェントの起動も既定200回が上限で、暴走した検索・委譲のループを止められますMCPBG — 2分を超えるMCPツール呼び出しは自動的にバックグラウンドへ移り、セッションが固まらなくなりました。しきい値はCLAUDE_CODE_MCP_AUTO_BACKGROUND_MSで調整できますPLANFIX — プランモードがtouchやrmといったファイルを変更するBashコマンドを、許可プロンプトもcanUseToolコールバックも通さずに実行してしまう不具合が修正されましたSONNET5 — Claude Sonnet 5は導入価格として入力100万トークンあたり2ドル、出力10ドルで提供中です。8月31日を過ぎると3ドルと15ドルに戻りますIPO — Anthropicが早ければ10月の株式公開を視野に、引受銀行が投資家との面談を組み始めたと報じられています
記事一覧/API & SDK
API & SDK/2026-04-26上級

Claude API でジェネレーティブ UI を本番投入する — JSON Schema と Tool Use で動的コンポーネントをストリーミング描画する実装パターン

Claude API のツール呼び出しと部分 JSON ストリーミングを組み合わせ、AI が動的に組み立てた UI コンポーネントを安全に描画する本番設計を、コンポーネントレジストリ・型安全性・フォールバック含めて解説します。

generative-uistreaming16tool-use21json-schema2production87

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 点を押さえれば、社内ツールから自信を持って本番投入できる状態が作れます。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

API & SDK2026-05-26
Claude API の構造化レスポンスを本番で安定させる — tool_use と JSON Schema、多層検証の実装メモ
Claude API の応答を JSON で受け取る実装は数行で書けますが、本番運用では「形は正しいが意味が壊れている」レスポンスにどう備えるかが分かれ目になります。tool_use・JSON Schema・ドメイン検証を組み合わせた多層パイプラインを、壁紙アプリの分類処理での実体験を交えて整理しました。
API & SDK2026-04-16
Claude API × Go言語 本番実装 — Anthropic Go SDK・並行処理・Tool Use・マイクロサービス統合
Anthropic Go SDKを使ったClaude API本番実装の完全解説。ストリーミング・goroutineによる並行処理・Tool Use・Gin/Echoフレームワーク統合・グレースフルシャットダウンまで、Goらしい設計パターンを実装コード付きで徹底解説します。
API & SDK2026-07-03
同時リクエスト数はいくつまで持つのか — Little の法則と実測メモリで決める Claude API 本番デプロイのインフラ要件
同時リクエスト数・待ち行列長・メモリはいくつに設定すべきか。Claude API 本番デプロイのインフラ要件を Little の法則と実測ハーネスで数字から導く手順を、夜間バッチで OOM を踏んだ経験をもとに整理しました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →