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

今回は「Z Coffee」というカフェを想定し、ユーザーとのやり取りに応じて店舗情報を表示するアプリを作ってみました。
これをどのように実装したのかをまとめてみます。
ChatGPT Appsとは?
OpenAIが提供する仕組みで、ChatGPTとの会話の中で3rd partyが作成したアプリをリッチなUI付きで表示できるものです。
例えば「渋谷付近でホテルを予約したい」とユーザーが尋ねた際、Booking.comのアプリが反応して渋谷近辺のホテルの一覧を画像付きで表示する、みたいな感じです。

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に入稿
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を実行
Tools > List Tools を実行し、store-list ツールが表示されて動いていれば確認はOKです。
UIを実装する
次にUI部分を実装していきましょう。
前準備
必要なファイルを準備していきます。
ディレクトリ構成はこんな感じです。

ディレクトリ構成。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.cssweb/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を実行
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の「設定 > アプリとコネクター」画面を開き、下部にスクロールして 高度な設定 を選択します。

高度な設定メニューを開く
ここから開発者モードをONにしましょう。

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

各項目を入力します。

MCPサーバーURLには先ほどのngrokのものを入力
名前、説明、MCPサーバーのURL(先ほどコピーしたもの)を記載します。認証は「認証なし」を選択します。
アイコンはなしでも良いですが、ChatGPTに作ってもらったものを設定してみました。
ChatGPTで試す
それでは実際にChatGPTで試してみましょう。
確実に発火させるために、ここではまずアプリを選択します。

ChatGPTの+ボタンからアプリを選択
ChatGPTに尋ねてみましょう。
「Z Coffee Store の店舗一覧を教えて」

店舗一覧がUIつきで表示される
店舗情報が表示されました!いい感じです。
ここまでの実装に、住所で絞り込んで店舗を取得するロジックも含まれています。
「六本木」にある店舗を尋ねてみましょう。

指定した住所で絞り込まれているのが分かる
絞り込んだ結果が表示されていますね。
これでアプリの開発は完了です!
おわりに
Apps SDKを使ってChatGPTアプリを作る手順を紹介しました。
今回は最低限のUI実装でしたが、公式のサンプルにはフルスクリーン表示などたくさんの例があり参考になります(そのままだと動かないケースもあるのでIssueを都度確認すると良いです)。
今年後半には審査の受付が開始されるとアナウンスされています。
状況をウォッチしながら、色々できることを試していきましょう。
もし内容に誤りがありましたらご指摘ください🙇♂️
関連リンク
AI開発のTipsなどをポストしてるので、Xをフォローしてもらえるとうれしいです!
Discussion