diff --git a/internal/mcpserver/widget/app.html b/internal/mcpserver/widget/app.html
index 644cc9d..6081f53 100644
--- a/internal/mcpserver/widget/app.html
+++ b/internal/mcpserver/widget/app.html
@@ -520,7 +520,7 @@
}
if (view.sourceURL) {
const link = el("button", "linkbtn", domainOf(view.sourceURL) || "source");
- link.onclick = () => bridge.request("ui/open-link", { url: view.sourceURL });
+ link.onclick = () => openLink(view.sourceURL);
actions.appendChild(link);
}
headings.appendChild(actions);
@@ -578,6 +578,12 @@
* Tool calls initiated from the widget
* ------------------------------------------------------------------ */
async function callTool(name, args) {
+ const oa = openAIHost();
+ if (oa && typeof oa.callTool === "function") {
+ // ChatGPT Apps runtime: returns { structuredContent, content }.
+ const next = await oa.callTool(name, args);
+ return (next && next.structuredContent) || {};
+ }
const result = await bridge.request("tools/call", { name, arguments: args });
if (result && result.isError) {
const text = (result.content || []).map((c) => c.text).filter(Boolean).join(" ");
@@ -586,6 +592,17 @@
return (result && result.structuredContent) || {};
}
+// openLink sends the user to an external URL via whichever host bridge is active.
+function openLink(url) {
+ if (!url) return;
+ const oa = openAIHost();
+ if (oa && typeof oa.openExternal === "function") {
+ try { oa.openExternal({ href: url }); return; } catch (e) { /* fall through */ }
+ try { oa.openExternal(url); return; } catch (e) { /* fall through */ }
+ }
+ bridge.request("ui/open-link", { url }).catch(() => {});
+}
+
async function openPreview(url) {
if (!url) return;
skeleton();
@@ -625,6 +642,26 @@
}
}
+// openAIHost returns ChatGPT's Apps runtime object when the widget is embedded in
+// ChatGPT, else null. ChatGPT delivers the tool's structured output via
+// window.openai (globals + the "openai:set_globals" event), NOT the MCP Apps
+// postMessage bridge — so without this the widget would sit on its loading note
+// in ChatGPT forever.
+function openAIHost() {
+ return (typeof window !== "undefined" && window.openai) ? window.openai : null;
+}
+
+// applyOpenAIGlobals renders from the ChatGPT runtime globals: theme + the tool's
+// structured output, which is exactly the shape render() expects.
+function applyOpenAIGlobals(globals) {
+ if (!globals) return;
+ if (globals.theme === "dark" || globals.theme === "light") {
+ document.documentElement.setAttribute("data-theme", globals.theme);
+ }
+ if (globals.toolOutput) render(globals.toolOutput);
+}
+
+// MCP Apps postMessage host (Claude and other ext-apps hosts).
bridge.on("ui/notifications/tool-result", (params) => {
if (params && params.structuredContent) render(params.structuredContent);
});
@@ -633,6 +670,11 @@
applyHostContext(params && params.hostContext);
});
+// ChatGPT Apps runtime host: tool output + theme arrive via window.openai globals.
+window.addEventListener("openai:set_globals", (event) => {
+ applyOpenAIGlobals(event && event.detail && event.detail.globals);
+});
+
const resizeObserver = new ResizeObserver(() => {
bridge.notify("ui/notifications/size-changed", {
width: document.documentElement.scrollWidth,
@@ -640,19 +682,23 @@
});
});
-(async function main() {
- try {
- const init = await bridge.request("ui/initialize", {
- protocolVersion: "2026-01-26",
- clientInfo: { name: "saltybytes-widget", version: "1.0.0" },
- capabilities: {},
- });
+(function main() {
+ // ChatGPT: window.openai is present at mount — render immediately from whatever
+ // is already set, then let "openai:set_globals" drive updates.
+ const oa = openAIHost();
+ if (oa) applyOpenAIGlobals(oa);
+ // MCP Apps host (Claude, etc.): run the postMessage handshake, but NON-BLOCKING
+ // so a host that never answers ui/initialize (ChatGPT) can't leave the widget
+ // stuck on its loading note.
+ bridge.request("ui/initialize", {
+ protocolVersion: "2026-01-26",
+ clientInfo: { name: "saltybytes-widget", version: "1.0.0" },
+ capabilities: {},
+ }).then((init) => {
applyHostContext(init && init.hostContext);
bridge.notify("ui/notifications/initialized");
- resizeObserver.observe(document.body);
- } catch {
- /* Host without MCP Apps support: leave the static note visible. */
- }
+ }).catch(() => { /* not an MCP Apps postMessage host */ });
+ resizeObserver.observe(document.body);
})();