JavaRush /Курсы /ChatGPT Apps /Как устроен шаблон: структура проекта и ключевые файлы

Как устроен шаблон: структура проекта и ключевые файлы

ChatGPT Apps
2 уровень , 1 лекция
Открыта

1. Введение

Проект ChatGPT App HelloWorld — это не «магическая чёрная коробка от CodeGym, в которой лучше ничего не трогать». Это обычный Next.js‑проект, просто в нём одновременно живут:

  • фронтенд, который рендерится внутри ChatGPT,
  • MCP‑сервер, который отвечает на вызовы инструментов (tools),
  • настройки, которые склеивают всё это с ChatGPT.

Если не понимать, где что лежит, обычно происходят три классических сценария:

  1. Разработчик случайно пишет window в серверном файле, ловит падение и начинает ненавидеть весь стек.
  2. Пытается добавить кнопку в UI, но правит не тот page.tsx (например, корень приложения, а не виджет) и не видит изменений в ChatGPT.
  3. Случайно кладёт OPENAI_API_KEY в клиентскую часть, и ключ утекает в браузер.

Поэтому сегодняшняя цель — разметить карту: где UI, где MCP, где конфиги, и куда ходить, когда вы хотите:

  • поменять внешний вид виджета;
  • добавить новый tool;
  • подкрутить какую‑нибудь платформенную настройку (CORS, assetPrefix и т.п.).

2. Высокоуровневая анатомия проекта

Next.js‑проект ChatGPT App HelloWorld использует App Router и организован вокруг папки app/. В нём в одном дереве страниц уживаются:

  • UI виджета, который будет рендериться внутри ChatGPT,
  • MCP‑endpoint, который будет обрабатывать tool‑вызовы.

Типичное дерево (упрощённое, имена папок в вашем шаблоне могут отличаться, но паттерн тот же):

my-chatgpt-app/
├─ app/
│  ├─ api/                          // REST API
│  │  └─ time/                      // GET /api/time возвращает время на сервере
│  │     └─ route.ts
│  ├─ hooks/                        // Набор хуков из официального Apps SDK
│  │  ├─ use-call-tool.ts
│  │  ├─ use-display-mode.ts
│  │  └─ use-open-external.ts
│  ├─ mcp/                          // MCP-сервер: сюда стучится ChatGPT, когда вызывает tools
│  │  └─ route.ts
│  ├─ globals.css                   // Корневой globals.css всего приложения
│  ├─ layout.tsx                    // Корневой layout всего приложения
│  └─ page.tsx                      // Страница виджета внутри ChatGPT
├─ public/                          // Статика: иконки, манифест и т.п.
├─ next.config.ts                   // Конфиг Next.js и Apps-специфичные настройки (assetPrefix и др.)
├─ proxy.ts                         // CORS/заголовки для работы внутри iframe (бывший middleware.ts)
├─ package.json                     // Зависимости проекта
├─ tsconfig.json                    // Конфигурация TypeScript
└─ .env.local                       // Секреты: OPENAI_API_KEY и др.

Если виджетов несколько, то обычно их кладут не в app/page.tsx, а в app/widget/page.tsx. Но логика от этого не меняется: всё равно есть одна страница‑виджет и один endpoint, который играет роль MCP‑сервера.

Удобно мыслить так: ваш репозиторий — это «двуликий Янус»:

  • одно «лицо» — путь /mcp , куда идёт ChatGPT, когда хочет вызвать инструмент;
  • другое «лицо» — путь /widget (или /), который загружается в iframe, когда модель решает показать ваш UI.

Чтобы не путаться, зафиксируем в голове три группы файлов:

  1. UI‑слой — всё, что связано с React/Next‑страницами (app/widget, компоненты, стили).
  2. MCP‑слойapp/mcp/route.ts и файлы, которые он использует.
  3. Клеевой слой и конфигиnext.config.ts, proxy.ts, .env.local, package.json, tsconfig.json.

Чуть ниже мы пройдёмся по каждому из этих слоёв.

3. Где живёт виджет: папка app/widget и/или app/page.tsx

Начнём с того, что вы будете трогать чаще всего — виджет, то есть UI, который будет виден внутри ChatGPT.

В большинстве актуальных проектов есть либо:

  • папка app/widget/page.tsx — виджет живёт по отдельному префиксу /widget,
  • либо корневой app/page.tsx — виджет совпадает с корневой страницей.

Главные признаки файла с виджетом:

  • в самом верху стоит 'use client', потому что компонент работает в браузере, общается с window и Apps SDK;
  • это обычный React‑компонент, который рендерит разметку и (чуточку позже в курсе) общается с window.openai.

Простейший пример учебного виджета (можете очень похожий уже увидеть у себя в проекте):

// app/widget/page.tsx
'use client';

import React from 'react';

export default function WidgetPage() {
  return (
    <main className="p-4">
      <h1 className="text-xl font-semibold">
        HelloWorld — ChatGPT App
      </h1>
      <p className="text-sm text-gray-500">
        Здесь мы будем строить UI нашего виджета.
      </p>
    </main>
  );
}

Если в вашем шаблоне виджет лежит прямо в app/page.tsx, код будет примерно таким же, только без промежуточной папки widget.

Обратите внимание на несколько моментов.

Во‑первых, директива 'use client' обязательна: виджет читает/пишет в window.openai, слушает события и т.п., а это возможно только в клиентском компоненте. Если её убрать, Next попробует сделать страницу серверной, и вы получите ошибки вида «window is not defined».

Во‑вторых, это обычный, совсем не магический React‑компонент. Вы можете:

  • разбивать его на подкомпоненты в components/,
  • использовать Tailwind или любую другую CSS‑систему,
  • подключать контексты, хуки и т.д.

Во‑третьих, позже именно здесь вы будете:

  • читать window.openai.toolInput и window.openai.toolOutput, чтобы отрисовать реальные данные,
  • сохранять widgetState через window.openai.setWidgetState,
  • вызывать openExternal, callTool и прочие методы рантайма.

Сейчас достаточно знать: если вы хотите поменять визуальный интерфейс — вам почти наверняка в app/widget/page.tsx или app/page.tsx.

4. Корневой layout: app/layout.tsx как «рамка» для всего приложения

Следующий важный файл — app/layout.tsx. Он:

  • определяет HTML‑структуру (<html>, <body>),
  • подключает глобальные стили (globals.css),
  • часто инициализирует «bootstrap» для Apps SDK (обёртку, которая слушает window.openai и пробрасывает данные в React).

Упрощённый пример:

// app/layout.tsx
import './globals.css';
import type { ReactNode } from 'react';
import { OpenAIAppProvider } from '@/lib/openai-app-provider';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NextChatSDKBootstrap baseUrl={baseURL} />
      </head>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden`}>
        {children}
      </body>
    </html>
  );
}

Название NextChatSDKBootstrap здесь условное, в вашем шаблоне это может быть OpenAIAppProvider или другой компонент. Его задача обычно одна: настроить связь между React‑деревом и рантаймом Apps SDK, подписаться на глобальные данные (theme, displayMode, toolInput и т.д.) и раздать их детям.

Важный практический вывод: если вам нужно подключить глобальный контекст, стили или UI‑библиотеку (например, shadcn/ui) — место для этого почти всегда app/layout.tsx (или layout внутри app/widget для настроек и компонентов, специфичных именно для виджета).

Разбор NextChatSDKBootstrap

NextChatSDKBootstrap я подсмотрел в официальном шаблоне у Vercel. Если вы не знали, то это как раз те ребята, которые создали и развивают Next. У них на сайте есть хороший пост про ChatGPT App на Next. А так же есть Starter Template. Хотя в паре моментов он уже немного устарел, думаю есть все шансы, что они будут поддерживать его актуальность.

Выделим 5 ключевых вещей, который нам дает NextChatSDKBootstrap:

  • 1. Фиксит проблемы с гидрацией
    Все дело в том, что ChatGPT сначала загружает HTML вашего виджета на свой сервер, подчищает и патчит его. В результате механизм гидрации ругается и сыпет Warnings в консоль. Что может помешать вам пройти review.
  • 2. Патчит историю браузера
    Все дело в том, что ваш виджет загружается в iframe со специального домена в ChatGPT. И если вы будете использвать свой домент, то будете ломать песочницу. Поэтому в историю браузера сохраняется только путь без домена.
  • 3. Переписывает функцию fetch()
    Весь ваш fetch() на относительные адреса без домена не будет работать в виджете, т.к. домен у iframe другой. Поэтому мы подменяем функцию fetch() на свою, которая отправляет запросы без домена на правильный url. Если домен указан, то все работает без изменений.
  • 4. Клики по ссылкам работают
    Если ссылки будут открываться внутри iframe то ChatGPT этого не одобрит. Поэтому был добавлен код, который отслеживает нажатия на ссылки и открывает их во внешней окне через openExternal().
  • 5. Установка head base (DEPRECATED)
    Так же этот код добавлял <base> в <head>, но он уже не работает. Песочница сбрасывает любой установленный base, так что рекомендую использовать абсолютные ссылки для всего что есть: скрипты, ресуры, шрифты, api и т.п.

5. MCP‑сервер: app/mcp/route.ts

Теперь переходим ко второй половине «двуликого Януса» — серверу, который говорит с ChatGPT по MCP.

Файл app/mcp/route.ts — это обычный Route Handler App Router’а, который:

  • принимает HTTP‑запросы от ChatGPT (обычно POST с JSON‑payload в формате MCP),
  • передаёт их в MCP‑сервер (на базе @modelcontextprotocol/sdk или тонкой обёртки),
  • возвращает обратно JSON‑ответ в формате MCP.

Есть два варианта: можно написать на голом MCP SDK, а можно попробовать сгладить углы и использовать несколько классов от того же Next/Vercel.

Вот вариант на чистом TS MCP SDK:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

// 1. Создаём MCP сервер
const server = new McpServer({
  name: "simple-mcp-server",
  version: "1.0.0",
});

// 2. Регистрируем MCP Resources
// 3. Регистрируем MCP Tools

// 4. HTTP транспорт
const transport = new HttpServerTransport({
  port: 3001,
  path: "/mcp",
});

// 5. Запуск сервера
await server.connect(transport);

Но лучше использовать несколько готовых классов, чтобы работать было приятнее:

// app/mcp/route.ts
import { NextRequest } from 'next/server';
import { createMcpHandler } from "mcp-handler";

const handler = createMcpHandler(async (server) => {
  const gateway = new McpGateway(server);
  await gateway.initialize();
  gateway.registerResources();
  gateway.registerTools();
});

export const GET = handler;
export const POST = handler;

Здесь McpGateway — это класс-обертка вокруг McpServer, который вы где‑то создаёте (например, в lib/mcp/server.ts) с помощью SDK. В нашем случае он весь помещается в app/mcp/route.ts. Давайте полность разберем, что находится в этом файле.

type ContentWidget

В начале файла у нас описан тип ContentWidget. Он содержит все данные виджета и используется в двух местах: при регистрации виджета как mcp-resource и когда mcp-tool возвращает metadata, где указывает какой виджет использовать для отображения данных, которые он вернул.

type ContentWidget = {
  id: string;            // Уникальное имя/key
  title: string;         // Title
  description: string;   // Description
  templateUri: string;   // Уникальный URI виджета, может быть любым. Ни на что не влияет.
  invoking: string;      // Надпись над виджетом пока он грузится
  invoked: string;       // Надпись над виджетом когда он уже загрузился
  html: string;          // Весь html код виджета.
  widgetDomain: string;  // "Домен" виджета. Ни на что не влияет.
};

class McpGateway

Класс-обертка над McpServer, упрощает некоторые вещи. Содержит 6 методов:

  • initialize() — тут мы загружаем HTML нашего виджета
  • registerResources() — регистрируем виджеты как mcp-resources
  • registerTools() — регистрируем функции как mcp-tools
  • widgetMeta() — возвращает метаданные виджета
  • getAppsSdkCompatibleHtml() — загружает html код виджета и немного патчит его
  • makeImgUrlsAbsolute() — патчит html: меняет ссылки на картинки на абсолютные

Давайте пройдемся по ним подробнее:

public async initialize()

Этот метод загружает из интернета html-код виджетов и заполняет объект типа ContentWidget.

{
  id: "hello_world",                         // Уникальный key виджета
  templateUri: "ui://widget/hello_world.html", // Уникальный URI виджета. "ui:" ничего не значит.
  title: "HelloWorld Widget",               // Имя виджета
  description: "Displays the HelloWorld widget", // Объяснение для LLM что делает виджет
  invoking: "Loading widget...",            // Надпись над виджетом во время его загрузки
  invoked: "Widget loaded",                 // Надпись над виджетом после его загрузки
  html: htmlWidget,                         // HTML виджета
  widgetDomain: baseURL,                    // "Домен" виджета. Сейчас ни на что не влияет.
}

public registerResources()

Регистрирует виджеты как mcp-resources. Вызывает метод server.registerResource(), в который передаеются 4 параметра:

  • id/key MCP-ресурса
  • URI ресурса (это нужно именно для MCP-протокола, для виджета фактически синоним уникального адреса)
  • Метаданные MCP-ресурса
  • Функция, которая возвращает MCP-ресурс

Метаданные виджета

{
  title: widget.title,                 // Имя ресурса/виджета
  description: widget.description,     // Описание ресурса/виджета
  mimeType: "text/html+skybridge",     // Важно! Только такой html будет отображаться как виджет
  _meta: {
    "openai/widgetDescription": widget.description, // Описание виджета
    "openai/widgetPrefersBorder": true,            // Говорим ChatGPT отрисовать рамку виджета
  },
}

Виджет как MCP-ресурс

{
  uri: uri.href,                        // Наш URI (берется из параметра uri)
  mimeType: "text/html+skybridge",      // Важно! Только такой html будет отображаться как виджет
  text: widget.html,                    // HTML виджета
  _meta: {
    "openai/widgetDescription": widget.description, // Описание виджета
    "openai/widgetPrefersBorder": true,            // Говорим ChatGPT отрисовать рамку виджета
    "openai/widgetDomain": widget.widgetDomain,    // "Домен" виджета. Сейчас ни на что не влияет.
    "openai/widgetCSP": {                          // Важно! Домены доступные виджету:
      connect_domains: [                           // Домены для соединений (fetch и т.п.)
        baseURL,
        "https://codegym.cc",
      ],
      resource_domains: [                          // Домены для ресурсов (css/fonts/img)
        baseURL,
        "https://codegym.cc",
        "https://cdn.tailwindcss.com",
        "https://persistent.oaistatic.com",
        "https://fonts.googleapis.com",
        "https://fonts.gstatic.com"
      ]
    }
  },
}

В будущем мы еще не раз коснемся openai/widgetCSP, но сейчас хотелось бы отметить 2 момента насчет него:

  • connect_domains — список доментов для:
    • fetch()
    • загрузки скриптов
    • openExternal()
  • resource_domains — список доментов для:
    • картинок
    • CSS
    • шрифтов

Теоретически вы можете написать 200 доменов, но вот сможете ли вы с таким списком пройти review — это еще тот вопрос.

Так же я изучил эти параметры у уже опубликованных приложений и нашел там amplitude.com. Что тоже позитивная новость. Думаю хорошая аналитика никому не помешает.

public registerTools()

Регистрирует функции как mcp-tools. Вызывает метод server.registerTool(), в который передаеются 3 параметра:

  • id/key MCP-tool
  • Метаданные MCP-tool
  • Функция, которая возвращает MCP-tool

Метаданные инструмента

Все параметры в этом списке важны. Подробнее о них я расскажу в следующих лекциях.

{
  title: widget.title,                               // Имя инструмента
  description: "Returns HelloWorld widget",          // Важно! Описание что инструмент делает
  inputSchema: z.object({}).describe("No inputs"),   // Схема параметров инструмента. Можно Zod
  _meta: this.widgetMeta(widget),                    // Метаданные виджета: какой виджет отображать
  annotations: {
    destructiveHint: false,                          // Метод делает что-то важное - нужен confirm
    openWorldHint: false,                            // Метод меняет что-то в third party services
    readOnlyHint: true                               // Метод ничего не меняет
  },
}

Функция которая делает что-то важное

async (input, extra) => {
  // 1. Валидация параметров
  // 2. Делаем что-то важное
  return {
    content: [{ type: "text", text: "HelloWorld MCP-tool" }], // Описание результата для ИИ
    structuredContent: {                                      // Важно! Это и есть JSON результата.
      timestamp: new Date().toISOString()                     // Может содержать любые данные.
    },
    _meta: this.widgetMeta(widget),                           // Метаданные виджета, который отображает JSON
  };                                                          // Может отсутствовать - тогда виджета не будет
}

private widgetMeta(widget: ContentWidget)

Возвращает метаданные виджета — по ним ChatGPT определит какой виджет использовать для отображения JSON-результата.

{
  "openai/outputTemplate": widget.templateUri,            // URI виджета
  "openai/toolInvocation/invoking": widget.invoking,      // Надпись над виджетом, пока он грузится
  "openai/toolInvocation/invoked": widget.invoked,        // Надпись над виджетом, когда он загрузился
  "openai/widgetAccessible": true,                        // MCP-tool можно вызывать из виджета
  "openai/resultCanProduceWidget": true,                  // MCP-tool вернет виджет
}

Отдельно хотелось бы обговорить такую простую вещь как "openai/outputTemplate". В MCP-протоколе есть 3 сущности (о которых вы подробнее узнаете в модуле 6):

  • MCP Resources
  • MCP Templates
  • MCP Tools

Так вот, данный "openai/outputTemplate" не имеет никакого отношения к MCP Templates. MCP Templates вообще никак не используются в ChatGPT Apps. Слово template тут вот откуда:

Виджеты задумывались как шаблон для отображения JSON. MCP-tool вернул некий JSON, ИИ отобразил виджет, передал ему JSON через параметр ToolOutput, и виджет красиво отображает этот JSON. outputTemplate — это просто синоним виджета.

Думаю на этом все. Подробнее мы разберем эти вещи в модуле 4: как именно описывать инструменты, JSON Schema и обработчики. Сейчас достаточно понимать: если что‑то связано с инструментами (tools) и логикой — ищите рядом с app/mcp/route.ts.

6. Конфигурация и «клей»: next.config.ts, middleware.ts, .env и друзья

Теперь разберём главный набор файлов, которые нужны, чтобы ваш Next.js‑проект корректно работал внутри iframe ChatGPT и был доступен ChatGPT через HTTPS‑туннель (ngrok, Cloudflare Tunnel и т.п.; про туннели мы ещё поговорим отдельно).

next.config.ts

В этом файле, помимо стандартных настроек Next.js, часто настраивается:

  • assetPrefix — чтобы статика (JS, CSS из /_next/) корректно грузилась не с домена ChatGPT, а с вашего dev‑URL (туннеля или Vercel);
  • любые специфичные настройки, нужные шаблону (например, экспериментальные флаги под Next 16).

На практике это выглядит как обычный экспорт nextConfig с нужными полями. Для лекции нам важно одно: если в ChatGPT виджет не может загрузить CSS/JS, очень часто виноват именно assetPrefix.

proxy.ts (бывший middleware.ts)

Этот файл вставляет слой middleware между запросом из ChatGPT и вашими роутами. В шаблоне он обычно:

  • выставляет CORS‑заголовки, чтобы iframe ChatGPT вообще имел право обращаться к вашему серверу;
  • иногда настраивает дополнительные заголовки для React Server Components.

Знать все тонкости сейчас не обязательно. Полезно лишь помнить: если ChatGPT жалуется на CORS или вы видите странные ошибки в DevTools про запреты доступа, загляните в proxy.ts.

.env

Файл .env (или .env.local) — место для секретов и параметров окружения:

  • OPENAI_API_KEY (если MCP‑сервер сам ходит в OpenAI API),
  • адреса ваших внутренних API,
  • токены сторонних сервисов и т.д.

Есть важный нюанс: в Next.js переменные, начинающиеся с NEXT_PUBLIC_, автоматически попадают в JS‑бандл и становятся доступными в браузере. Никогда не делайте так с OPENAI_API_KEY; секреты должны быть только серверными переменными.

package.json и tsconfig.json

В package.json вы увидите:

  • версии Next.js, React, Apps SDK, MCP SDK и прочих зависимостей;
  • скрипты dev, build, start, а иногда и вспомогательные команды (линтер, форматер и т.д.).

В tsconfig.json лежат привычные для вас настройки TypeScript:

  • пути алиасов (@/lib, @/components),
  • строгий режим,
  • таргеты компиляции.

С точки зрения этого курса главное — понимать, что шаблон использует обычный TypeScript‑стек, и вы можете расширять его стандартным образом.

7. Быстрый «навигатор по проекту» для разработчика

Давайте зафиксируем, куда идти, когда вы хотите сделать типичные вещи. Без списков, просто в виде мини‑сценариев.

Если вам хочется поменять текст/кнопки в виджете, вы открываете файл c UI виджета: это либо app/widget/page.tsx, либо app/page.tsx — в зависимости от шаблона. Там вы правите JSX, добавляете новые компоненты, подключаете дизайн‑систему. И именно здесь вы будете использовать Apps SDK‑рантайм (window.openai или удобные хуки) для отображения данных.

Если нужно добавить новую кнопку, которая что‑то делает на сервере, вы начинаете всё равно с UI‑файла. Кнопка в виджете по клику будет вызывать window.openai.callTool, а реализацию этого инструмента вы добавите в конфигурацию MCP‑сервера, то есть в код рядом с app/mcp/route.ts. Связку UI ↔ tool‑логика как раз будем разбирать в модулях 4 и далее.

Когда вы хотите научить ChatGPT новый функционал (например, «поиск туров» или «подбор товаров»), вы идёте в MCP‑слой (файлы, импортируемые из app/mcp/route.ts). Там регистрируете новый tool с JSON Schema, описанием и обработчиком. Виджет может потом читать результат через window.openai.toolOutput и красиво его отображать.

Если у вас слетела статика или виджет странно отображается только в ChatGPT, а локально всё в порядке, вспоминаем про клеевой слой. В первую очередь стоит проверить next.config.ts (особенно assetPrefix) и middleware.ts/proxy.ts (CORS). Если вы недавно меняли туннель, URL или деплоили на Vercel, то корректность этих настроек критична.

Наконец, если вы подозреваете проблемы с ключами или окружением, ваша тройка файлов — .env.local, package.json (чтобы убедиться, какие зависимости и скрипты реально используются) и логи dev‑сервера. Именно эта связка отвечает за то, чтобы MCP имел доступ к нужным секретам и сервисам.

8. Мини‑практика: знакомимся с файловой системой руками

Теория теорией, но давайте руками зафиксируем, где что лежит. Эти шаги можно проделать прямо сейчас в редакторе/IDE.

Попробуйте открыть в своём проекте папку app и найти, какой файл отвечает за виджет. Если шаблон использует app/page.tsx, именно там вы увидите знакомую надпись вроде «HelloWorld — ChatGPT App» или приветственный текст. Если виджета как отдельной папки нет, откройте app/page.tsx и убедитесь, что там есть 'use client' и какая‑то JSX‑разметка.

Дальше найдите app/mcp/route.ts. Обратите внимание, какие модули он импортирует: обычно вы увидите либо прямое использование MCP SDK, либо вызов вспомогательной функции из lib/mcp/*. Оцените, насколько «тонкой» сделана эта прослойка — в идеале там почти нет бизнес‑логики, только «принял JSON → передал серверу → вернул JSON».

После этого загляните в next.config.ts и proxy.ts/middleware.ts. Не нужно понимать всё, что там написано, просто зафиксируйте, что:

  • next.config.ts отвечает за конфигурацию Next, в том числе за правила сборки и отдачи ассетов;
  • proxy.ts вмешивается в HTTP‑запросы (почти наверняка увидите там работу с заголовками).

И напоследок откройте .env или .env.local и удостоверьтесь, что ваши ключи лежат именно там, а не в коде. Если где‑то увидите NEXT_PUBLIC_OPENAI_API_KEY — это отличный повод исправить, пока речь ещё только о локальной разработке.

9. Визуальная схема: как ChatGPT взаимодействует с вашим шаблоном

Чтобы картина окончательно сложилась, полезно взглянуть на простой поток:

flowchart TD
    U[Пользователь в ChatGPT] -->|Пишет запрос| M[Модель ChatGPT]

    M -->|Вызывает tool| MCP["Ваш MCP endpoint
app/mcp/route.ts"] MCP -->|"JSON-ответ MCP(structuredContent, _meta, UI-ссылка)"| M M -->|Решает показать UI| WIDGET_URL["URL виджета
(/widget или /)"] WIDGET_URL -->|iframe| W[Ваш виджет
app/page.tsx] W -->|читает window.openai.toolOutput
+ widgetState| U

Здесь важно заметить, что инициатором почти всегда является модель ChatGPT, а не браузер пользователя, как в классическом веб‑приложении. Ваш app/mcp/route.ts и app/widget/page.tsx — это просто две разные «двери» в один и тот же Next.js‑проект: одна для робота (MCP), другая для UI.

Если держать в голове эту карту проекта (виджет → MCP‑слой → конфиги) и осознанно избегать перечисленных граблей, дальше по курсу вы сможете сосредоточиться уже на логике и UX своего App, а не на поиске «того самого файла, который всё ломает».

10. Типичные ошибки при работе со структурой шаблона

Ошибка №1: Путать виджет с обычной страницей сайта.
Иногда разработчик видит в шаблоне и app/page.tsx, и app/widget/page.tsx, правит «не тот» файл и удивляется, почему изменения не появляются в ChatGPT. Виджет — это именно та страница, которая используется как outputTemplate/iframe для MCP‑инструмента. Если вы меняете другой роут, ChatGPT об этом даже не узнает. Всегда сверяйтесь с README шаблона и смотрите, какой URL указан как виджет.

Ошибка №2: Писать клиентский код (window, document) в серверных файлах MCP.
Файл app/mcp/route.ts и всё, что он импортирует, выполняется на сервере. Любая попытка использовать там window или DOM‑API приведёт к падающему рантайму. Если хочется что‑то сделать в UI, это почти наверняка должно быть в файлах под app/widget или других клиентских компонентах. MCP‑слой — чистый backend: запросы, базы, внешние API и формирование структурированного ответа.

Ошибка №3: Игнорировать assetPrefix и CORS‑настройки.
На локальном localhost:3000 всё работает прекрасно, но стоит открыть App через туннель в ChatGPT — и стили пропадают, JS не грузится, в консоли куча CORS‑ошибок. Часто причина в том, что конфигурация next.config.ts или middleware.ts/proxy.ts не учитывает новый публичный URL или была случайно сломана при рефакторинге. Меняя эти файлы, всегда держите в голове, что ваш код будет жить внутри iframe на домене ChatGPT, а не напрямую на localhost.

Ошибка №4: Хранить секреты не в .env, а прямо в коде или в NEXT_PUBLIC_*‑переменных.
Прятать OPENAI_API_KEY в const apiKey = 'sk-...' где‑нибудь в app/widget/page.tsx — это худшая из идей: ключ окажется в JS‑бандле и уйдёт к любому пользователю. Почти так же плохо — сделать переменную NEXT_PUBLIC_OPENAI_API_KEY, потому что префикс NEXT_PUBLIC_ гарантирует попадание в браузер. Всегда кладите секреты в .env без этого префикса и используйте их только на серверной стороне (MCP‑сервер, backend‑функции).

Ошибка №5: Считать шаблон «слишком умным» и бояться его трогать.
Иногда разработчики относятся к официальному стартеру как к чему‑то священному: «лучше туда не лезть, вдруг сломаю интеграцию». В результате они пишут весь свой код где‑то сбоку, усложняют архитектуру и всё равно наступают на те же грабли. На самом деле шаблон — это всего лишь аккуратно собранный Next.js‑проект с парой настроек под Apps SDK. Понимание, что app/ — это UI и MCP, а остальное — обычные конфиги, очень освобождает: вы начинаете работать с кодом как с привычным React/Next‑проектом, а не с магической коробкой.

Ошибка №6: Пытаться решить все проблемы «на уровне виджета».
Иногда хочется всё делать в UI: и бизнес‑логику, и доступ к базам, и внешние API‑запросы. В контексте ChatGPT Apps это особенно плохая идея: виджет живёт в очень жёсткой песочнице, не видит ваши секреты и сильно зависит от window.openai. Если нужно что‑то серьёзное — место этому в MCP‑слое и backend‑сервисах, а виджет должен быть тонким презентационным слоем, который отображает структурированные данные и, при необходимости, триггерит инструменты.

1
Задача
ChatGPT Apps, 2 уровень, 1 лекция
Недоступна
Бейдж окружения виджета + мини-карта ключевых путей
Бейдж окружения виджета + мини-карта ключевых путей
1
Задача
ChatGPT Apps, 2 уровень, 1 лекция
Недоступна
Серверный маршрут /api/time как “маяк” для понимания app/api
Серверный маршрут /api/time как “маяк” для понимания app/api
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ