From 7be4398dd41630fdad1eae8508060bd331257afe Mon Sep 17 00:00:00 2001 From: Den Delimarsky Date: Mon, 23 Mar 2026 23:22:42 +0000 Subject: [PATCH 1/2] docs: fill gaps surfaced by developer feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds guidance for recurring questions and pitfalls that came up while building Apps against the SDK: patterns.md: - Model vs App data visibility (content/structuredContent/_meta split) - Controlling App height, including the autoResize + 100vh feedback loop - Touch device support (touch-action, horizontal overflow) - localStorage key namespacing across shared sandbox origin - Sharing a single ui:// resource across multiple tools - Conditionally showing UI (two-tool workaround) - Opening external links via app.openLink - color-scheme CSS gotcha that breaks iframe transparency overview.md: - Resource versioning/caching note — template and data may be from different code versions New pages: - design-guidelines.md - troubleshooting.md typedoc.config.mjs wired up to include the two new pages. --- docs/design-guidelines.md | 61 ++++++++++++++++ docs/overview.md | 2 + docs/patterns.md | 145 ++++++++++++++++++++++++++++++++++++++ docs/troubleshooting.md | 56 +++++++++++++++ typedoc.config.mjs | 2 + 5 files changed, 266 insertions(+) create mode 100644 docs/design-guidelines.md create mode 100644 docs/troubleshooting.md diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md new file mode 100644 index 00000000..2c2a92c4 --- /dev/null +++ b/docs/design-guidelines.md @@ -0,0 +1,61 @@ +--- +title: Design Guidelines +group: Getting Started +description: UX guidance for MCP Apps — what the host already provides, how to size your content, and how to stay visually consistent with the surrounding chat. +--- + +# Design Guidelines + +MCP Apps live inside a conversation. They should feel like a natural part of the chat, not a separate application wedged into it. + +## The host provides the chrome + +Hosts typically render a frame around your App that includes: + +- A **title bar** showing your App's name (from the tool or server metadata) +- **Display-mode controls** (expand to fullscreen, collapse, close) +- **Attribution** (which connector/server the App came from) + +**Don't duplicate these.** Your App doesn't need its own close button, title header, or "powered by" footer. Start your layout with the actual content. + +If you need a title _inside_ your content (e.g., "Q3 Revenue by Region" above a chart), that's fine — just don't put your App's brand name there. + +## Keep it focused + +An MCP App answers one question or supports one task. Resist the urge to build a full dashboard with tabs, sidebars, and settings panels. + +Good heuristics: + +- **Inline mode should fit in roughly one screen of scroll.** If your content is much taller than the chat viewport, consider whether it belongs in fullscreen mode — or whether you're showing too much. +- **One primary action at most.** A "Confirm" button is fine. A toolbar with eight icons is probably too much for inline mode. +- **Let the conversation drive navigation.** Instead of building a search box inside your App, let the user ask a follow-up question and re-invoke the tool with new arguments. + +## Don't replicate the host's UI + +Your App must not look like the surrounding chat client. Specifically, avoid: + +- Rendering fake chat bubbles or message threads +- Mimicking the host's input box or send button +- Showing fake system notifications or permission dialogs + +These patterns confuse users about what's real host UI versus App content, and most hosts prohibit them in their submission guidelines. + +## Use host styling where possible + +Hosts provide CSS custom properties for colors, fonts, spacing, and border radius (see [Adapting to host context](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas)). Using them makes your App feel native across light mode, dark mode, and different host themes. + +You can bring your own brand colors for content (chart series, status badges), but let the host's variables drive backgrounds, text, and borders. Always provide fallback values so your App still renders reasonably on hosts that don't supply every variable. + +## Inline vs fullscreen layout + +Design for **inline first** — that's where your App appears by default. Inline mode is narrow (often the width of a chat message) and height-constrained. + +Treat **fullscreen** as a progressive enhancement for Apps that benefit from more space (editors, maps, large datasets). Check `hostContext.availableDisplayModes` before showing a fullscreen toggle — not every host supports it. + +When switching modes, remember to adjust your layout: remove border radius at the edges, expand to fill the viewport, and re-read `containerDimensions` from the updated host context. + +## Handle the empty and loading states + +Your App mounts before the tool result arrives. Between `ui/initialize` and `ontoolresult`, show something — a skeleton, a spinner, or at minimum a neutral background. A blank white rectangle looks broken. + +Similarly, if your tool result can be empty (no search results, no items in cart), design a clear empty state rather than rendering nothing. diff --git a/docs/overview.md b/docs/overview.md index 28f5b237..8b090f01 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -110,6 +110,8 @@ Resources are declared upfront, during tool registration. This design enables: - **Separation of concerns** — Templates (presentation) are separate from tool results (data) - **Review** — Hosts can inspect UI templates during connection setup +**Versioning and caching.** Resource caching behavior is host-defined. A host may re-fetch your `ui://` resource each time it renders, cache it for the session, or persist it alongside the conversation. This means a user revisiting an old conversation may see either your _current_ template code running against the _original_ tool result, or a snapshot of both from when the tool first ran. Design your App to tolerate older `structuredContent` shapes — treat unknown fields gracefully and don't assume the template and the data were produced by the same code version. + See the [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#ui-resource-format) section of the specification for the full schema. ## Tool-UI Linkage diff --git a/docs/patterns.md b/docs/patterns.md index 87a6d823..d9365bde 100644 --- a/docs/patterns.md +++ b/docs/patterns.md @@ -37,6 +37,35 @@ registerAppTool( > [!NOTE] > For full examples that implement this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) and [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server). +## What the model sees vs what the App sees + +A tool result has three places to put data, each with different visibility: + +| Field | Seen by model | Seen by App | Use for | +| ------------------- | ------------- | ----------- | -------------------------------------------------------------------------- | +| `content` | ✅ | ✅ | Short text summary the model can reason about and text-only hosts can show | +| `structuredContent` | ❌ | ✅ | Structured data the App renders (tables, charts, lists) | +| `_meta` | ❌ | ✅ | Opaque metadata (IDs, timestamps, view identifiers) | + +Keep `content` brief — a one-line summary is usually enough. The model uses it to decide what to say next, so avoid dumping raw data there. + +> [!WARNING] +> **Don't put large payloads in tool results.** Base64-encoded audio, images, or file contents should be served via MCP resources (see [Serving binary blobs via resources](#serving-binary-blobs-via-resources)) or fetched by the App over the network, not returned inline in `structuredContent`. Even though `structuredContent` is not added to the model's context by spec, large tool results slow down transport, inflate conversation storage, and some host implementations may include more of the result than you expect. + +**Write `content` for the model, not the user.** The user is looking at your App, not reading the `content` text. A good `content` string tells the model what just happened so it can respond naturally without repeating what's already on screen: + +```ts +return { + content: [ + { + type: "text", + text: "Rendered an interactive chart of Q3 revenue by region. The user can see and interact with it directly — do not describe the chart contents in your response.", + }, + ], + structuredContent: { regions, revenue, quarter: "Q3" }, +}; +``` + ## Polling for live data For real-time dashboards or monitoring views, use an app-only tool (with `visibility: ["app"]`) that the App polls at regular intervals. @@ -402,6 +431,29 @@ function MyApp() { > [!NOTE] > For full examples that implement this pattern, see: [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react). +> [!TIP] +> **Avoid the `color-scheme` CSS property on your root element.** If your App declares `color-scheme: light dark` but the host's document doesn't, browsers insert an opaque backdrop behind the iframe to prevent cross-scheme bleed-through — which breaks transparent backgrounds. Prefer the `[data-theme]` attribute approach shown above and let the host control scheme negotiation. + +## Supporting touch devices + +Apps that handle pointer gestures (pan, drag, pinch) need to prevent those gestures from also scrolling the surrounding chat. Use [`touch-action`](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) on interactive surfaces: + +```css +/* Chart/canvas that handles its own panning */ +.chart-surface { + touch-action: none; +} + +/* Horizontal slider that shouldn't trigger vertical page scroll */ +.slider-track { + touch-action: pan-y; /* allow vertical scroll, consume horizontal */ +} +``` + +Without this, a user dragging across your chart on mobile will also scroll the chat, and your App may never receive the `pointermove` events. + +Also make sure your layout doesn't overflow horizontally — set `overflow-x: hidden` on the root container if you have any fixed-width elements. Horizontal overflow on mobile causes the entire App to wobble when scrolled. + ## Entering / exiting fullscreen Toggle fullscreen mode by calling {@link app!App.requestDisplayMode `requestDisplayMode`}: @@ -453,6 +505,39 @@ In fullscreen mode, remove the container's border radius so content extends to t > [!NOTE] > For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server), [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server), and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server). +## Controlling App height + +By default, the SDK observes your document's content height and reports it to the host so the iframe grows to fit (`autoResize: true`). This works well for content-driven UI like cards, tables, and forms — but it's the wrong choice for viewport-filling UI like canvases, maps, and editors. + +Pick one of three strategies: + +**1. Auto-resize (default)** — for content that has a natural height. Let the iframe grow to fit. Don't set `height: 100vh` or `height: 100%` on your root element, or you'll create a feedback loop where the reported height keeps growing. + +**2. Fixed height** — for UI that should always be the same size inline. Disable auto-resize and set an explicit height: + +```ts +const app = new App( + { name: "my-app", version: "0.1.0" }, + {}, + { autoResize: false }, +); +``` + +```css +html, +body { + height: 500px; + margin: 0; +} +``` + +**3. Host-driven height** — for UI that should fill whatever space the host gives it (common for fullscreen-capable Apps). Disable auto-resize and read the host-provided dimensions from {@link types!McpUiHostContext `hostContext.containerDimensions`}, updating on {@link app!App.onhostcontextchanged `onhostcontextchanged`}. + +> [!WARNING] +> **Never combine `autoResize: true` with `height: 100vh` or `100%` on the root element.** The SDK reports the document height, the host grows the iframe to match, the document sees a taller viewport and grows again — this loops until the host's maximum height cap. + +If you're using the React `useApp` hook, note that it always creates the App with `autoResize: true`. For fixed or host-driven height, construct the `App` manually or use the `useAutoResize` hook with a specific element. + ## Passing contextual information from the App to the model Use {@link app!App.updateModelContext `updateModelContext`} to keep the model informed about what the user is viewing or interacting with. Structure the content with YAML frontmatter for easy parsing: @@ -569,6 +654,11 @@ app.ontoolresult = (result) => { For state that represents user effort (e.g., saved bookmarks, annotations, custom configurations), consider persisting it server-side using [app-only tools](#tools-that-are-private-to-apps) instead. Pass the `viewUUID` to the app-only tool to scope the saved data to that view instance. +> [!WARNING] +> **Always namespace your `localStorage` keys.** Hosts typically serve all MCP Apps from the same sandbox origin, which means every App shares the same `localStorage`. Using generic keys like `"state"` or `"settings"` will collide with other Apps. The server-generated `viewUUID` pattern above avoids this, but if you use any other keys, prefix them with a string unique to your App. +> +> Availability of `localStorage` is also host-dependent — it may be unavailable in some sandbox configurations. Always wrap access in `try`/`catch` and degrade gracefully. + > [!NOTE] > For full examples using `localStorage`, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) (persists current page) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (persists camera position). @@ -601,6 +691,61 @@ app.onteardown = async () => { > [!NOTE] > For full examples that implement this pattern, see: [`examples/shadertoy-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/shadertoy-server) and [`examples/threejs-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/threejs-server). +## Sharing one UI resource across multiple tools + +You can point several tools at the same `ui://` resource — for example, a single "document viewer" App that renders results from `open-document`, `search-documents`, and `recent-documents`. + +The App needs to know which tool produced its data so it can parse the payload correctly. The host may provide this via `hostContext.toolInfo`, but it's optional and not guaranteed on every host. The reliable pattern is to include a discriminator in your tool result: + +```ts +// In each tool handler, tag the result with its origin +return { + content: [{ type: "text", text: "Opened annual-report.pdf" }], + structuredContent: { + kind: "open-document", // discriminator + document: { id, title, pageCount }, + }, +}; +``` + +```ts +// In the App, branch on the discriminator +app.ontoolresult = (result) => { + const data = result.structuredContent as { kind: string }; + switch (data.kind) { + case "open-document": + renderViewer(data); + break; + case "search-documents": + renderSearchResults(data); + break; + } +}; +``` + +## Conditionally showing UI + +The tool-to-resource binding is declared at registration time — a tool either has a `_meta.ui.resourceUri` or it doesn't. You can't decide per-call whether to render UI. + +If you need both behaviors, register two tools: + +- `query-data` — no `_meta.ui`, returns text/structured data for the model to reason about +- `visualize-data` — has `_meta.ui`, returns the same data rendered as an interactive App + +Give each a clear description so the model picks the right one based on user intent ("show me" → visualize, "tell me" → query). + +If the decision truly must be server-side (e.g., only show UI when the result set exceeds a threshold), the current workaround is to always attach the UI resource but have the App render a minimal, collapsed placeholder when there's nothing worth showing. Keep the placeholder small so it doesn't add visual noise to the conversation. + +## Opening external links + +Use {@link app!App.openLink `app.openLink()`} instead of `window.open()` or ``. The sandbox blocks direct navigation; `openLink` asks the host to open the URL on your behalf. + +Hosts typically show an interstitial confirmation before navigating so users can review the destination — don't assume the navigation is instant, and don't chain multiple `openLink` calls. + +```ts +await app.openLink({ url: "https://example.com/docs" }); +``` + ## Lowering perceived latency Use {@link app!App.ontoolinputpartial `ontoolinputpartial`} to receive streaming tool arguments as they arrive. This lets you show a loading preview before the complete input is available, such as streaming code into a `
` tag before executing it, partially rendering a table as data arrives, or incrementally populating a chart.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 00000000..e0d5f5a7
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,56 @@
+---
+title: Troubleshooting
+group: Getting Started
+description: Diagnose common issues with MCP Apps — blank iframes, CSP errors, missing tool callbacks, and cross-host rendering differences.
+---
+
+# Troubleshooting
+
+## The App renders a blank iframe
+
+This is almost always one of four things. Check them in order:
+
+**1. Open the browser developer console inside the iframe.** Right-click inside the App area → _Inspect_, then switch the console's context dropdown (top-left of the Console tab) from `top` to the sandboxed iframe. Any uncaught JavaScript error will stop your App before it paints.
+
+**2. Check for CSP violations.** Look for `Refused to connect to…` or `Refused to load…` messages. If your App fetches anything over the network — including `localhost` during development — you must declare it in `_meta.ui.csp.connectDomains` or `resourceDomains`. See the [CSP & CORS guide](./csp-cors.md).
+
+**3. Verify the resource URI matches exactly.** The `_meta.ui.resourceUri` on your tool must be character-for-character identical to the URI you registered with `registerAppResource` (or `server.registerResource`). A trailing slash or case mismatch means the host can't find your HTML.
+
+**4. Verify the MIME type.** The resource's `mimeType` must be `text/html;profile=mcp-app` (exported as {@link app!RESOURCE_MIME_TYPE `RESOURCE_MIME_TYPE`}). Plain `text/html` won't be recognized as an App.
+
+## `ontoolinput` / `ontoolresult` never fires
+
+- **Register handlers before calling `connect()`.** If you attach `app.ontoolresult = …` after `connect()` resolves, the notification may have already been delivered and discarded. The React `useApp` hook handles this for you; with vanilla JS, set handlers first.
+- **Check the host actually called your tool.** If the model chose a different tool (or none), there's no result to deliver. Verify in the host's tool-call log.
+- **Check SDK version compatibility.** Older SDK versions had stricter schemas for host notifications. If your App was built against a significantly older `@modelcontextprotocol/ext-apps` than the host expects, the initialize handshake may silently fail. Keep the SDK version reasonably current.
+
+## The App works in one host but not another
+
+MCP Apps are portable by design, but only if you stick to the SDK. Common portability mistakes:
+
+- **Relying on host-specific globals.** Don't reference `window.openai`, `window.claude`, or any other host-injected object. Use the `App` class from this SDK — it speaks the standard protocol to any compliant host.
+- **Hardcoding asset URLs to a specific host's CDN.** Bundle your assets or declare them in `resourceDomains`.
+- **Assuming a specific sandbox origin.** The origin that serves your App varies by host. Don't hardcode it in CORS allowlists; use `_meta.ui.domain` to request a stable origin instead (see [CSP & CORS](./csp-cors.md)).
+
+## The App keeps growing taller / has the wrong height
+
+See [Controlling App height](./patterns.md#controlling-app-height). The usual culprit is `height: 100vh` combined with the default `autoResize: true`.
+
+## Network requests fail with CORS errors
+
+CSP and CORS are separate controls:
+
+- **CSP** (`Refused to connect`) — the _browser_ blocked the request because the domain isn't in `connectDomains`. Fix on the MCP server side by adding the domain to `_meta.ui.csp`.
+- **CORS** (`No 'Access-Control-Allow-Origin' header`) — the _API server_ rejected the request because it doesn't recognize the sandbox origin. Fix on the API server side by allowlisting the origin, or use `_meta.ui.domain` to get a predictable origin you can allowlist.
+
+See the [CSP & CORS guide](./csp-cors.md) for configuration examples.
+
+## The App's background is opaque when it should be transparent
+
+If you set `color-scheme: light dark` (or just `dark`) on your document, browsers may insert an opaque backdrop behind the iframe when the host's color scheme doesn't match. Remove the `color-scheme` declaration and use the `[data-theme]` attribute pattern from the [host context guide](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas) instead.
+
+## Where to get help
+
+- Test against the reference host: `npm start` in this repo serves `examples/basic-host` at `http://localhost:8080`, which logs all protocol traffic to the console.
+- Check the [GitHub Discussions](https://github.com/modelcontextprotocol/ext-apps/discussions) for similar issues.
+- File a bug with a minimal reproduction in [GitHub Issues](https://github.com/modelcontextprotocol/ext-apps/issues).
diff --git a/typedoc.config.mjs b/typedoc.config.mjs
index a97bb29b..b8ae8872 100644
--- a/typedoc.config.mjs
+++ b/typedoc.config.mjs
@@ -14,6 +14,8 @@ const config = {
     "docs/agent-skills.md",
     "docs/testing-mcp-apps.md",
     "docs/patterns.md",
+    "docs/design-guidelines.md",
+    "docs/troubleshooting.md",
     "docs/authorization.md",
     "docs/csp-cors.md",
     "docs/migrate_from_openai_apps.md",

From 65d9e31ff7af0b625dfe522e0c69d1236252e0c9 Mon Sep 17 00:00:00 2001
From: Den Delimarsky 
Date: Mon, 23 Mar 2026 23:30:26 +0000
Subject: [PATCH 2/2] docs: tighten prose and remove conversational tone

---
 docs/design-guidelines.md | 62 ++++++++++++++++-----------------
 docs/overview.md          |  2 +-
 docs/patterns.md          | 72 +++++++++++++++++++--------------------
 docs/troubleshooting.md   | 50 +++++++++++++--------------
 4 files changed, 92 insertions(+), 94 deletions(-)

diff --git a/docs/design-guidelines.md b/docs/design-guidelines.md
index 2c2a92c4..4d416416 100644
--- a/docs/design-guidelines.md
+++ b/docs/design-guidelines.md
@@ -1,61 +1,59 @@
 ---
 title: Design Guidelines
 group: Getting Started
-description: UX guidance for MCP Apps — what the host already provides, how to size your content, and how to stay visually consistent with the surrounding chat.
+description: UX guidance for MCP Apps, covering host-provided chrome, content sizing, and visual consistency with the surrounding chat.
 ---
 
 # Design Guidelines
 
-MCP Apps live inside a conversation. They should feel like a natural part of the chat, not a separate application wedged into it.
+An MCP App is part of a conversation. It should read as a continuation of the chat, not as a separate application embedded inside it.
 
-## The host provides the chrome
+## Host chrome
 
-Hosts typically render a frame around your App that includes:
+Hosts render a frame around your App that typically includes:
 
-- A **title bar** showing your App's name (from the tool or server metadata)
-- **Display-mode controls** (expand to fullscreen, collapse, close)
-- **Attribution** (which connector/server the App came from)
+- A title bar showing the App name (from tool or server metadata)
+- Display-mode controls (expand, collapse, close)
+- Attribution indicating which connector or server provided the App
 
-**Don't duplicate these.** Your App doesn't need its own close button, title header, or "powered by" footer. Start your layout with the actual content.
+Do not duplicate these elements. Your App does not need its own close button, header bar, or "powered by" footer. Begin the layout with content.
 
-If you need a title _inside_ your content (e.g., "Q3 Revenue by Region" above a chart), that's fine — just don't put your App's brand name there.
+A title inside the content area (for example, "Q3 Revenue by Region" above a chart) is acceptable. The App's brand name is not.
 
-## Keep it focused
+## Scope
 
-An MCP App answers one question or supports one task. Resist the urge to build a full dashboard with tabs, sidebars, and settings panels.
+An MCP App answers one question or supports one task. Avoid building a full dashboard with tabs, sidebars, and settings panels.
 
-Good heuristics:
+- Inline mode should fit within roughly one viewport of scroll. Content that is significantly taller than the chat viewport belongs in fullscreen mode, or should be trimmed.
+- Limit inline mode to one primary action. A "Confirm" button is appropriate; a toolbar with eight icons is not.
+- Let the conversation handle navigation. Rather than adding a search box inside the App, let the user ask a follow-up question that re-invokes the tool with new arguments.
 
-- **Inline mode should fit in roughly one screen of scroll.** If your content is much taller than the chat viewport, consider whether it belongs in fullscreen mode — or whether you're showing too much.
-- **One primary action at most.** A "Confirm" button is fine. A toolbar with eight icons is probably too much for inline mode.
-- **Let the conversation drive navigation.** Instead of building a search box inside your App, let the user ask a follow-up question and re-invoke the tool with new arguments.
+## Host UI imitation
 
-## Don't replicate the host's UI
+Your App must not resemble the surrounding chat client. Do not render:
 
-Your App must not look like the surrounding chat client. Specifically, avoid:
+- Chat bubbles or message threads
+- Anything that resembles the host's text input or send button
+- System notifications or permission dialogs
 
-- Rendering fake chat bubbles or message threads
-- Mimicking the host's input box or send button
-- Showing fake system notifications or permission dialogs
+These patterns blur the line between host UI and App content, and most hosts prohibit them in their submission guidelines.
 
-These patterns confuse users about what's real host UI versus App content, and most hosts prohibit them in their submission guidelines.
+## Host styling
 
-## Use host styling where possible
+Hosts provide CSS custom properties for colors, fonts, spacing, and border radius (see [Adapting to host context](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas)). Using them keeps your App consistent across light mode, dark mode, and different host themes.
 
-Hosts provide CSS custom properties for colors, fonts, spacing, and border radius (see [Adapting to host context](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas)). Using them makes your App feel native across light mode, dark mode, and different host themes.
+Brand colors are appropriate for content elements such as chart series or status badges. Backgrounds, text, and borders should use host variables. Always provide fallback values so the App renders correctly on hosts that omit some variables.
 
-You can bring your own brand colors for content (chart series, status badges), but let the host's variables drive backgrounds, text, and borders. Always provide fallback values so your App still renders reasonably on hosts that don't supply every variable.
+## Display modes
 
-## Inline vs fullscreen layout
+Design for inline mode first. It is the default, and it is narrow (often the width of a chat message) and height-constrained.
 
-Design for **inline first** — that's where your App appears by default. Inline mode is narrow (often the width of a chat message) and height-constrained.
+Treat fullscreen as a progressive enhancement for Apps that benefit from more space: editors, maps, large datasets. Check `hostContext.availableDisplayModes` before rendering a fullscreen toggle, since not every host supports it.
 
-Treat **fullscreen** as a progressive enhancement for Apps that benefit from more space (editors, maps, large datasets). Check `hostContext.availableDisplayModes` before showing a fullscreen toggle — not every host supports it.
+When the display mode changes, update your layout: remove edge border radius, expand to fill the viewport, and re-read `containerDimensions` from the updated host context.
 
-When switching modes, remember to adjust your layout: remove border radius at the edges, expand to fill the viewport, and re-read `containerDimensions` from the updated host context.
+## Loading and empty states
 
-## Handle the empty and loading states
+The App mounts before the tool result arrives. Between `ui/initialize` and `ontoolresult`, render a loading indicator such as a skeleton, spinner, or neutral background. A blank rectangle looks broken.
 
-Your App mounts before the tool result arrives. Between `ui/initialize` and `ontoolresult`, show something — a skeleton, a spinner, or at minimum a neutral background. A blank white rectangle looks broken.
-
-Similarly, if your tool result can be empty (no search results, no items in cart), design a clear empty state rather than rendering nothing.
+If the tool result can be empty (no search results, empty cart), design an explicit empty state rather than rendering nothing.
diff --git a/docs/overview.md b/docs/overview.md
index 8b090f01..67f70575 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -110,7 +110,7 @@ Resources are declared upfront, during tool registration. This design enables:
 - **Separation of concerns** — Templates (presentation) are separate from tool results (data)
 - **Review** — Hosts can inspect UI templates during connection setup
 
-**Versioning and caching.** Resource caching behavior is host-defined. A host may re-fetch your `ui://` resource each time it renders, cache it for the session, or persist it alongside the conversation. This means a user revisiting an old conversation may see either your _current_ template code running against the _original_ tool result, or a snapshot of both from when the tool first ran. Design your App to tolerate older `structuredContent` shapes — treat unknown fields gracefully and don't assume the template and the data were produced by the same code version.
+**Versioning and caching.** Resource caching behavior is host-defined. A host may re-fetch the `ui://` resource on each render, cache it for the session, or persist it alongside the conversation. When a user revisits an old conversation, the host may run the current template code against the original tool result, or it may replay a snapshot of both from the time of the original tool call. Design the App to tolerate older `structuredContent` shapes: handle unknown fields gracefully and do not assume the template and the data were produced by the same code version.
 
 See the [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#ui-resource-format) section of the specification for the full schema.
 
diff --git a/docs/patterns.md b/docs/patterns.md
index d9365bde..beb7da68 100644
--- a/docs/patterns.md
+++ b/docs/patterns.md
@@ -37,29 +37,29 @@ registerAppTool(
 > [!NOTE]
 > For full examples that implement this pattern, see: [`examples/system-monitor-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/system-monitor-server) and [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server).
 
-## What the model sees vs what the App sees
+## Tool result data visibility
 
-A tool result has three places to put data, each with different visibility:
+A tool result has three fields for data, each with different visibility:
 
-| Field               | Seen by model | Seen by App | Use for                                                                    |
-| ------------------- | ------------- | ----------- | -------------------------------------------------------------------------- |
-| `content`           | ✅            | ✅          | Short text summary the model can reason about and text-only hosts can show |
-| `structuredContent` | ❌            | ✅          | Structured data the App renders (tables, charts, lists)                    |
-| `_meta`             | ❌            | ✅          | Opaque metadata (IDs, timestamps, view identifiers)                        |
+| Field               | Seen by model | Seen by App | Use for                                                       |
+| ------------------- | ------------- | ----------- | ------------------------------------------------------------- |
+| `content`           | Yes           | Yes         | Short text summary for the model and for text-only hosts      |
+| `structuredContent` | No            | Yes         | Structured data the App renders (tables, charts, lists)       |
+| `_meta`             | No            | Yes         | Opaque metadata such as IDs, timestamps, and view identifiers |
 
-Keep `content` brief — a one-line summary is usually enough. The model uses it to decide what to say next, so avoid dumping raw data there.
+Keep `content` brief. The model uses it to decide what to say next, so a one-line summary is preferable to raw data.
 
 > [!WARNING]
-> **Don't put large payloads in tool results.** Base64-encoded audio, images, or file contents should be served via MCP resources (see [Serving binary blobs via resources](#serving-binary-blobs-via-resources)) or fetched by the App over the network, not returned inline in `structuredContent`. Even though `structuredContent` is not added to the model's context by spec, large tool results slow down transport, inflate conversation storage, and some host implementations may include more of the result than you expect.
+> Do not return large payloads in tool results. Serve base64-encoded audio, images, or file contents via MCP resources (see [Serving binary blobs via resources](#serving-binary-blobs-via-resources)) or have the App fetch them over the network. Although `structuredContent` is excluded from the model's context by the specification, large tool results still slow down transport, inflate conversation storage, and some host implementations include more of the result than the specification requires.
 
-**Write `content` for the model, not the user.** The user is looking at your App, not reading the `content` text. A good `content` string tells the model what just happened so it can respond naturally without repeating what's already on screen:
+Write `content` for the model, not the user. The user sees your App, not the `content` text. Use `content` to tell the model what happened so it can respond without repeating what is already on screen:
 
 ```ts
 return {
   content: [
     {
       type: "text",
-      text: "Rendered an interactive chart of Q3 revenue by region. The user can see and interact with it directly — do not describe the chart contents in your response.",
+      text: "Rendered an interactive chart of Q3 revenue by region. The user can see and interact with it; do not describe the chart contents in your response.",
     },
   ],
   structuredContent: { regions, revenue, quarter: "Q3" },
@@ -432,27 +432,27 @@ function MyApp() {
 > For full examples that implement this pattern, see: [`examples/basic-server-vanillajs/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) and [`examples/basic-server-react/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react).
 
 > [!TIP]
-> **Avoid the `color-scheme` CSS property on your root element.** If your App declares `color-scheme: light dark` but the host's document doesn't, browsers insert an opaque backdrop behind the iframe to prevent cross-scheme bleed-through — which breaks transparent backgrounds. Prefer the `[data-theme]` attribute approach shown above and let the host control scheme negotiation.
+> Avoid setting the `color-scheme` CSS property on your root element. If the App declares `color-scheme: light dark` and the host document does not, the browser inserts an opaque backdrop behind the iframe to prevent cross-scheme bleed-through, which breaks transparent backgrounds. Use the `[data-theme]` attribute approach shown above and let the host control scheme negotiation.
 
 ## Supporting touch devices
 
-Apps that handle pointer gestures (pan, drag, pinch) need to prevent those gestures from also scrolling the surrounding chat. Use [`touch-action`](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) on interactive surfaces:
+Apps that handle pointer gestures (pan, drag, pinch) must prevent those gestures from also scrolling the surrounding chat. Set [`touch-action`](https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action) on interactive surfaces:
 
 ```css
-/* Chart/canvas that handles its own panning */
+/* Chart or canvas that handles its own panning */
 .chart-surface {
   touch-action: none;
 }
 
-/* Horizontal slider that shouldn't trigger vertical page scroll */
+/* Horizontal slider that should not trigger vertical page scroll */
 .slider-track {
   touch-action: pan-y; /* allow vertical scroll, consume horizontal */
 }
 ```
 
-Without this, a user dragging across your chart on mobile will also scroll the chat, and your App may never receive the `pointermove` events.
+Without `touch-action`, dragging across the App on a mobile device also scrolls the chat, and the App may never receive `pointermove` events.
 
-Also make sure your layout doesn't overflow horizontally — set `overflow-x: hidden` on the root container if you have any fixed-width elements. Horizontal overflow on mobile causes the entire App to wobble when scrolled.
+Prevent horizontal overflow by setting `overflow-x: hidden` on the root container if the layout contains any fixed-width elements. Horizontal overflow on mobile causes the entire App to shift when the page is scrolled.
 
 ## Entering / exiting fullscreen
 
@@ -507,13 +507,13 @@ In fullscreen mode, remove the container's border radius so content extends to t
 
 ## Controlling App height
 
-By default, the SDK observes your document's content height and reports it to the host so the iframe grows to fit (`autoResize: true`). This works well for content-driven UI like cards, tables, and forms — but it's the wrong choice for viewport-filling UI like canvases, maps, and editors.
+By default, the SDK observes the document's content height and reports it to the host so the iframe grows to fit (`autoResize: true`). This is appropriate for content-driven UI such as cards, tables, and forms. It is the wrong choice for viewport-filling UI such as canvases, maps, and editors.
 
-Pick one of three strategies:
+There are three height strategies:
 
-**1. Auto-resize (default)** — for content that has a natural height. Let the iframe grow to fit. Don't set `height: 100vh` or `height: 100%` on your root element, or you'll create a feedback loop where the reported height keeps growing.
+**Auto-resize (default).** For content with a natural height. The iframe grows to fit. Do not set `height: 100vh` or `height: 100%` on the root element; doing so creates a feedback loop where the reported height keeps increasing.
 
-**2. Fixed height** — for UI that should always be the same size inline. Disable auto-resize and set an explicit height:
+**Fixed height.** For UI that should remain the same size when inline. Disable auto-resize and set an explicit height:
 
 ```ts
 const app = new App(
@@ -531,12 +531,12 @@ body {
 }
 ```
 
-**3. Host-driven height** — for UI that should fill whatever space the host gives it (common for fullscreen-capable Apps). Disable auto-resize and read the host-provided dimensions from {@link types!McpUiHostContext `hostContext.containerDimensions`}, updating on {@link app!App.onhostcontextchanged `onhostcontextchanged`}.
+**Host-driven height.** For UI that should fill the space the host provides (common for fullscreen-capable Apps). Disable auto-resize and read dimensions from {@link types!McpUiHostContext `hostContext.containerDimensions`}, updating on {@link app!App.onhostcontextchanged `onhostcontextchanged`}.
 
 > [!WARNING]
-> **Never combine `autoResize: true` with `height: 100vh` or `100%` on the root element.** The SDK reports the document height, the host grows the iframe to match, the document sees a taller viewport and grows again — this loops until the host's maximum height cap.
+> Do not combine `autoResize: true` with `height: 100vh` or `100%` on the root element. The SDK reports the document height, the host grows the iframe to match, the document sees a taller viewport and grows again. This loops until the host's maximum height cap.
 
-If you're using the React `useApp` hook, note that it always creates the App with `autoResize: true`. For fixed or host-driven height, construct the `App` manually or use the `useAutoResize` hook with a specific element.
+The React `useApp` hook always creates the App with `autoResize: true`. For fixed or host-driven height, construct the `App` manually or use the `useAutoResize` hook with a specific element.
 
 ## Passing contextual information from the App to the model
 
@@ -655,9 +655,9 @@ app.ontoolresult = (result) => {
 For state that represents user effort (e.g., saved bookmarks, annotations, custom configurations), consider persisting it server-side using [app-only tools](#tools-that-are-private-to-apps) instead. Pass the `viewUUID` to the app-only tool to scope the saved data to that view instance.
 
 > [!WARNING]
-> **Always namespace your `localStorage` keys.** Hosts typically serve all MCP Apps from the same sandbox origin, which means every App shares the same `localStorage`. Using generic keys like `"state"` or `"settings"` will collide with other Apps. The server-generated `viewUUID` pattern above avoids this, but if you use any other keys, prefix them with a string unique to your App.
+> Namespace all `localStorage` keys. Hosts typically serve every MCP App from the same sandbox origin, so all Apps share a single `localStorage`. Generic keys such as `"state"` or `"settings"` will collide with other Apps. The server-generated `viewUUID` pattern above avoids this; any additional keys should be prefixed with a string unique to your App.
 >
-> Availability of `localStorage` is also host-dependent — it may be unavailable in some sandbox configurations. Always wrap access in `try`/`catch` and degrade gracefully.
+> `localStorage` availability is host-dependent and may be disabled in some sandbox configurations. Wrap access in `try`/`catch` and degrade gracefully.
 
 > [!NOTE]
 > For full examples using `localStorage`, see: [`examples/pdf-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/pdf-server) (persists current page) and [`examples/map-server/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/map-server) (persists camera position).
@@ -693,9 +693,9 @@ app.onteardown = async () => {
 
 ## Sharing one UI resource across multiple tools
 
-You can point several tools at the same `ui://` resource — for example, a single "document viewer" App that renders results from `open-document`, `search-documents`, and `recent-documents`.
+Several tools can reference the same `ui://` resource. For example, a single document viewer App might render results from `open-document`, `search-documents`, and `recent-documents`.
 
-The App needs to know which tool produced its data so it can parse the payload correctly. The host may provide this via `hostContext.toolInfo`, but it's optional and not guaranteed on every host. The reliable pattern is to include a discriminator in your tool result:
+The App needs to know which tool produced its data in order to parse the payload correctly. The host may provide this via `hostContext.toolInfo`, but the field is optional and not available on every host. The reliable approach is to include a discriminator in the tool result:
 
 ```ts
 // In each tool handler, tag the result with its origin
@@ -725,22 +725,22 @@ app.ontoolresult = (result) => {
 
 ## Conditionally showing UI
 
-The tool-to-resource binding is declared at registration time — a tool either has a `_meta.ui.resourceUri` or it doesn't. You can't decide per-call whether to render UI.
+The tool-to-resource binding is declared at registration time. A tool either has a `_meta.ui.resourceUri` or it does not; the server cannot decide per-call whether to render UI.
 
-If you need both behaviors, register two tools:
+If both behaviors are needed, register two tools:
 
-- `query-data` — no `_meta.ui`, returns text/structured data for the model to reason about
-- `visualize-data` — has `_meta.ui`, returns the same data rendered as an interactive App
+- `query-data` with no `_meta.ui`, returning text and structured data for the model to reason about
+- `visualize-data` with `_meta.ui`, returning the same data rendered as an interactive App
 
-Give each a clear description so the model picks the right one based on user intent ("show me" → visualize, "tell me" → query).
+Write distinct descriptions so the model selects the correct tool based on user intent ("show me" maps to visualize, "tell me" maps to query).
 
-If the decision truly must be server-side (e.g., only show UI when the result set exceeds a threshold), the current workaround is to always attach the UI resource but have the App render a minimal, collapsed placeholder when there's nothing worth showing. Keep the placeholder small so it doesn't add visual noise to the conversation.
+If the decision must be made server-side (for example, showing UI only when the result set exceeds a threshold), the workaround is to always attach the UI resource and have the App render a minimal collapsed placeholder when there is nothing to show. Keep the placeholder small to avoid adding visual noise to the conversation.
 
 ## Opening external links
 
-Use {@link app!App.openLink `app.openLink()`} instead of `window.open()` or ``. The sandbox blocks direct navigation; `openLink` asks the host to open the URL on your behalf.
+Use {@link app!App.openLink `app.openLink()`} instead of `window.open()` or ``. The sandbox blocks direct navigation; `openLink` asks the host to open the URL on the App's behalf.
 
-Hosts typically show an interstitial confirmation before navigating so users can review the destination — don't assume the navigation is instant, and don't chain multiple `openLink` calls.
+Hosts typically show an interstitial confirmation so users can review the destination before navigating. Do not assume navigation is instant, and do not chain multiple `openLink` calls.
 
 ```ts
 await app.openLink({ url: "https://example.com/docs" });
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index e0d5f5a7..515827b8 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -1,56 +1,56 @@
 ---
 title: Troubleshooting
 group: Getting Started
-description: Diagnose common issues with MCP Apps — blank iframes, CSP errors, missing tool callbacks, and cross-host rendering differences.
+description: Diagnose common MCP App issues including blank iframes, CSP errors, missing tool callbacks, and cross-host rendering differences.
 ---
 
 # Troubleshooting
 
-## The App renders a blank iframe
+## Blank iframe
 
-This is almost always one of four things. Check them in order:
+The most common causes, in the order you should check them:
 
-**1. Open the browser developer console inside the iframe.** Right-click inside the App area → _Inspect_, then switch the console's context dropdown (top-left of the Console tab) from `top` to the sandboxed iframe. Any uncaught JavaScript error will stop your App before it paints.
+1. **Uncaught JavaScript error.** Open browser developer tools inside the iframe: right-click the App area, choose _Inspect_, then switch the console context dropdown (top-left of the Console tab) from `top` to the sandboxed frame. An uncaught error stops the App before it paints.
 
-**2. Check for CSP violations.** Look for `Refused to connect to…` or `Refused to load…` messages. If your App fetches anything over the network — including `localhost` during development — you must declare it in `_meta.ui.csp.connectDomains` or `resourceDomains`. See the [CSP & CORS guide](./csp-cors.md).
+2. **CSP violation.** Look for `Refused to connect to…` or `Refused to load…` in the console. Any network request, including to `localhost` during development, must be declared in `_meta.ui.csp.connectDomains` or `resourceDomains`. See the [CSP & CORS guide](./csp-cors.md).
 
-**3. Verify the resource URI matches exactly.** The `_meta.ui.resourceUri` on your tool must be character-for-character identical to the URI you registered with `registerAppResource` (or `server.registerResource`). A trailing slash or case mismatch means the host can't find your HTML.
+3. **Resource URI mismatch.** The `_meta.ui.resourceUri` on the tool must match the URI passed to `registerAppResource` exactly. A trailing slash or case difference prevents the host from finding the HTML.
 
-**4. Verify the MIME type.** The resource's `mimeType` must be `text/html;profile=mcp-app` (exported as {@link app!RESOURCE_MIME_TYPE `RESOURCE_MIME_TYPE`}). Plain `text/html` won't be recognized as an App.
+4. **Wrong MIME type.** The resource's `mimeType` must be `text/html;profile=mcp-app` (exported as {@link app!RESOURCE_MIME_TYPE `RESOURCE_MIME_TYPE`}). Plain `text/html` is not recognized as an App resource.
 
 ## `ontoolinput` / `ontoolresult` never fires
 
-- **Register handlers before calling `connect()`.** If you attach `app.ontoolresult = …` after `connect()` resolves, the notification may have already been delivered and discarded. The React `useApp` hook handles this for you; with vanilla JS, set handlers first.
-- **Check the host actually called your tool.** If the model chose a different tool (or none), there's no result to deliver. Verify in the host's tool-call log.
-- **Check SDK version compatibility.** Older SDK versions had stricter schemas for host notifications. If your App was built against a significantly older `@modelcontextprotocol/ext-apps` than the host expects, the initialize handshake may silently fail. Keep the SDK version reasonably current.
+- **Handlers registered too late.** Attach `app.ontoolresult` before calling `connect()`. If the handler is attached after `connect()` resolves, the notification may have already been delivered and discarded. The React `useApp` hook handles this ordering automatically.
+- **Tool was not called.** If the model chose a different tool, or none, there is no result to deliver. Check the host's tool-call log.
+- **SDK version mismatch.** Older SDK versions used stricter schemas for host notifications. If the App was built against a significantly older `@modelcontextprotocol/ext-apps` than the host expects, the initialize handshake can fail silently. Keep the SDK version current.
 
-## The App works in one host but not another
+## App works in one host but not another
 
-MCP Apps are portable by design, but only if you stick to the SDK. Common portability mistakes:
+MCP Apps are portable only if they use the SDK exclusively. Common portability mistakes:
 
-- **Relying on host-specific globals.** Don't reference `window.openai`, `window.claude`, or any other host-injected object. Use the `App` class from this SDK — it speaks the standard protocol to any compliant host.
-- **Hardcoding asset URLs to a specific host's CDN.** Bundle your assets or declare them in `resourceDomains`.
-- **Assuming a specific sandbox origin.** The origin that serves your App varies by host. Don't hardcode it in CORS allowlists; use `_meta.ui.domain` to request a stable origin instead (see [CSP & CORS](./csp-cors.md)).
+- **Host-specific globals.** Do not reference `window.openai`, `window.claude`, or any other host-injected object. Use the `App` class from this SDK, which speaks the standard protocol to any compliant host.
+- **Hardcoded CDN URLs.** Bundle assets into the App or declare their origins in `resourceDomains`.
+- **Hardcoded sandbox origin.** The origin that serves the App varies by host. Use `_meta.ui.domain` to request a stable origin rather than hardcoding one in CORS allowlists. See [CSP & CORS](./csp-cors.md).
 
-## The App keeps growing taller / has the wrong height
+## App grows unbounded or has the wrong height
 
-See [Controlling App height](./patterns.md#controlling-app-height). The usual culprit is `height: 100vh` combined with the default `autoResize: true`.
+See [Controlling App height](./patterns.md#controlling-app-height). The most common cause is `height: 100vh` combined with the default `autoResize: true`.
 
 ## Network requests fail with CORS errors
 
-CSP and CORS are separate controls:
+CSP and CORS are separate controls with different error messages and different fixes:
 
-- **CSP** (`Refused to connect`) — the _browser_ blocked the request because the domain isn't in `connectDomains`. Fix on the MCP server side by adding the domain to `_meta.ui.csp`.
-- **CORS** (`No 'Access-Control-Allow-Origin' header`) — the _API server_ rejected the request because it doesn't recognize the sandbox origin. Fix on the API server side by allowlisting the origin, or use `_meta.ui.domain` to get a predictable origin you can allowlist.
+- **CSP** (`Refused to connect`): The browser blocked the request because the domain is not in `connectDomains`. Add the domain to `_meta.ui.csp` on the MCP server.
+- **CORS** (`No 'Access-Control-Allow-Origin' header`): The API server rejected the request because it does not recognize the sandbox origin. Add the origin to the API server's allowlist, or use `_meta.ui.domain` to get a predictable origin that can be allowlisted.
 
 See the [CSP & CORS guide](./csp-cors.md) for configuration examples.
 
-## The App's background is opaque when it should be transparent
+## Opaque background instead of transparent
 
-If you set `color-scheme: light dark` (or just `dark`) on your document, browsers may insert an opaque backdrop behind the iframe when the host's color scheme doesn't match. Remove the `color-scheme` declaration and use the `[data-theme]` attribute pattern from the [host context guide](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas) instead.
+If the App declares `color-scheme: light dark` (or `color-scheme: dark`) and the host document does not, browsers insert an opaque backdrop behind the iframe to prevent cross-scheme bleed-through. Remove the `color-scheme` declaration and use the `[data-theme]` attribute pattern from the [host context guide](./patterns.md#adapting-to-host-context-theme-styling-fonts-and-safe-areas).
 
-## Where to get help
+## Getting help
 
-- Test against the reference host: `npm start` in this repo serves `examples/basic-host` at `http://localhost:8080`, which logs all protocol traffic to the console.
-- Check the [GitHub Discussions](https://github.com/modelcontextprotocol/ext-apps/discussions) for similar issues.
+- Test against the reference host: run `npm start` in this repository to serve `examples/basic-host` at `http://localhost:8080`. It logs all protocol traffic to the console.
+- Search [GitHub Discussions](https://github.com/modelcontextprotocol/ext-apps/discussions) for similar issues.
 - File a bug with a minimal reproduction in [GitHub Issues](https://github.com/modelcontextprotocol/ext-apps/issues).