1. Hai con đường ra ngoài: điều hướng và dữ liệu
Với một lập trình viên Next.js thông thường, khi nghe “cần gọi lên server”, phản xạ tự nhiên là dùng fetch hoặc HTTP‑client ưa thích. Trong thế giới ChatGPT Apps, phản xạ đó dễ dẫn tới rắc rối.
Trong phần khóa học về bảo mật của widget trong ChatGPT Apps, chúng tôi đề xuất ngay từ đầu hãy bỏ phản xạ cũ. Widget không sống trên internet tự do: nó ở trong môi trường cô lập chặt chẽ, và truy cập mạng của nó bị lọc và giới hạn bởi chính sách của host.
Widget chỉ có ba “cửa sổ” cơ bản ra bên ngoài:
- Điều hướng: đưa người dùng tới đâu đó bên ngoài. Dùng openExternal.
- Trao đổi dữ liệu: nhận/gửi JSON, nói chuyện với backend. Làm qua fetch, nhưng có thể bị giới hạn đáng kể.
- MCP tool call: gọi công cụ (MCP / backend), vốn không chịu các giới hạn nói trên.
Trong bài này, ta tập trung vào con đường đầu tiên và an toàn nhất (điều hướng) và làm quen cẩn thận với fetch có kiểm soát. Ở các mô-đun tiếp theo, ta sẽ phân tích MCP và công cụ như cách chính để giao tiếp nghiêm túc với máy chủ.
2. openExternal: “dịch chuyển” người dùng an toàn
Vì sao không thể đơn giản dùng window.open
Trong một ứng dụng web thông thường, bạn có thể làm như sau:
window.open("https://example.com", "_blank");
Trong sandbox của ChatGPT, điều này hoặc sẽ không hoạt động, hoặc hoạt động rất kỳ lạ. Widget là một iframe bị cô lập với sandbox nghiêm ngặt, không có cùng quyền như một tab trình duyệt.
Ngoài ra, host của ChatGPT muốn kiểm soát việc bạn đưa người dùng đi đâu và khi nào, để:
- ngăn theo dõi ngầm;
- hiển thị cho người dùng UI xác nhận dễ hiểu (đặc biệt trong ứng dụng di động/desktop);
- đảm bảo hành vi liên kết nhất quán ở các môi trường khác nhau (web, desktop, ứng dụng di động).
Vì vậy có API chuyên dụng openExternal, truy cập qua window.openai hoặc hook React tiện lợi hơn là useOpenExternal.
useOpenExternal trông như thế nào
Trong các ví dụ chính thức của Apps SDK, hook useOpenExternal được triển khai đại khái như sau:
export function useOpenExternal() {
const openExternal = useCallback((href: string) => {
if (typeof window === "undefined") return;
if (window?.openai?.openExternal) {
try {
window.openai.openExternal({ href });
return;
} catch (error) {
console.warn("openExternal failed, falling back to window.open", error);
}
}
window.open(href, "_blank", "noopener,noreferrer");
}, []);
return openExternal;
}
Ý chính rất đơn giản. Trước hết, ta cố gắng dùng cơ chế gốc của ChatGPT (window.openai.openExternal). Nếu widget tình cờ không render trong ChatGPT (ví dụ bạn mở nó trong trình duyệt khi phát triển), thì ta rơi về window.open một cách an toàn.
Trong ứng dụng của bạn, hook này đã có sẵn trong template (nếu bạn dùng repository chuẩn của OpenAI), và nên dùng đúng như vậy — chứ không nên tự thao tác trực tiếp vào window.openai.
Ví dụ: nút “Xem trong cửa hàng” ở GiftGenius
Giả sử trong toolOutput của GiftGenius có các gợi ý với trường productUrl. Hãy thêm vào mỗi thẻ một nút mở sản phẩm trên website của bạn:
import { useWidgetProps } from "../hooks/use-widget-props";
import { useOpenExternal } from "../hooks/use-open-external";
export function GiftListWidget() {
const { toolOutput } = useWidgetProps<{
recommendations: { id: string; title: string; price: string; url: string }[];
}>();
const openExternal = useOpenExternal();
if (!toolOutput) return <p>Hiện chưa có gợi ý…</p>;
return (
<div>
{toolOutput.recommendations.map((gift) => (
<div key={gift.id} className="flex justify-between gap-2">
<div>
<div>{gift.title}</div>
<div className="text-sm text-muted-foreground">{gift.price}</div>
</div>
<button onClick={() => openExternal(gift.url)}>
Mở
</button>
</div>
))}
</div>
);
}
Từ góc nhìn người dùng: họ nhấn nút, ChatGPT có thể hiện cửa sổ hệ thống “Mở trang web bên ngoài?”, sau đó mở trang của bạn ở tab mới hoặc trong trình duyệt mặc định. Bạn không mang theo bất kỳ bí mật, token… nào — chỉ đơn giản là “đưa người dùng từ chat ra website”.
3. window.fetch trong sandbox: không phải fetch mà bạn quen dùng
Frontend thường kỳ vọng điều gì
Thường thì suy nghĩ sẽ là: “Đã là trình duyệt thì có thể thoải mái gọi bất kỳ URL nào có CORS. Cùng lắm thì lỗi, nhưng thử được mà.”
Trong hệ sinh thái ChatGPT Apps, đó là một ngộ nhận nguy hiểm. Sandbox quanh widget không chỉ là “bắt bẻ nhỏ nhặt”, mà là yêu cầu bảo mật căn bản: để widget không thể theo dõi người dùng, không gọi tới domain tùy ý, không quét mạng cục bộ và nói chung không hành xử như một mini‑browser bên trong browser.
Cũng trong các tài liệu này nhấn mạnh rằng trong Apps SDK, quyền truy cập mạng tùy ý của widget hoặc là không có, hoặc bị giới hạn rất mạnh — và đó không phải lỗi, mà là quyết định kiến trúc có chủ đích.
Thực tế trông ra sao
Trong môi trường ChatGPT điển hình:
- fetch có thể khả dụng nhưng chỉ tới danh sách domain bị giới hạn (thường là domain của bạn nơi App chạy, và có thể là một vài API được cho phép rõ ràng);
- các request có thể đi qua proxy đặc biệt của host, proxy này lọc header và URL;
- một số method (PUT, DELETE) hoặc header không chuẩn có thể bị chặn bởi chính sách bảo mật.
Tuy vậy bạn vẫn có một con đường thuận tiện: nếu widget và backend của bạn cùng sống trên một domain (như trong template Next.js, nơi cả MCP‑server và UI được phục vụ bởi một ứng dụng), các request nội bộ fetch("/api/...") thường sẽ được cho phép.
Điều quan trọng — đừng trông chờ widget có thể gọi tới bất kỳ API nào trên internet. Mọi tương tác “nặng” với dịch vụ bên ngoài (Stripe, Notion, CRM, v.v.) phải diễn ra ở phía MCP/backend, nơi ChatGPT truy cập như một tài nguyên tin cậy.
Insight
Trong widget ChatGPT, hãy quên ngay đường dẫn tương đối và chỉ dùng URL tuyệt đối. Lý do đơn giản: HTML của bạn không chạy trên cùng domain với backend. ChatGPT lấy HTML của bạn, đặt nó trên host của chính ChatGPT và render bên trong một iframe cô lập. Bất kỳ "/api/..." hoặc "/static/logo.png" đều sẽ được resolve theo domain của ChatGPT chứ không phải ứng dụng của bạn — và mọi thứ sẽ hỏng.
<base> gần như không giúp được ở đây. Theo thử nghiệm, nếu widget không được đặt widgetCSP, bạn có thể khai báo <base href="https://my-app.dev/">: tài nguyên sẽ được tải từ domain của bạn, nhưng script, theo quy tắc sandbox, vẫn không thể hoạt động. Nhưng điều này chỉ hoạt động ở Dev Mode.
Còn khi bạn đặt openai/widgetCSP chuẩn (ở production bạn buộc phải đặt để được review), nền tảng sẽ bỏ qua <base>, và trò chơi kết thúc: tài nguyên và script chỉ được tải từ các domain được phép trong CSP, và bằng liên kết tuyệt đối.
Khuyến nghị: trong widget ChatGPT, mọi thứ đi ra ngoài — fetch, ảnh, CSS, các trang của bạn cho openExternal — luôn phải là URL đầy đủ từ domain gốc của ứng dụng, domain này bạn kiểm soát qua config/ENV, chứ không dùng đường dẫn tương đối và <base>.
4. Kiến trúc: UI mỏng, backend dày
Từ các giới hạn của fetch và sandbox, rút ra một nguyên tắc kiến trúc chung rất quan trọng cho toàn khóa học. Chúng ta đã lặp lại nhiều lần, giờ là lúc khẳng định: widget là một lớp UI mỏng. Nó render nội dung mà backend đã chuẩn bị (qua MCP/tools), hiển thị phản hồi theo thao tác người dùng và cùng lắm chỉ thực hiện vài request nhỏ, công khai.
Mọi thứ liên quan đến ủy quyền, truy cập dữ liệu cá nhân, bí mật và logic nghiệp vụ phức tạp phải sống ở phía máy chủ. Tài liệu bảo mật của khóa học nhấn mạnh: frontend (React‑widget) là “public place”, vùng tin cậy bằng 0, và bí mật không được tồn tại ở đó.
Toàn bộ nghiên cứu của tôi về chủ đề này dẫn đến mục tiêu rõ ràng: “đóng chiếc đinh cuối cùng vào nắp quan tài của ý tưởng ‘thick client’” cho ChatGPT Apps. Widget là phần đầu, còn thân và não — ở MCP/backend.
Vì vậy:
- openExternal — để điều hướng người dùng tới “website bình thường” của bạn, nơi có thể chạy SPA quen thuộc, trang tài khoản, v.v.;
- callTool (mô‑đun tiếp theo) — cách chính để giao nhiệm vụ cho mô hình, nhiệm vụ được backend của bạn thực thi;
- fetch từ widget — người hùng hiếm hoi cho các request bổ trợ, an toàn và tốt nhất là công khai tới ứng dụng của chính bạn.
5. Thực hành: openExternal trong GiftGenius của chúng ta
Hãy tích hợp openExternal vào App thực hành của chúng ta cẩn thận hơn và đồng thời suy nghĩ về UX.
Quy tắc UX nhỏ
Nếu bạn đưa người dùng ra bên ngoài, nên:
- nói rõ họ sẽ tới đâu;
- không tạo “cú nhảy” bất ngờ mà không giải thích trong văn bản (hoặc GPT thông báo “Tôi sẽ mở website của cửa hàng…”, hoặc bạn ghi rõ trên nút).
Ví dụ tiêu đề và chú thích:
<button onClick={() => openExternal(gift.url)}>
Mở trên website của cửa hàng
</button>
Người dùng hiểu rằng sắp rời “khung chat ấm áp” để tới thế giới thực với giỏ hàng và thanh toán.
Refactor nhỏ cho component danh sách
Trước đó ta đã làm một GiftListWidget đơn giản. Giả định rằng ở các bài trước bạn đã hiện thực widget hiển thị danh sách quà dựa trên toolOutput. Giờ ta làm bản gọn gàng hơn: thêm kiểu Gift với trường url và nút openExternal.
type Gift = {
id: string;
title: string;
priceLabel: string;
url: string;
};
export function GiftListWidget() {
const { toolOutput } = useWidgetProps<{ gifts: Gift[] }>();
const openExternal = useOpenExternal();
if (!toolOutput || toolOutput.gifts.length === 0) {
return <p>Hiện chưa tìm thấy gì. Hãy thử thay đổi yêu cầu.</p>;
}
return (
<div>
{toolOutput.gifts.map((gift) => (
<div key={gift.id} className="flex justify-between gap-2">
<div>
<div>{gift.title}</div>
<div className="text-sm text-muted-foreground">
{gift.priceLabel}
</div>
</div>
<button onClick={() => openExternal(gift.url)}>
Xem
</button>
</div>
))}
</div>
);
}
Ta vẫn không làm việc trực tiếp với window.openai, mà dùng hook tiện lợi — hook này đã biết cách rơi về window.open trong trường hợp không có môi trường ChatGPT. Cấu trúc Gift ở đây chỉ là ví dụ — trong App của bạn, hãy điều chỉnh theo backend của mình.
6. Thực hành: gọi fetch cẩn thận tới backend của chúng ta
Giờ hãy xem fetch. Nhắc lại: các thao tác phức tạp hoặc nhạy cảm tốt hơn nên làm qua công cụ/MCP. Nhưng đôi khi bạn muốn tải một thứ gì đó nhẹ và công khai từ chính máy chủ của bạn, ví dụ danh sách danh mục quà tặng phổ biến.
Một route API công khai đơn giản trong Next.js
Thêm vào dự án Next.js của chúng ta handler sau:
// app/api/public/popular-tags/route.ts
import { NextResponse } from "next/server";
const tags = ["Cho trẻ em", "Cho người hay đi du lịch", "Cho game thủ"];
export async function GET() {
return NextResponse.json({ tags });
}
Route này không biết gì về người dùng, không yêu cầu token, không gọi dịch vụ bên ngoài — chỉ trả về một mảng tĩnh. Loại mã này gần như có thể đưa vào production và sandbox mà ít rủi ro.
Gọi route này từ widget qua fetch
Giờ trong component widget, hãy thêm việc tải các tag. Với giới hạn của sandbox, tiện nhất là gọi bằng URL tuyệt đối: cùng domain nơi App của bạn chạy — domain mà bạn chuyển tiếp qua tunnel và đăng ký trong Dev Mode của ChatGPT (chúng ta đã cấu hình ở mô‑đun về Dev Mode và tunnel).
Quan trọng: domain của widget sẽ là dạng https://genius.web-sandbox.oaiusercontent.com, nên đừng dùng đường dẫn tương đối để tải dữ liệu, chỉ dùng URL tuyệt đối. Ví dụ:
import { useEffect, useState } from "react";
export function PopularTags() {
const [tags, setTags] = useState<string[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function loadTags() {
try {
const res = await fetch("https://giftgenius.app/api/public/popular-tags");
if (!res.ok) throw new Error("Bad status");
const data: { tags: string[] } = await res.json();
if (!cancelled) setTags(data.tags);
} catch (e) {
if (!cancelled) setError("Không thể tải danh mục phổ biến");
}
}
loadTags();
return () => {
cancelled = true;
};
}, []);
if (error) return <p>{error}</p>;
if (!tags) return <p>Đang tải danh mục phổ biến…</p>;
return (
<div className="flex flex-wrap gap-2 text-sm">
{tags.map((tag) => (
<span key={tag} className="rounded border px-2 py-1">
{tag}
</span>
))}
</div>
);
}
Quan trọng là:
- chúng ta xử lý lỗi cẩn thận và hiển thị thông báo dễ hiểu cho người dùng;
- không giả định rằng fetch “chắc chắn hoạt động” — chính sách của sandbox có thể chặn quyền truy cập bất kỳ lúc nào nếu bạn đổi domain hoặc bắt đầu gửi request lạ;
- không truyền bất kỳ token/bí mật nào; nếu cần xác thực — đó là công việc của MCP và các mô‑đun về Auth.
7. openExternal vs fetch vs công cụ (callTool): ai làm gì
Để khỏi nhầm lẫn, hãy giữ trong đầu “ma trận trách nhiệm” sau:
| Kịch bản | Dùng gì | Vì sao như vậy |
|---|---|---|
| Mở landing/sản phẩm/tài khoản | openExternal | Chuyển hướng rõ ràng của người dùng, do host kiểm soát |
| Lấy dữ liệu công khai từ App | fetch("my.com/api/...") | JSON nhẹ, cùng domain, không có secret |
| Lấy dữ liệu người dùng, DB | callTool/MCP | Cần xác thực, logic, backend an toàn |
| Gọi API bên ngoài (Stripe…) | MCP/server | Frontend không thấy secret, tuân thủ policy |
Trong mô‑đun hiện tại, điều quan trọng là học cách chọn công cụ một cách có chủ đích. Cần từ bỏ suy nghĩ “widget là frontend, nên làm được mọi thứ qua fetch”, và chuyển sang kiến trúc “widget là lớp UI được điều phối nằm trên LLM+MCP‑backend”.
Insight
Tương tác với máy chủ trong ChatGPT App hợp lý khi chia làm hai tầng:
- ChatGPT ↔ máy chủ MCP: mô hình gọi các công cụ MCP. Mỗi lần gọi công cụ (tool‑call) là khởi chạy hoặc chuyển trạng thái của một kịch bản nghiệp vụ (gợi ý quà, tạo đơn hàng, tính chi phí, v.v.). Ở đây là nơi đặt “logic nặng”, làm việc với dữ liệu, API bên ngoài và xác thực.
- Widget ↔ server: widget thực hiện các request fetch() nhẹ tới backend của chính nó và/hoặc gọi lại các công cụ MCP qua callTool() bên trong kịch bản đang hoạt động. Đây là các bước cục bộ: tải thêm dữ liệu phụ trợ, cập nhật một phần UI, làm rõ trạng thái.
Tức là MCP‑tool = khởi chạy/điều phối quy trình nghiệp vụ, còn fetch()/callTool() từ widget là các thao tác nhỏ bên trong kịch bản đã chọn, không nhằm thay đổi “câu chuyện” tổng thể của cuộc hội thoại.
8. Bài tập nhỏ thực hành
Để củng cố, hãy làm một tính năng nhỏ trong GiftGenius.
Kịch bản đề xuất:
- Trong danh sách quà, thêm nút “Đi tới thanh toán”, nút này dùng openExternal để mở trang checkout trên website dev của bạn.
- Phía trên danh sách quà, render PopularTags như ví dụ ở trên để hiển thị các danh mục phổ biến. Khi tải lỗi, hãy hiển thị văn bản dự phòng (fallback) và đừng làm hỏng toàn bộ widget.
- Lưu ý về UX: trong văn bản trả lời của GPT hoặc trong UI của widget, hãy giải thích cho người dùng rằng “khi nhấn nút, tôi sẽ mở trang của cửa hàng trong tab mới”.
Tính năng này ở dạng thu nhỏ cho thấy hai kênh:
- openExternal để điều hướng rõ ràng;
- fetch cho một API nhỏ, công khai, nằm cạnh App của bạn.
9. Lỗi thường gặp khi làm việc với window.fetch và openExternal
Lỗi số 1: cố dùng widget như client SPA đầy đủ cho tất cả API của bạn.
Thói quen cũ dễ kéo ta theo hướng “hãy gọi REST/GraphQL trực tiếp từ React”. Trong ChatGPT Apps, điều này dẫn tới va chạm trực diện với sandbox: một phần request sẽ không qua nổi, một phần bị chặn bởi policy, còn bảo mật của dự án thì bị đặt dấu hỏi. Logic phức tạp và quyền truy cập dữ liệu người dùng phải đi qua MCP/công cụ, chứ không trực tiếp từ widget.
Lỗi số 2: lưu secret và token trong mã của widget.
Đôi khi muốn “prototype nhanh” và nhét API‑key của dịch vụ nào đó vào mã frontend (“tôi chỉ đang test”). Đây là ý tưởng tồi ngay cả với SPA thường, còn với ChatGPT Apps — là điều cấm kỵ. Widget là môi trường công khai; secret phải nằm ở cấu hình server hoặc hệ thống quản lý secret (Vercel env, KMS, v.v.).
Lỗi số 3: cho rằng fetch tới bất kỳ domain nào “cũng sẽ chạy”.
Ngay cả khi ở Dev Mode một request nào đó chạy (ví dụ do tunnel được cấu hình bất thường), ở production nó gần như chắc chắn sẽ hỏng: ChatGPT giới hạn request đi ra, và domain bên ngoài tùy ý là không khả dụng cho widget. Hãy mặc định rằng widget chỉ có thể gọi đáng tin cậy tới domain của chính nó và một danh sách trắng rất nhỏ các tài nguyên được cho phép rõ ràng.
Lỗi số 4: dùng window.open thay cho openExternal.
Về mặt kỹ thuật, đôi lúc window.open có thể chạy, nhất là trong trình duyệt preview, tạo ảo giác “mọi thứ ổn”. Nhưng trong môi trường ChatGPT thực, đặc biệt trên client native, hành vi sẽ khó lường. Người dùng có thể không thấy chuyển hướng hoặc gặp lỗi kỳ lạ. Cách đúng là dùng openExternal (qua hook useOpenExternal), vốn biết cách mở liên kết phù hợp với môi trường hiện tại.
Lỗi số 5: không xử lý lỗi fetch và không hiển thị trạng thái tải.
Trong sandbox, lỗi mạng không phải ngoại lệ mà là chuyện thường: tunnel có thể rớt, domain có thể đổi, policy có thể chặn một phần. Nếu bạn chỉ await fetch(...) rồi render UI như thể dữ liệu luôn có — bạn sẽ có một giao diện “lúc được lúc không”. Luôn đặt try/catch, kiểm tra res.ok, hiển thị “Đang tải…” và thông báo lỗi gọn gàng.
Lỗi số 6: biến openExternal thành chuyển hướng ẩn.
Đôi khi có xu hướng, khi nhấn bất kỳ nút nào cũng lập tức đưa người dùng ra website bên ngoài, đặc biệt là tới checkout, mà không có ngữ cảnh trong văn bản. Điều này vừa lạ với người dùng, vừa không tốt với người duyệt trên Store. Thực hành tốt là viết rõ điều sắp xảy ra: hoặc mô hình GPT nói “Tôi sẽ mở trang của cửa hàng…”, hoặc chính nút có nhãn đủ minh bạch (“Đi tới thanh toán trên website của cửa hàng”).
Lỗi số 7: quên rằng widget không phải “chủ” duy nhất của hội thoại.
Nếu UI của bạn cố ép người dùng vào một kịch bản phức tạp với hàng loạt liên kết và request mạng, bỏ qua bản thân cuộc trò chuyện và các follow‑up, kết quả sẽ là UX kém và chất lượng hoạt động của mô hình tệ đi. Hãy nhớ kiến trúc: GPT quyết định khi nào hiển thị App, cách dùng kết quả của nó; widget chỉ gợi ý và trực quan hóa. Điều hướng và lời gọi mạng cần được thiết kế sao cho hòa vào cuộc đối thoại chung, chứ không kéo hết sự chú ý về phía mình.
GO TO FULL VERSION