# Widget Templates Minimal HTML scaffolds for the common widget shapes. Copy, fill in, ship. All templates inline the `App` class from `@modelcontextprotocol/ext-apps` at build time — the iframe's CSP blocks CDN script imports. They're intentionally framework-free; widgets are small enough that React/Vue hydration cost usually isn't worth it. --- ## Serving widget HTML Widgets are static HTML with one placeholder: `/*__EXT_APPS_BUNDLE__*/` gets replaced at server startup with the `ext-apps/app-with-deps` bundle (rewritten to expose `globalThis.ExtApps`). ```typescript import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { registerAppResource, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; const require = createRequire(import.meta.url); const bundle = readFileSync( require.resolve("@modelcontextprotocol/ext-apps/app-with-deps"), "utf8", ).replace(/export\{([^}]+)\};?\s*$/, (_, body) => "globalThis.ExtApps={" + body.split(",").map((p) => { const [local, exported] = p.split(" as ").map((s) => s.trim()); return `${exported ?? local}:${local}`; }).join(",") + "};", ); const pickerHtml = readFileSync("./widgets/picker.html", "utf8") .replace("/*__EXT_APPS_BUNDLE__*/", () => bundle); registerAppResource(server, "Picker", "ui://widgets/picker.html", {}, async () => ({ contents: [{ uri: "ui://widgets/picker.html", mimeType: RESOURCE_MIME_TYPE, text: pickerHtml }], }), ); ``` Bundle once per server startup (or at build time); reuse the `bundle` string across all widget templates. --- ## Picker (single-select list) ```html
Starting…
``` Server side, emit progress via `extra.sendNotification({ method: "notifications/progress", ... })` — see `apps-sdk-messages.md`. --- ## Display-only (chart / preview) Display widgets don't call `sendMessage` — they render and sit there. The tool should return a text summary **alongside** the widget so Claude can keep reasoning while the user sees the visual: ```typescript registerAppTool(server, "show_chart", { description: "Render a revenue chart", inputSchema: { range: z.enum(["week", "month", "year"]) }, _meta: { ui: { resourceUri: "ui://widgets/chart.html" } }, }, async ({ range }) => { const data = await fetchRevenue(range); return { content: [{ type: "text", text: `Revenue is up ${data.change}% over the ${range}. Chart rendered.\n\n` + JSON.stringify(data.points), }], }; }); ``` ```html ``` --- ## Carousel (multi-item display with actions) For presenting multiple items (product picks, search results) in a horizontal scroll rail. Patterns that tested well: - **Skip nav chevrons** — users know how to scroll. `scroll-snap-type` can cause a few-px-off-flush initial render; omit it and `scrollLeft = 0` after rendering. - **Layout-fork by item count** — `items.length === 1` → detail/PDP layout, `> 1` → carousel. Handle in widget JS, keep the tool schema flat. - **Put Claude's reasoning in each item** — a `note` field rendered as a small callout on the card gives users the "why" inline. - **Silent state via `updateModelContext`** — cart/selection changes should inform Claude without spamming the chat. Reserve `sendMessage` for terminal actions ("checkout", "done"). - **Outbound links via `app.openLink`** — `window.open` and `` are blocked by the sandbox. ```html ``` **Images:** the iframe CSP blocks remote `img-src`. Fetch thumbnails server-side in the tool handler, embed as `data:` URLs in the JSON payload, and render from those. Add `referrerpolicy="no-referrer"` as a fallback.