mirror of
https://github.com/anthropics/claude-plugins-official.git
synced 2026-03-21 11:53:08 +00:00
add(plugin): mcp-server-dev — skills for building MCP servers (#731)
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# ext-apps messaging — widget ↔ host ↔ server
|
||||
|
||||
The `@modelcontextprotocol/ext-apps` package provides the `App` class (browser side) and `registerAppTool`/`registerAppResource` helpers (server side). Messaging is bidirectional and persistent.
|
||||
|
||||
---
|
||||
|
||||
## Widget → Host
|
||||
|
||||
### `app.sendMessage({ role, content })`
|
||||
|
||||
Inject a visible message into the conversation. This is how user actions become conversation turns.
|
||||
|
||||
```js
|
||||
app.sendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "User selected order #1234" }],
|
||||
});
|
||||
```
|
||||
|
||||
The message appears in chat and Claude responds to it. Use `role: "user"` — the widget speaks on the user's behalf.
|
||||
|
||||
### `app.updateModelContext({ content })`
|
||||
|
||||
Update Claude's context **silently** — no visible message. Use for state that informs but doesn't warrant a chat bubble.
|
||||
|
||||
```js
|
||||
app.updateModelContext({
|
||||
content: [{ type: "text", text: "Currently viewing: orders from last 30 days" }],
|
||||
});
|
||||
```
|
||||
|
||||
### `app.callServerTool({ name, arguments })`
|
||||
|
||||
Call a tool on your MCP server directly, bypassing Claude. Returns the tool result.
|
||||
|
||||
```js
|
||||
const result = await app.callServerTool({
|
||||
name: "fetch_order_details",
|
||||
arguments: { orderId: "1234" },
|
||||
});
|
||||
```
|
||||
|
||||
Use for data fetches that don't need Claude's reasoning — pagination, detail lookups, refreshes.
|
||||
|
||||
### `app.openLink({ url })`
|
||||
|
||||
Open a URL in a new browser tab, host-mediated. **Required** for any outbound navigation — the iframe sandbox blocks `window.open()` and `<a target="_blank">`.
|
||||
|
||||
```js
|
||||
await app.openLink({ url: "https://example.com/cart" });
|
||||
```
|
||||
|
||||
For anchors in rendered HTML, intercept the click:
|
||||
|
||||
```js
|
||||
card.querySelector("a").addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
app.openLink({ url: e.currentTarget.href });
|
||||
});
|
||||
```
|
||||
|
||||
### `app.downloadFile({ name, mimeType, content })`
|
||||
|
||||
Host-mediated download (sandbox blocks direct `<a download>`). `content` is a base64 string.
|
||||
|
||||
---
|
||||
|
||||
## Host → Widget
|
||||
|
||||
### `app.ontoolresult = ({ content }) => {...}`
|
||||
|
||||
Fires when the tool handler's return value is piped to the widget. This is the primary data-in path.
|
||||
|
||||
```js
|
||||
app.ontoolresult = ({ content }) => {
|
||||
const data = JSON.parse(content[0].text);
|
||||
renderUI(data);
|
||||
};
|
||||
```
|
||||
|
||||
**Set this BEFORE `await app.connect()`** — the result may arrive immediately after connection.
|
||||
|
||||
### `app.ontoolinput = ({ arguments }) => {...}`
|
||||
|
||||
Fires with the arguments Claude passed to the tool. Useful if the widget needs to know what was asked for (e.g., highlight the search term).
|
||||
|
||||
### `app.getHostContext()` / `app.onhostcontextchanged = (ctx) => {...}`
|
||||
|
||||
Read and subscribe to host context — `theme` (`"light"` / `"dark"`), locale, etc. Call `getHostContext()` **after** `connect()`. Subscribe for live updates (user toggles dark mode mid-conversation).
|
||||
|
||||
```js
|
||||
const applyTheme = (t) =>
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
|
||||
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);
|
||||
await app.connect();
|
||||
applyTheme(app.getHostContext()?.theme);
|
||||
```
|
||||
|
||||
Keep colors in CSS custom props with a `:root.dark {}` override block and set `color-scheme: light | dark` so native form controls follow.
|
||||
|
||||
---
|
||||
|
||||
## Server → Widget (progress)
|
||||
|
||||
For long-running operations, emit progress notifications. The client sends a `progressToken` in the request's `_meta`; the server emits against it.
|
||||
|
||||
```typescript
|
||||
// In the tool handler
|
||||
async ({ query }, extra) => {
|
||||
const token = extra._meta?.progressToken;
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (token !== undefined) {
|
||||
await extra.sendNotification({
|
||||
method: "notifications/progress",
|
||||
params: { progressToken: token, progress: i, total: steps.length, message: steps[i].name },
|
||||
});
|
||||
}
|
||||
await steps[i].run();
|
||||
}
|
||||
return { content: [{ type: "text", text: "Complete" }] };
|
||||
}
|
||||
```
|
||||
|
||||
No `{ notify }` destructure — `extra` is `RequestHandlerExtra`; progress goes through `sendNotification`.
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Claude calls a tool with `_meta.ui.resourceUri` declared
|
||||
2. Host fetches the resource (your HTML) and renders it in an iframe
|
||||
3. Widget script runs, sets handlers, calls `await app.connect()`
|
||||
4. Host pipes the tool's return value → `ontoolresult` fires
|
||||
5. Widget renders, user interacts
|
||||
6. Widget calls `sendMessage` / `updateModelContext` / `callServerTool` as needed
|
||||
7. Widget persists until conversation context moves on — subsequent calls to the same tool reuse the iframe and fire `ontoolresult` again
|
||||
|
||||
There's no explicit "submit and close" — the widget is a long-lived surface.
|
||||
|
||||
---
|
||||
|
||||
## Sandbox & CSP gotchas
|
||||
|
||||
The iframe runs under both an HTML `sandbox` attribute **and** a restrictive Content-Security-Policy. The practical effect is that almost nothing external is allowed — widgets should be self-contained.
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Widget is a blank rectangle, nothing renders | CDN `import` of ext-apps blocked (transitive SDK fetches) | **Inline** the `ext-apps/app-with-deps` bundle — see `iframe-sandbox.md` |
|
||||
| Widget renders but JS doesn't run | Inline event handlers blocked | Use `addEventListener` — never `onclick="..."` in HTML |
|
||||
| `eval` / `new Function` errors | Script-src restriction | Don't use them; use JSON.parse for data |
|
||||
| `fetch()` to your API fails | Cross-origin blocked | Route through `app.callServerTool()` instead |
|
||||
| External CSS doesn't load | `style-src` restriction | Inline styles in a `<style>` tag |
|
||||
| Fonts don't load | `font-src` restriction | Use system fonts (`font: 14px system-ui`) |
|
||||
| External `<img src>` broken | CSP `img-src` + referrer hotlink blocking | Fetch server-side, inline as `data:` URL in the tool result payload |
|
||||
| `window.open()` does nothing | Sandbox lacks `allow-popups` | Use `app.openLink({url})` |
|
||||
| `<a target="_blank">` does nothing | Same | Intercept click → `preventDefault()` → `app.openLink` |
|
||||
| Edited HTML doesn't appear in Desktop | Desktop caches UI resources | Fully quit (⌘Q) + relaunch, not just window-close |
|
||||
|
||||
When in doubt, open the **iframe's own** devtools console (not the main app's) — CSP violations log there. See `iframe-sandbox.md` for the bundle-inlining pattern.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Iframe sandbox constraints
|
||||
|
||||
MCP-app widgets run inside a sandboxed `<iframe>` in the host (Claude Desktop,
|
||||
claude.ai). The sandbox and CSP attributes lock down what the widget can do.
|
||||
Every item below was observed failing with a silent blank iframe until the
|
||||
fix was applied — the error only appears in the iframe's own devtools console,
|
||||
not the host's.
|
||||
|
||||
---
|
||||
|
||||
## Problem → fix table
|
||||
|
||||
| Symptom | Root cause | Fix |
|
||||
|---|---|---|
|
||||
| Widget renders as blank rectangle, no error | CSP `script-src` blocks esm.sh fetching transitive `@modelcontextprotocol/sdk` deps | Inline the `ext-apps/app-with-deps` bundle into the HTML |
|
||||
| `window.open()` does nothing | Sandbox lacks `allow-popups` | Use `app.openLink({ url })` |
|
||||
| `<a target="_blank">` does nothing | Same | `e.preventDefault()` + `app.openLink({ url })` on click |
|
||||
| External `<img src>` broken | CSP `img-src` + referrer hotlink blocking | Fetch server-side, ship as `data:` URL in the tool result payload |
|
||||
| Widget edits don't appear after server restart | Host caches UI resources | Fully quit the host (⌘Q / Alt+F4) and relaunch |
|
||||
| Top-level `await` throws | Older iframe contexts | Wrap module body in an async IIFE |
|
||||
|
||||
---
|
||||
|
||||
## Inlining the ext-apps bundle
|
||||
|
||||
`@modelcontextprotocol/ext-apps` ships a self-contained browser build at the
|
||||
`app-with-deps` export (~300KB). It's minified ESM ending in `export{…}`; to
|
||||
use it from an inline `<script type="module">` block, rewrite the export
|
||||
statement into a global assignment at build time:
|
||||
|
||||
```ts
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
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((pair) => {
|
||||
const [local, exported] = pair.split(" as ").map((s) => s.trim());
|
||||
return `${exported ?? local}:${local}`;
|
||||
}).join(",") + "};",
|
||||
);
|
||||
|
||||
const widgetHtml = readFileSync("./widgets/widget.html", "utf8")
|
||||
.replace("/*__EXT_APPS_BUNDLE__*/", () => bundle);
|
||||
```
|
||||
|
||||
Widget side:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
/*__EXT_APPS_BUNDLE__*/
|
||||
const { App } = globalThis.ExtApps;
|
||||
(async () => {
|
||||
const app = new App({ name: "…", version: "…" }, {});
|
||||
// …
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
The `() => bundle` replacer form (rather than a bare string) is important —
|
||||
`String.replace` interprets `$…` sequences in a string replacement, and the
|
||||
minified bundle is full of them.
|
||||
|
||||
---
|
||||
|
||||
## Outbound links
|
||||
|
||||
```js
|
||||
// ✗ blocked
|
||||
window.open(url, "_blank");
|
||||
// ✗ blocked
|
||||
<a href="…" target="_blank">…</a>
|
||||
|
||||
// ✓ host-mediated
|
||||
await app.openLink({ url });
|
||||
```
|
||||
|
||||
Intercept anchor clicks:
|
||||
|
||||
```js
|
||||
el.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
app.openLink({ url: el.href });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## External images
|
||||
|
||||
CSP `img-src` defaults (plus many CDN referrer policies) block
|
||||
`<img src="https://external-cdn/…">` from loading. Inline them server-side in
|
||||
the tool handler:
|
||||
|
||||
```ts
|
||||
async function toDataUrl(url: string): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return undefined;
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const mime = res.headers.get("content-type") ?? "image/jpeg";
|
||||
return `data:${mime};base64,${buf.toString("base64")}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// in the tool handler
|
||||
const inlined = await Promise.all(
|
||||
items.map(async (it) =>
|
||||
it.thumb ? { ...it, thumb: await toDataUrl(it.thumb) ?? it.thumb } : it,
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
Add `referrerpolicy="no-referrer"` on the `<img>` as a fallback for any URL
|
||||
that survives un-inlined.
|
||||
|
||||
---
|
||||
|
||||
## Dark mode
|
||||
|
||||
```js
|
||||
const applyTheme = (theme) =>
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
|
||||
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);
|
||||
await app.connect();
|
||||
applyTheme(app.getHostContext()?.theme);
|
||||
```
|
||||
|
||||
```css
|
||||
:root { --ink:#0f1111; --bg:#fff; color-scheme:light; }
|
||||
:root.dark { --ink:#e6e6e6; --bg:#1f2428; color-scheme:dark; }
|
||||
:root.dark .thumb { mix-blend-mode: normal; } /* multiply → images vanish in dark */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
The iframe has its own console. In Claude Desktop, open DevTools (View → Toggle
|
||||
Developer Tools), then switch the context dropdown (top-left of the Console
|
||||
tab) from "top" to the widget's iframe. CSP violations, uncaught exceptions,
|
||||
and import errors all surface there — the host's main console stays silent.
|
||||
@@ -0,0 +1,249 @@
|
||||
# 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
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 0; }
|
||||
ul { list-style: none; padding: 0; margin: 0; max-height: 280px; overflow-y: auto; }
|
||||
li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eee; }
|
||||
li:hover { background: #f5f5f5; }
|
||||
.sub { color: #666; font-size: 12px; }
|
||||
</style>
|
||||
<ul id="list"></ul>
|
||||
<script type="module">
|
||||
/*__EXT_APPS_BUNDLE__*/
|
||||
const { App } = globalThis.ExtApps;
|
||||
(async () => {
|
||||
const app = new App({ name: "Picker", version: "1.0.0" }, {});
|
||||
const ul = document.getElementById("list");
|
||||
|
||||
app.ontoolresult = ({ content }) => {
|
||||
const { items } = JSON.parse(content[0].text);
|
||||
ul.innerHTML = "";
|
||||
for (const it of items) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<div>${it.label}</div><div class="sub">${it.sub ?? ""}</div>`;
|
||||
li.addEventListener("click", () => {
|
||||
app.sendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `Selected: ${it.id}` }],
|
||||
});
|
||||
});
|
||||
ul.append(li);
|
||||
}
|
||||
};
|
||||
|
||||
await app.connect();
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
**Tool returns:** `{ content: [{ type: "text", text: JSON.stringify({ items: [{ id, label, sub? }] }) }] }`
|
||||
|
||||
---
|
||||
|
||||
## Confirm dialog
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 16px; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
button { padding: 8px 16px; cursor: pointer; }
|
||||
.danger { background: #d33; color: white; border: none; }
|
||||
</style>
|
||||
<p id="msg"></p>
|
||||
<div class="actions">
|
||||
<button id="cancel">Cancel</button>
|
||||
<button id="confirm" class="danger">Confirm</button>
|
||||
</div>
|
||||
<script type="module">
|
||||
/*__EXT_APPS_BUNDLE__*/
|
||||
const { App } = globalThis.ExtApps;
|
||||
(async () => {
|
||||
const app = new App({ name: "Confirm", version: "1.0.0" }, {});
|
||||
|
||||
app.ontoolresult = ({ content }) => {
|
||||
const { message, confirmLabel } = JSON.parse(content[0].text);
|
||||
document.getElementById("msg").textContent = message;
|
||||
if (confirmLabel) document.getElementById("confirm").textContent = confirmLabel;
|
||||
};
|
||||
|
||||
await app.connect();
|
||||
|
||||
document.getElementById("confirm").addEventListener("click", () => {
|
||||
app.sendMessage({ role: "user", content: [{ type: "text", text: "Confirmed." }] });
|
||||
});
|
||||
document.getElementById("cancel").addEventListener("click", () => {
|
||||
app.sendMessage({ role: "user", content: [{ type: "text", text: "Cancelled." }] });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
**Tool returns:** `{ content: [{ type: "text", text: JSON.stringify({ message, confirmLabel? }) }] }`
|
||||
|
||||
**Note:** For simple confirmation, prefer **elicitation** over a widget — see `../build-mcp-server/references/elicitation.md`. Use this widget when you need custom styling or context beyond what a native form offers.
|
||||
|
||||
---
|
||||
|
||||
## Progress (long-running)
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body { font: 14px system-ui; margin: 16px; }
|
||||
.bar { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; }
|
||||
.fill { height: 100%; background: #2a7; transition: width 200ms; }
|
||||
</style>
|
||||
<p id="label">Starting…</p>
|
||||
<div class="bar"><div id="fill" class="fill" style="width:0%"></div></div>
|
||||
<script type="module">
|
||||
/*__EXT_APPS_BUNDLE__*/
|
||||
const { App } = globalThis.ExtApps;
|
||||
(async () => {
|
||||
const app = new App({ name: "Progress", version: "1.0.0" }, {});
|
||||
const label = document.getElementById("label");
|
||||
const fill = document.getElementById("fill");
|
||||
|
||||
// The tool result fires when the job completes — intermediate updates
|
||||
// arrive via the same handler if the server streams them
|
||||
app.ontoolresult = ({ content }) => {
|
||||
const state = JSON.parse(content[0].text);
|
||||
if (state.progress !== undefined) {
|
||||
label.textContent = state.message ?? `${state.progress}/${state.total}`;
|
||||
fill.style.width = `${(state.progress / state.total) * 100}%`;
|
||||
}
|
||||
if (state.done) {
|
||||
label.textContent = "Complete";
|
||||
fill.style.width = "100%";
|
||||
}
|
||||
};
|
||||
|
||||
await app.connect();
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
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
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<style>body { font: 14px system-ui; margin: 12px; }</style>
|
||||
<canvas id="chart" width="400" height="200"></canvas>
|
||||
<script type="module">
|
||||
/*__EXT_APPS_BUNDLE__*/
|
||||
const { App } = globalThis.ExtApps;
|
||||
(async () => {
|
||||
const app = new App({ name: "Chart", version: "1.0.0" }, {});
|
||||
|
||||
app.ontoolresult = ({ content }) => {
|
||||
// Parse the JSON points from the text content (after the summary line)
|
||||
const text = content[0].text;
|
||||
const jsonStart = text.indexOf("\n\n") + 2;
|
||||
const points = JSON.parse(text.slice(jsonStart));
|
||||
drawChart(document.getElementById("chart"), points);
|
||||
};
|
||||
|
||||
await app.connect();
|
||||
|
||||
function drawChart(canvas, points) { /* ... */ }
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 `<a target="_blank">` are blocked by the sandbox.
|
||||
|
||||
```html
|
||||
<style>
|
||||
.rail { display: flex; gap: 10px; overflow-x: auto; padding: 12px; scrollbar-width: none; }
|
||||
.rail::-webkit-scrollbar { display: none; }
|
||||
.card { flex: 0 0 220px; border: 1px solid #ddd; border-radius: 6px; padding: 10px; }
|
||||
.thumb-box { aspect-ratio: 1 / 1; display: grid; place-items: center; background: #f7f8f8; }
|
||||
.thumb { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.note { font-size: 12px; color: #666; border-left: 3px solid orange; padding: 2px 8px; margin: 8px 0; }
|
||||
</style>
|
||||
<div class="rail" id="rail"></div>
|
||||
```
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user