CodeGym /コース /ChatGPT Apps /ウィジェットでのツール結果の処理: ToolOutput → UI

ウィジェットでのツール結果の処理: ToolOutput → UI

ChatGPT Apps
レベル 4 , レッスン 3
使用可能

1. ToolOutput から React コンポーネントへ: データの全体フロー

前回の講義では、サーバー側ツールが ToolOutput(モデルとウィジェット向けの構造化応答)をどのように生成するかを見ました。今回はその後半、つまりこの ToolOutput がウィジェットに届き、UI に変わるまでを見ていきます。

魔法のように見えないよう、ユーザーからあなたのウィジェットまでのデータの道のりをもう一度確認しましょう。簡略化すると次のとおりです。

  1. ユーザーがチャットで質問する。
  2. GPT がリクエストを分析し、ツール一覧を見て「今は suggest_gifts が役立つ」と判断する。
  3. GPT は名前と引数を含むツール呼び出し(ToolInput)を作成し、あなたのサーバー(MCP または backend)に送る。
  4. サーバーはツールのロジックを実行し、結果を ToolOutput(データを含む構造化 JSON とモデル向けのテキスト要約)として返す。
  5. ChatGPT は ToolOutput を受け取り、モデル(対話継続用)へ、そして Apps SDK 経由であなたのウィジェットへ渡す(window.openai.toolOutput またはフック)。
  6. あなたのウィジェット(通常の React コンポーネント)が toolOutput を読み、UI をレンダリングする。

概念図は次のとおりです。

flowchart TD
  U[ユーザー] -->|チャットでのリクエスト| GPT[GPT]
  GPT -->|callTool: suggest_gifts| B[Backend/MCP]
  B -->|"ToolOutput (JSON)"| GPT
  GPT -->|toolOutput を渡す| W["ウィジェット (React)"]
  W -->|カード、リスト| U

重要なポイントとして、ToolOutput は単なる「サーバーの応答」ではありません。同時に、ウィジェットへの描画指示であり、モデルにとってのコンテキストでもあります。良い App とは、この JSON を DevTools で開発者が目で追うだけのものにせず、使いやすいインターフェースへと変換するものです。

2. ToolOutput の構造: 中身を理解する

Apps SDK におけるツールの結果は、3 つの論理ブロックに分かれます: structuredContentcontent、およびウィジェットでは toolResponseMetadata という名前で届く _meta です。

概念的には次のように表せます。

{
  "structuredContent": { /* UI とモデル向けのデータ */ },
  "content": "モデルとユーザー向けの簡潔なテキスト要約",
  "_meta": { /* ウィジェット専用のメタデータ */ }
}

どのフィールドを誰が見られるかは次の表のとおりです。

フィールド 誰が見られるか 用途
structuredContent
モデル + ウィジェット 主な構造化データ(リスト、オブジェクト、パラメータ)
content
モデル + ユーザー(テキストとして) GPT が自分の回答に差し込める簡潔な要約
_meta
ウィジェットのみ モデルには不要な運用用データ(ID、バージョン、キーなど)

Apps SDK のドキュメントでは、structuredContentcontent のペアはモデルに渡され、その後の応答で利用され得ると強調されています。一方、_meta は非公開で、ウィジェット内から toolResponseMetadata としてのみ参照できます。

GiftGenius の ToolOutput 例

仮に、サーバー上のツール suggest_gifts が次のようなボディを返すとします。

{
  "structuredContent": {
    "items": [
      {
        "id": "boardgame-cozy-strategy",
        "title": "Cozy Strategy Board Game",
        "price": 39.99,
        "currency": "USD",
        "score": 0.92,
        "tags": ["board_game","strategy","2-4_players"]
      }
    ]
  },
  "content": "いくつかのギフト候補を見つけました。以下でウィジェットがカードとして表示します。",
  "_meta": {
    "giftGenius": {
      "catalogVersion": "2025-10-01",
      "experimentBucket": "A"
    }
  }
}

ここで structuredContent.items は React ウィジェットがレンダリングする対象です。content は、今起きていることをユーザーに説明するためにモデルが使えます。_meta.giftGenius は UI や分析のためだけに必要な内部情報です(たとえば、リンクにどのカタログバージョンを使うか)。

まさに structuredContent が、サーバーから来る任意の JSON を手作業でパースするのではなく、JSX で参照する主体となるオブジェクトです。

3. ウィジェットで ToolOutput を受け取る: window.openai とフック

そろそろ JSON の話からコードに移りましょう。この ToolOutput は実際にはどのように React コンポーネントへ渡されるのでしょうか。

Apps SDK のテンプレートには主に 2 つの方法があります。window.openai.toolOutput を直接読むか、あるいは用意された React フック(useWidgetPropsuseToolOutput など)を使うかです。推奨はフックの利用です。window.openai に直接触れず、よりテストしやすく安全なコードになります。

最も単純な方法: window.openai から直接読む

理解のために「素の」やり方を見てみましょう。

'use client';

function RawToolOutputDebug() {
  const toolOutput = (window as any).openai?.toolOutput;
  return (
    <pre>{JSON.stringify(toolOutput, null, 2)}</pre>
  );
}

本番では当然おすすめしませんが、デバッグや「まず目で確認する」用途には十分です。

実用的なやり方: React フック経由

window.openai へのアクセスを小さなフックに包み、型付きのオブジェクトで扱えるとずっと便利です。仮に SDK が useWidgetProps というフックを提供し、toolOutputtoolResponseMetadata を返すとしましょう。

'use client';

import { useWidgetProps } from '@/lib/openai-widget';

export function GiftWidgetRoot() {
  const { toolOutput, toolResponseMetadata } = useWidgetProps();

  // ひとまずギフトの件数だけ表示
  const items = toolOutput?.structuredContent?.items ?? [];

  return (
    <div>
      見つかったギフト数: {items.length}
    </div>
  );
}

実際のテンプレートではフック名は異なるかもしれませんが、考え方は同じです。SDK が window.openai からデータを取り、あなたのコンポーネントに props やコンテキストで渡します。毎回グローバルオブジェクトに手で潜るよりずっと簡単で、テストでは toolOutput のフィクスチャを差し替えるだけで済みます。

4. ギフトをレンダリングする: structuredContent から JSX へ

次は実装です。structuredContent.items を使ってカードを描画しましょう。ウィジェットは Next.js の通常の React クライアントコンポーネントです(ファイル先頭の 'use client')。

まず 1 件のギフトの型を定義します。

type GiftItem = {
  id: string;
  title: string;
  price: number;
  currency: string;
  tags?: string[];
};

次に小さなカードコンポーネントを書きます。

function GiftCard({ gift }: { gift: GiftItem }) {
  return (
    <div className="gift-card">
      <div className="gift-title">{gift.title}</div>
      <div className="gift-price">
        {gift.price} {gift.currency}
      </div>
    </div>
  );
}

そして toolOutput からデータを取るリストコンポーネントです。

'use client';

import { useWidgetProps } from '@/lib/openai-widget';

export function GiftList() {
  const { toolOutput } = useWidgetProps();
  const items = (toolOutput?.structuredContent?.items ?? []) as GiftItem[];

  return (
    <div className="gift-list">
      {items.map(gift => (
        <GiftCard key={gift.id} gift={gift} />
      ))}
    </div>
  );
}

どれほど普通の React コードに近いかに注目してください。唯一の「魔法」はデータ源です。propsfetch ではなく、ChatGPT のコンテナから toolOutput を読みます。

最初のうちは as GiftItem[] を付けても問題ありません。後で Zod / JSON Schema → TS 型 といった手段で structuredContent を厳密に型付けしていけますが、デモとしては十分です。

5. ToolOutput を取り巻く UI の状態: ローディング、空、エラー

うまくいったときだけカードを表示し、それ以外は沈黙するアプリは親切ではありません。少なくとも 4 つの状態を明示的に扱いましょう。ツールが実行中、まだデータがない、結果がある、そして何かがうまくいかなかった場合です。

Apps SDK は通常、ツール呼び出しのステータスについて、ツール実行の一覧(useToolInvocations)や toolOutput に関連するフラグといった情報を提供します。この講義では簡単に、「toolOutput がまだない → ローディング」「あるがリストが空 → 空」「エラーが来た → エラー」というモデルで考えます。

単純化のため、サーバーがエラー時には structuredContenterror フィールドを入れ、toolOutput のルートにある ok フラグは false とします。これは前回のサーバー実装回で設計した契約です。

type ToolOutput = {
  ok: boolean;
  structuredContent?: {
    items?: GiftItem[];
    error?: { code: string; message: string };
  };
};

ではリストコンポーネントを更新します。

'use client';

import { useWidgetProps } from '@/lib/openai-widget';

export function GiftListWithStates() {
  const { toolOutput } = useWidgetProps() as { toolOutput?: ToolOutput };

  if (!toolOutput) {
    return <div>ギフトを選定中…</div>;
  }

  if (!toolOutput.ok) {
    const msg = toolOutput.structuredContent?.error?.message
      ?? 'おすすめを取得できませんでした。';
    return <div>エラー: {msg}</div>;
  }

  const items = toolOutput.structuredContent?.items ?? [];

  if (items.length === 0) {
    return <div>条件に合うギフトは見つかりませんでした。パラメータを変更してお試しください。</div>;
  }

  return (
    <div className="gift-list">
      {items.map(gift => (
        <GiftCard key={gift.id} gift={gift} />
      ))}
    </div>
  );
}

このコードだけでもユーザー体験は十分に良くなります。

  • ツールが動作中であることが分かる。
  • 失敗時も分かりやすいメッセージが出て、空白画面にならない。
  • 何も見つからなかった場合も、そうなった理由を正直に伝えられる。

本番では「ギフトを選定中…」を小さなスケルトンやスピナーに置き換えるでしょう。複雑なエラーでは、GPT に人が読める説明の生成を任せてもよいでしょう。ただしコンポーネントの基本構造は同じです。

6. _meta と toolResponseMetadata を UI で活用する

すでに structuredContent から主要データをレンダリングし、loading/empty/error といった基本状態を扱いました。残る重要な要素が、モデルが使わない ToolOutput_meta フィールドです。

_meta はモデルからは見えませんが、ウィジェットには toolResponseMetadata として届きます(名称は違うことがありますが本質は同じ)。

これは GPT の推論に影響させたくないが UI には重要なものを置くのに最適です。

  • カタログや設定のバージョン;
  • キャンペーン / A/B 実験の internal ID;
  • ユーザーに表示する「ボタン」の種類を制御するフラグ;
  • ドメインデータと混ぜたくない技術的な情報など。

たとえばサーバーは次のような _meta を返せます。

"_meta": {
  "giftGenius": {
    "catalogVersion": "2025-10-01",
    "showExperimentalBadges": true
  }
}

ウィジェット側はこれを読み取り、一部のカードに「新しいアイデア」といったバッジを描画できます。

type GiftMeta = {
  giftGenius?: {
    catalogVersion: string;
    showExperimentalBadges?: boolean;
  };
};

export function GiftListWithMeta() {
  const { toolOutput, toolResponseMetadata } = useWidgetProps() as {
    toolOutput?: ToolOutput;
    toolResponseMetadata?: GiftMeta;
  };

  const meta = toolResponseMetadata?.giftGenius;
  const items = toolOutput?.structuredContent?.items ?? [];

  return (
    <div>
      {meta && (
        <div className="catalog-version">
          カタログのバージョン {meta.catalogVersion}
        </div>
      )}
      <div className="gift-list">
        {items.map(gift => (
          <GiftCard
            key={gift.id}
            gift={gift}
          />
        ))}
      </div>
    </div>
  );
}

ここでモデルはまったく関与しません。catalogVersionshowExperimentalBadges をモデルは知りませんが、UI は自由に使えます。

ドキュメントはこの分離を強調しています。対話や推論に重要なデータは structuredContentcontent に、UI のためだけの技術情報は _meta / toolResponseMetadata に置きます。

7. ToolInvocation のステータスと「X を実行中…」について

ツールが動作中は、ChatGPT が自動でユーザーに状況を示します。チャット上部に「GiftGenius を実行中…」や「外部アプリにアクセス中」といったステータスが出ます。これはあなたが文字列を手で出しているのではなく、ツール呼び出しのメタデータに反応した ChatGPT のホスト環境が表示しています。

内部的には、 _meta["openai/toolInvocation/invoking"]_meta["openai/toolInvocation/invoked"] といったサービス用キーで、実行中か完了かが示されます。これらはプラットフォーム自身がステータス表示に使うもので、通常あなたが触る必要はありません。SDK がサーバー側でよしなに処理します。

UX 的には、ウィジェットがまだスケルトンを描画していなくても「何かが進んでいる」と伝えられるのが利点です。あなたの役割は、先ほどのようにウィジェット内で「ギフトを選定中…」やスケルトンなどのローカル状態を補うことです。

8. データ量とパフォーマンス: structuredContent に全てを詰め込まない

structuredContent にはどれだけ入れてよいのか」という話をしておきます。直感的には「カタログ全部を入れて、ウィジェットが絞り込めばいい」と思いがちですが、実際にはおすすめできません。

第一に、structuredContent はモデル(LLM)のコンテキストに入ります。トークンの総量には制限があります。ドキュメントや実践的なガイドはこれを強く意識するよう勧めています。ここはデータストアではなく、ひとつのアクションの結果です。

第二に、payload が大きいほど応答が遅くなり、上限や予期せぬカット/エラーにぶつかる可能性が高まります。

健全なアプローチは次のとおりです。

  • Backend があらかじめデータをフィルタ・ソートし、今このステップに必要な分だけ返す(例: 上位 10–20 件)。
  • 次のページが必要なら別アクション(新しいツール呼び出し、新しい ToolOutput)。
  • 純粋に UI のための情報(例: フィルタ用の全タグ一覧)は _meta に入れてもよいが、やりすぎない。

状態管理回でも触れたとおり、「backend は唯一の真実の源泉、ウィジェットはキャッシュ/表現」です。ここでも同じです。ツールの結果は呼び出し時点の「きれいなスナップショット」であり、あなたのデータベースの完全コピーではありません。

9. ウィジェット状態との連動と、その後の対話

この講義は ToolOutput → UI がテーマですが、隣に重要なピース widgetState があることも忘れられません。これにより、リレンダー間でユーザーの選択を保持でき、単なるカタログではなくウィザードや「ギフトコンフィギュレーター」にできます。

典型的な流れは次のとおりです。

  1. 最初の ToolOutput がギフト一覧を持ってくる。
  2. ユーザーがいずれかのカードをクリックする。
  3. ウィジェットは選択したギフトを widgetState に保存し、必要に応じて follow‑up や詳細のための新しいツール呼び出しを行う。
  4. 次の ToolOutput 群はこの選択に基づいて返ってくる。

コード的には通常の React state と setWidgetState 呼び出しに見えます。違いは、この状態がモデルや backend からも参照される点です。したがってコンパクトに保ち、秘密情報を保存しないようにします。

多段のワークフローや follow‑up については別モジュールで詳しく扱います。今はこう考えるとよいでしょう。ToolOutput はサーバーからの「データのスナップショット」、widgetState はその周囲にあるユーザー選択のコンテキストです。

ToolOutput → UI でよくあるミス

よくある誤り 1: 「UI が生の JSON ツリーをそのまま表示してしまう」
開発中は <pre>{JSON.stringify(toolOutput)}</pre> で済ませたくなりますが、本番でユーザーに見せるのはよくありません。できるだけ早い段階で structuredContent を意味のあるコンポーネント(リスト、カード、テーブル)に包み、サーバーのトークン化された応答を読ませないようにしましょう。

よくある誤り 2: ドメインデータと技術的メタデータを structuredContent に混在させる
「モデルとユーザーに見えるべきもの」と「UI や分析にだけ必要なもの」を分けるとコードは格段に見通しがよくなります。実験フラグ、カタログのバージョン、idempotency key のような技術的フィールドは _meta / toolResponseMetadata に置くべきです。これらが structuredContent の中に混ざると、契約の進化やモデル挙動のテストが難しくなります。

よくある誤り 3: ローディング・空・エラーの明示的な状態がない
「何も見つかりませんでした」や「問題が発生しました」の代わりに空の <div></div> を出すのは、ユーザーに「アプリが動いていない」と思わせる近道です。最小限のプレースホルダー文言や簡単なスケルトンだけでも UX は大きく改善します。「X を実行中…」という ChatGPT のシステムステータスに頼り切らず、ウィジェット自身も状態を伝えましょう。

よくある誤り 4: 1 つの ToolOutput に全部を詰め込もうとする
商品カタログの全件、ユーザー履歴、さらにサーバーログまで 1 つの structuredContent に入れるのは悪手です。モデルの制限に抵触し、応答を遅くし、UI を複雑にします。今のステップに必要な量(一覧ページ、選択された要素の詳細など)だけを返し、以降のステップは別のツール呼び出しにしましょう。

よくある誤り 5: 型なしの不安定な応答形に UI を強く結合する
どこでも toolOutput.structuredContent.items[0].whatever のように、存在チェックや型なしで突っ込むと、サーバーのスキーマが少しでも変わったときにウィジェットが落ちます。JSON Schema から TS 型を同期生成するか、少なくとも手でインターフェース(GiftItemToolOutput)を定義し、optional フィールドは丁寧に扱いましょう。

よくある誤り 6: _meta を無視して、モデルに不要なフィールドで過負荷にする
「JSON だからいくら入れてもいい」と思って structuredContent に何でも入れたくなることがありますが、フィールドが増えるほどモデルのコンテキストも増えます。推論に不要でテキスト回答にも要らない情報は _meta に置き、ウィジェット内だけで使いましょう。

よくある誤り 7: window.openai への直接アクセスを多数のコンポーネントが行う
たしかに window.openai.toolOutput は動きますが、アプリの半分がグローバル変数を触り始めると、デバッグやテストは地獄になります。フック/コンテキスト(useWidgetProps/useToolOutput)に一度だけ包み、以降は通常の props と型付きオブジェクトで受け渡す方がずっときれいで、Storybook/テストでもフィクスチャ差し替えが簡単です。

コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION