🍰

Apps SDKでシンプルなChatGPTアプリを作る

に公開

Apps SDKを使うとChatGPTで動くアプリを作ることができます。

ChatGPTに尋ねるとカフェの店舗一覧がUIつきで表示されている

今回は「Z Coffee」というカフェを想定し、ユーザーとのやり取りに応じて店舗情報を表示するアプリを作ってみました。

これをどのように実装したのかをまとめてみます。

ChatGPT Appsとは?

OpenAIが提供する仕組みで、ChatGPTとの会話の中で3rd partyが作成したアプリをリッチなUI付きで表示できるものです。
例えば「渋谷付近でホテルを予約したい」とユーザーが尋ねた際、Booking.comのアプリが反応して渋谷近辺のホテルの一覧を画像付きで表示する、みたいな感じです。

ChatGPT Appsのデザイン例。カルーセルやフルスクリーンなどのいろんなパターンが描かれている
UIは柔軟にデザインできる

UIはかなり自由度高く表現できます。リストやカルーセルはもちろん、フルスクリーンやPIP(Picture in Picture)も可能です。

ここでの表現はChatGPTの体験を左右するため、デザインガイドラインが公開されています。詳しくまとまっているので一読しておきましょう。読み物としても面白いです。

MCPと何が違うの?

  • リッチなUI表現ができる
  • ユーザーが事前に設定しなくても使える
    • 会話の内容に応じてChatGPTが判断し、使った方が良いと思えるアプリを推薦する

もう使えるの?

  • まだプレビュー版(2025/11/6時点)
  • 2025年後半に審査受付開始、アプリ収益化の方法についてアナウンスがあると発表されている
  • 今時点ではApps SDKを使った開発 + ChatGPT環境で試すところまではできる

その他

  • Apps SDKはMCPをベースにしている
    • MCP + UIでイメージすると分かりやすいかも
    • UI部分はReactなどの慣れた技術で作れる
  • ChatGPTで試すには有料プランが必要
    • Buinessプランなどのビジネスアカウントでは使えず、個人プランが必要
    • この辺りは状況変わりそうなので要ウォッチ。このIssueに議論がよくまとまってます

ChatGPT Appsを作ってみる

より具体的にイメージするため、実際にChatGPT Appsを作っていきましょう。
今回作るのは冒頭で見たカフェの店舗一覧を返すアプリです。

データを用意する

まずは店舗のデータを用意します。
API経由で取得できると便利なため、今回はデータを microCMS で管理しましょう。

今回はこんな感じでデータを入れました。

microCMSの管理画面。店舗情報が入稿されている
店舗情報をmicroCMSに入稿

APIリクエストでは例えばこんな感じでデータを取得できます。

// curl "https://z-coffee.microcms.io/api/v1/stores" -H "X-MICROCMS-API-KEY: <API_KEY>"

{
  "contents": [
    {
      "id": "ps5gcbidm7-l",
      "createdAt": "2025-10-28T08:51:17.841Z",
      "name": "恵比寿店",
      "address": "東京都渋谷区恵比寿1-2-3",
      "description": "緑の多さが特徴の店舗です。植物とコーヒーを楽しんでください。",
      "review": 4.3,
      "image": {
        "url": "https://images.microcms-assets.io/assets/d7b6e3b4b6854706aaaa12421e654cd9/bed3b9fd4ec44555b8901fb91ce6feb6/image.png",
        "height": 1024,
        "width": 1536
      }
    },
    {
      "id": "hsotbprdnovj",
      "createdAt": "2025-10-28T08:51:16.875Z",
      "name": "中目黒店",
      // ...(省略)

これはシンプルなリクエスト例ですが、microCMSにデータを入れておけば並び替えや絞り込みなども簡単にできるのでオススメです。

次はこのAPIをラップする形でMCPサーバーを実装していきましょう。

MCPサーバーを実装する

MCPサーバーはPythonあるいはTypeScriptの公式SDKで開発することが推奨されています。
今回はTypeScript SDKを選びました。

MCPサーバーのツールを実装します。

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { createClient } from "microcms-js-sdk";
import { StreamableHTTPTransport } from "@hono/mcp";
import { Hono } from "hono";
import { serve } from "@hono/node-server";

const server = new McpServer({
    name: "store-app",
    version: "0.1.0",
});

const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
const apiKey = process.env.MICROCMS_API_KEY;

let microcmsClient: ReturnType<typeof createClient> | null = null;
try {
    microcmsClient = serviceDomain && apiKey
        ? createClient({ serviceDomain, apiKey })
        : null;
} catch {
    microcmsClient = null;
}

type OutStore = { id: string; name: string; address: string; review: number; image: string; description: string };

// microCMSからデータを取得
const fetchStores = async (addressQuery?: string): Promise<OutStore[]> => {
    if (!microcmsClient) {
        return [];
    }
    try {
        const queries: any = { limit: 50, orders: "-publishedAt" };
        if (addressQuery && addressQuery.trim() !== "") {
            queries.filters = `address[contains]${addressQuery}`;
        }
        const res = await microcmsClient.getList<any>({ endpoint: "stores", queries });
        return (res.contents ?? []).map((c: any) => ({
            id: String(c.id),
            name: c.name ?? "",
            address: c.address ?? "",
            review: typeof c.review === "number" ? c.review : 0,
            image: (c.image && typeof c.image === "object" && c.image.url) ? c.image.url : "",
            description: c.description ?? "",
        }));
    } catch {
        return [];
    }
};

// 店舗一覧を取得するツール
server.registerTool(
    "store-list",
    {
        title: "Show Store List",
        inputSchema: {
            address: z.string().optional(),
        },
        outputSchema: {
            stores: z.array(
                z.object({
                    id: z.string(),
                    name: z.string(),
                    address: z.string(),
                    review: z.number(),
                    image: z.string(),
                    description: z.string(),
                })
            ),
        },
    },
    async (params: { address?: string } | undefined) => {
        const stores = await fetchStores(params?.address);
        return {
            content: [{ type: "text", text: JSON.stringify(stores) }],
            structuredContent: { stores },
        };
    }
);

const app = new Hono();

app.get("/", (c) => {
    return c.text("Hello, MCP Server is available at /mcp, yah!");
});

app.all("/mcp", async (c) => {
    const transport = new StreamableHTTPTransport();
    await server.connect(transport);
    return transport.handleRequest(c);
});

serve(app);

実装できたら期待通り動くか試してみましょう。

まずは以下のコマンドでサーバーを起動します。

npm run start

確認にはMCP Inspectorを使います。別タブを開いて以下のコマンドを実行しましょう。

npx @modelcontextprotocol/inspector

MCP Inspectorの画面。List Toolsが実行され、store-listの情報が表示されている
MCP InspectorでList Toolsを実行

Tools > List Tools を実行し、store-list ツールが表示されて動いていれば確認はOKです。

UIを実装する

次にUI部分を実装していきましょう。

前準備

必要なファイルを準備していきます。

ディレクトリ構成はこんな感じです。
ディレクトリ構成。MCP系がserver/に、UI系がweb/にまとめられている
ディレクトリ構成。server/にMCPサーバー系、web/にUI系の実装がある

index.css を以下の内容で作成。

@import "tailwindcss";

types.ts を以下の内容で作成。

export type OpenAiGlobals<
    ToolInput = UnknownObject,
    ToolOutput = UnknownObject,
    ToolResponseMetadata = UnknownObject,
    WidgetState = UnknownObject
> = {
    // visuals
    theme: Theme;

    userAgent: UserAgent;
    locale: string;

    // layout
    maxHeight: number;
    displayMode: DisplayMode;
    safeArea: SafeArea;

    // state
    toolInput: ToolInput;
    toolOutput: ToolOutput | null;
    toolResponseMetadata: ToolResponseMetadata | null;
    widgetState: WidgetState | null;
    setWidgetState: (state: WidgetState) => Promise<void>;
};

// currently copied from types.ts in chatgpt/web-sandbox.
// Will eventually use a public package.
type API = {
    callTool: CallTool;
    sendFollowUpMessage: (args: { prompt: string }) => Promise<void>;
    openExternal(payload: { href: string }): void;

    // Layout controls
    requestDisplayMode: RequestDisplayMode;
};

export type UnknownObject = Record<string, unknown>;

export type Theme = "light" | "dark";

export type SafeAreaInsets = {
    top: number;
    bottom: number;
    left: number;
    right: number;
};

export type SafeArea = {
    insets: SafeAreaInsets;
};

export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown";

export type UserAgent = {
    device: { type: DeviceType };
    capabilities: {
        hover: boolean;
        touch: boolean;
    };
};

/** Display mode */
export type DisplayMode = "pip" | "inline" | "fullscreen";
export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{
    /**
     * The granted display mode. The host may reject the request.
     * For mobile, PiP is always coerced to fullscreen.
     */
    mode: DisplayMode;
}>;

export type CallToolResponse = {
    result: string;
};

/** Calling APIs */
export type CallTool = (
    name: string,
    args: Record<string, unknown>
) => Promise<CallToolResponse>;

/** Extra events */
export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals";
export class SetGlobalsEvent extends CustomEvent<{
    globals: Partial<OpenAiGlobals>;
}> {
    readonly type = SET_GLOBALS_EVENT_TYPE;
}

/**
 * Global oai object injected by the web sandbox for communicating with chatgpt host page.
 */
declare global {
    interface Window {
        openai: API & OpenAiGlobals;
    }

    interface WindowEventMap {
        [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent;
    }
}

export type Store = {
    id: string;
    name: string;
    address: string;
    review: number;
    image: string;
    description: string;
};

useOpenAi.ts を以下の内容で作成。

import { useSyncExternalStore } from "react";
import {
    SET_GLOBALS_EVENT_TYPE,
    SetGlobalsEvent,
    type OpenAiGlobals,
} from "./types";

export function useOpenAiGlobal<K extends keyof OpenAiGlobals>(
    key: K
): OpenAiGlobals[K] | null {
    return useSyncExternalStore(
        (onChange) => {
            if (typeof window === "undefined") {
                return () => { };
            }

            const handleSetGlobal = (event: SetGlobalsEvent) => {
                const value = event.detail.globals[key];
                if (value === undefined) {
                    return;
                }

                onChange();
            };

            window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, {
                passive: true,
            });

            return () => {
                window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal);
            };
        },
        () => window.openai?.[key] ?? null,
        () => window.openai?.[key] ?? null
    );
}

tsconfig.json を以下の内容で作成。

{
    "compilerOptions": {
        "target": "ESNext",
        "useDefineForClassFields": true,
        "lib": [
            "DOM",
            "DOM.Iterable",
            "ESNext"
        ],
        "allowJs": false,
        "skipLibCheck": true,
        "esModuleInterop": false,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "ESNext",
        "moduleResolution": "Bundler",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react-jsx",
        "allowImportingTsExtensions": true
    },
    "include": [
        "src",
        "build.mjs"
    ]
}

店舗情報のUIを実装する

準備ができたので実際のUI部分を実装していきます。

UIはこんな感じのものを組んでいきます。
店舗がひとつずつカードになっており、横並びになっている
店舗を表示するUIのイメージ

まず、店舗カードを store-list/StoreListItem.tsx に作成します:

import React from "react";
import { Store } from "../types";

type StoreListItemProps = {
    store: Store;
    className?: string;
};

export const StoreListItem: React.FC<StoreListItemProps> = ({ store, className }) => {
    return (
        <li className={`bg-white rounded-2xl shadow-sm p-4 flex-shrink-0 w-[220px] sm:w-[260px] ${className ?? ""}`}>
            <div>
                <img
                    src={store.image}
                    alt={store.name}
                    className="w-full h-56 object-cover rounded-2xl"
                />
            </div>
            <div className="mt-4">
                <h3 className="text-xl font-semibold text-gray-900">
                    {store.name}
                </h3>
                <div className="mt-2 flex items-center text-sm text-gray-600">
                    <svg
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 20 20"
                        fill="currentColor"
                        className="w-4 h-4 text-amber-500"
                        aria-hidden="true"
                    >
                        <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.802 2.036a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.538 1.118l-2.802-2.036a1 1 0 00-1.175 0l-2.802 2.036c-.783.57-1.838-.197-1.538-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.88 8.72c-.783-.57-.38-1.81.588-1.81H6.93a1 1 0 00.95-.69l1.07-3.292z" />
                    </svg>
                    <span className="ml-1 font-medium">{store.review.toFixed(1)}</span>
                    {store.address && <span className="mx-2">·</span>}
                    <span className="truncate">{store.address}</span>
                </div>
                <p className="mt-3 text-gray-700 line-clamp-3 text-sm">
                    {store.description}
                </p>
                <div>
                    <a
                        href="https://yahoo.co.jp"
                        target="_blank"
                        rel="noopener noreferrer"
                        className="mt-8 inline-flex items-center justify-center rounded-full bg-purple-500 px-4 py-2 text-white font-semibold hover:bg-purple-600 active:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-400 focus:ring-offset-2"
                    >
                        <span className="text-sm">詳細を見る</span>
                    </a>
                </div>
            </div>
        </li>
    );
};

次に店舗一覧を store-list/StoreList.tsx に作成します:

import React from "react";
import { useOpenAiGlobal } from "../useOpenAi";
import { Store } from "../types";
import { StoreListItem } from "./StoreListItem.tsx";

export const StoreList: React.FC = () => {
    const toolOutput = useOpenAiGlobal("toolOutput");
    const initialStores = toolOutput
        ? toolOutput.stores as Store[]
        : [];

    const [stores, setStores] = React.useState<Store[]>(initialStores);

    React.useEffect(() => {
        if (toolOutput?.stores) {
            setStores(toolOutput.stores);
        }
    }, [toolOutput]);

    return (
        <div className="bg-blue-50 p-4 rounded">
            <h1 className="text-2xl font-bold mb-4">Z Coffee 店舗一覧</h1>
            <div className="relative antialiased">
                <div className="overflow-x-auto overflow-y-hidden">
                    <ul className="flex gap-4 items-stretch pr-4 py-1">
                        {stores.map((store) => (
                            <StoreListItem key={store.id} store={store} />
                        ))}
                    </ul>
                </div>
            </div>
        </div>
    );
};

最後に store-list/index.ts を作成します:

import React from "react";
import { createRoot } from "react-dom/client";
import { StoreList } from "./StoreList";
import "../index.css";

const container = document.getElementById("store-list-root");
if (container) {
    const root = createRoot(container);
    root.render(<StoreList />);
}

ビルド設定を準備

build.mjs を作成します。

import * as esbuild from "esbuild";
import tailwindPlugin from "esbuild-plugin-tailwindcss";

async function build() {
    const commonOptions = {
        bundle: true,
        loader: { ".tsx": "tsx", ".ts": "ts", ".css": "css" },
        jsx: "automatic",
        platform: "browser",
        target: "es2020",
        minify: true,
        sourcemap: true,
        external: ["tailwindcss"],
        plugins: [tailwindPlugin()],
    };

    try {
        // Build store-list
        await esbuild.build({
            ...commonOptions,
            entryPoints: ["src/store-list/index.tsx"],
            outfile: "dist/store-list.js",
        });
        console.log("✓ store-list.js built successfully");

        console.log("\nBuild completed successfully!");
    } catch (error) {
        console.error("Build failed:", error);
        process.exit(1);
    }
}

build();

package.json にビルドコマンドを追加します。

{
  ..
  "scripts": {
    "build": "node build.mjs"
  }
  ..

ビルドして生成物を確認する

ここまでのファイルを準備できたらビルドしてみましょう。

// webディレクトリで実行
npm run build

うまくいけば以下のパスにファイルが生成されています。確認しましょう。

  • web/dist/store-list.css
  • web/dist/store-list.js

ツールとUIを繋ぐ

最初に、実装したMCPツールからUIに接続します。

const fetchStores = { ... };

// ここから追記
import { readFileSync } from "fs";

const storeListJS = readFileSync("../web/dist/store-list.js", "utf-8");
const storeListCSS = readFileSync("../web/dist/store-list.css", "utf-8");

const storeListHTML = `
<div id="store-list-root"></div>
<style>${storeListCSS}</style>
<script>${storeListJS}</script>
`;

server.registerResource(
    "store-list-widget",
    "ui://widget/store-list.html",
    {},
    async () => ({
        contents: [
            {
                uri: "ui://widget/store-list.html",
                mimeType: "text/html+skybridge",
                text: storeListHTML,
                _meta: {
                    "openai/widgetDescription": "Simple Store List",
                },
            },
        ],
    })
);

// ...ここまで追記

server.registerTool( ... )

ファイルを変更できたら動作確認してみましょう。

サーバーを起動します。

npm run start

MCP Inspectorを別タブで立ち上げます。

npx @modelcontextprotocol/inspector

List Resources で、今定義したリソースが読み込まれていればOKです。

MCP Inspectorの画面。List Resourcesが実行されている
MCP InspectorでList Resourcesを実行

ChatGPTで確認する

ngrokでURLを取得

それでは実際にChatGPTから試してみましょう。

ChatGPTから開発中アプリに接続する際、インターネットに公開されたURLが必要です。
ローカル環境にアクセスするために ngrok を利用します。

ngrok でアカウントを作成し、手順に従ってセットアップしてください。

brew install ngrok
ngrok config add-authtoken <YOUR_AUTH_TOKEN>

ポート3000に向けてコマンドを実行します。

ngrok http 3000

以下のようなURLが吐かれるので、こちらをメモします。

https://xxxxxxxxxxxxxx.ngrok-free.dev

ChatGPTに設定する

ChatGPTの「設定 > アプリとコネクター」画面を開き、下部にスクロールして 高度な設定 を選択します。

ChatGPTのアプリとコネクター設定画面
高度な設定メニューを開く

ここから開発者モードをONにしましょう。

ChatGPTの開発者モード設定画面
開発者モードをONにする

アプリとコネクター画面に戻り、右上に「作成する」ボタンが表示されるので選択します。

ChatGPTのアプリとコネクター画面で、右上に作成ボタンがある

各項目を入力します。

ChatGPTのアプリ作成画面で、入力フォームが表示されている
MCPサーバーURLには先ほどのngrokのものを入力

名前、説明、MCPサーバーのURL(先ほどコピーしたもの)を記載します。認証は「認証なし」を選択します。
アイコンはなしでも良いですが、ChatGPTに作ってもらったものを設定してみました。

ChatGPTで試す

それでは実際にChatGPTで試してみましょう。

確実に発火させるために、ここではまずアプリを選択します。

ChatGPTのチャット画面で、1.+ボタン 2.さらに表示 3.Z Coffee Store と選択
ChatGPTの+ボタンからアプリを選択

ChatGPTに尋ねてみましょう。

「Z Coffee Store の店舗一覧を教えて」

ChatGPTのチャット画面で、UIつきで店舗一覧の情報が表示されている
店舗一覧がUIつきで表示される

店舗情報が表示されました!いい感じです。

ここまでの実装に、住所で絞り込んで店舗を取得するロジックも含まれています。
「六本木」にある店舗を尋ねてみましょう。

ChatGPTのチャット画面で、六本木の店舗情報がUIつきで表示されている
指定した住所で絞り込まれているのが分かる

絞り込んだ結果が表示されていますね。

これでアプリの開発は完了です!

おわりに

Apps SDKを使ってChatGPTアプリを作る手順を紹介しました。

今回は最低限のUI実装でしたが、公式のサンプルにはフルスクリーン表示などたくさんの例があり参考になります(そのままだと動かないケースもあるのでIssueを都度確認すると良いです)。

今年後半には審査の受付が開始されるとアナウンスされています。
状況をウォッチしながら、色々できることを試していきましょう。

もし内容に誤りがありましたらご指摘ください🙇‍♂️

関連リンク

AI開発のTipsなどをポストしてるので、Xをフォローしてもらえるとうれしいです!

Discussion