# Interactive views (MCP Apps)

An agent that brings a manifest can ship interactive views with its tools. A view is a small HTML page, such as a map of the places `search_places` found, that AI clients supporting [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) show next to the tool's result. Claude (web and desktop), ChatGPT, VS Code, and Goose render views today. Other clients, such as terminal agents, show the tool's text result instead, so every tool that shows a view still returns a useful text result. Loading a view is free.

## Declare a view in the manifest

Views go in the manifest's top-level `ui` list, and each tool that shows one names it with a tool-level `ui`. The HTML is inline in the manifest, so the person approving the install can read exactly what will run.

```json
{
  "ui": [
    {
      "name": "map",
      "title": "Places map",
      "html": "<!doctype html><html>...</html>",
      "csp": {
        "connectDomains": ["https://tiles.openfreemap.org"],
        "resourceDomains": ["https://unpkg.com", "https://tiles.openfreemap.org"]
      },
      "permissions": { "geolocation": {} },
      "prefersBorder": true
    }
  ],
  "tools": [
    {
      "name": "search_places",
      "ui": { "resource": "map" }
    }
  ]
}
```

The example shows only the view fields. The rest of the format, including the `app` section (`app.id`, `app.name`, `app.version`, the format's literal field names for your agent), is in the [Wire SDK README](https://github.com/usewire/wire-sdk#readme).

| Field | Required | What it does |
|---|---|---|
| `name` | Yes | The view's name within your agent: lowercase letters, digits, and single hyphens, up to 64 characters. The container serves it at `ui://<app.id>/<name>-<hash>`. See [How clients get a view](#how-clients-get-a-view). |
| `title` | No | A human-readable name, up to 120 characters, shown on the consent screen. |
| `html` | Yes | The whole page as an HTML document, with its scripts and styles inline or loaded from a declared domain. Up to 512 KB. |
| `csp` | No | The outside domains the page may use, by kind. See [Declare every domain](#declare-every-domain). |
| `permissions` | No | Browser permissions the page asks the client for: `camera`, `microphone`, `geolocation`, `clipboardWrite`. |
| `prefersBorder` | No | Whether the client should draw a border around the view. |

A tool shows a view with `ui: { "resource": "<name>" }` on the tool's entry in the manifest's `tools`. The name has to be one of the manifest's views, or the manifest is refused. Only tools take `ui`, not actions: an action's output reaches the view through the tool that runs it.

## Keep a text result in every tool

A tool that shows a view still returns its normal result, and that result has to be useful on its own. Clients that don't render views, such as terminal agents, show only the text, and the agent reads the text either way. For a map, return the places as text (names, addresses, coordinates) and let the view draw them. A hint such as `"presentation": "map"` in the result is a good way to tell a text-only agent what the view would have shown.

## Declare every domain

A view can reach only the domains it declares. Clients apply the list as the page's Content Security Policy, and anything not on it is blocked, including network requests: a view with no `connectDomains` can't send data anywhere.

| `csp` key | What the page may do with those domains |
|---|---|
| `connectDomains` | Send and receive data (`fetch`, WebSocket) |
| `resourceDomains` | Load scripts, styles, images, and fonts |
| `frameDomains` | Embed pages in frames |
| `baseUriDomains` | Use as the page's base URL |

Wire checks every domain when you register the manifest:

- **https origins only**: `https://host` or `https://host:port`, such as `https://tiles.openfreemap.org`. No path (not even a trailing `/`), query, fragment, or credentials, and no other scheme.
- **A real host name.** At least two labels, such as `example.com`. IP addresses, `localhost`, and single-label names are refused.
- **One leading wildcard label at most, over at least two labels.** `https://*.openfreemap.org` is allowed. `https://*.com`, a bare `*`, and a `*` anywhere else are refused.
- **Never Wire's own domains.** `usewire.io` and every name under it are refused, so a view can't reach Wire as the person using it.
- **No duplicates**, and at most 32 entries in each list.

Load heavy libraries, such as a mapping library, from a CDN listed in `resourceDomains` rather than bundling them into `html`. That keeps the view well under its size limit and the source on the consent screen readable.

## Limits

| Limit | Value |
|---|---|
| Views per manifest | 8 |
| `html` per view | 512 KB |
| `html` across all views | 1 MiB |
| Domains per `csp` list | 32 |
| The whole manifest | 2 MiB, of which at most 1 MiB is outside the views' `html` |

## How clients get a view

The container declares the MCP Apps extension (`io.modelcontextprotocol/ui`) when a client connects. In `tools/list`, each tool that shows a view carries `_meta.ui.resourceUri`, the view's `ui://` URI. The URI ends in the first 8 hex digits of the HTML's SHA-256, such as `ui://someday/places-map-3f2a9c1b`, so a changed view gets a new URI: a client that cached the old page can't show a stale version after an update, and a request for an old URI is refused. Take the URI from `tools/list` or `resources/list` rather than building it. The client reads that URI with `resources/read` and gets the page as `text/html;profile=mcp-app`, with the view's `csp`, `permissions`, and `prefersBorder` under `_meta.ui`. Views also appear in `resources/list`, next to the container's files and any [skill](/build/sdk/skills-instructions/).

- **Free.** Listing or reading a view isn't a tool call or a [file download](/mcp/resources/), so it never costs credits.
- **Authenticated like any request.** A view is read over the same connection, with the same credential, as the agent's tool calls.
- **Sandboxed in the client.** The client renders the view in a sandboxed frame. The view receives the tool result the agent already got, and can call the agent's tools back through the client over the same connection. It can't reach the container any other way.
- **Part of the container's setup, not its content.** A view isn't stored as entries, so searching, exploring, or querying the container never returns it.

## Keep data and rendering apart

A view works best behind its own tool. Keep the tool that finds records free of `ui`, and add a `render_*` tool that takes the ids the agent chose, looks those records up, and carries the view:

- **The agent decides what to show.** It searches, reads the results, and passes on only the records worth showing, so the view never has to guess.
- **Every client still gets a useful answer.** The data tool returns plain results everywhere, and only the render call produces a view.
- **The view gets exactly the fields it draws.** The render tool reads them straight from the records, so a map gets numeric coordinates instead of parsing them out of text.

`render_places_map` takes place ids from `search_places` and reads their names and coordinates with [`wire_query`](/reference/tools/#wire_query):

```json
{
  "name": "render_places_map",
  "description": "Show saved places on a map. Pass the ids of the places to show, from search_places.",
  "inputSchema": {
    "type": "object",
    "properties": { "ids": { "type": "array", "items": { "type": "string" }, "maxItems": 50 } },
    "required": ["ids"]
  },
  "tool": {
    "name": "wire_query",
    "args": {
      "sql": "SELECT _entry_id AS id, name, lat, lng FROM place WHERE _entry_id IN (SELECT value FROM json_each(?1, '$.ids'))",
      "params": ["{\"ids\": {{input.ids}}}"]
    }
  },
  "result": { "places": "{{tool.rows}}", "_meta": { "columns": "{{tool.columns}}" } },
  "ui": { "resource": "places-map" }
}
```

The ids arrive as a list and are written into one JSON parameter, and `json_each` expands it in SQL, so the ids are only ever compared as values. The tool doesn't need `wire_query` turned on for agents: it runs it on their behalf, for exactly these records. The column names are only for the view, so they go under `_meta` in the result, and the view receives them as `_meta.view`. The model reads `places` and nothing else. See [custom tools](/guides/custom-tools/#annotations-and-output-schemas).

## Tools only the view calls

A tool-level `ui` can also set `visibility`, a list of `"model"` and `"app"`. The default is both: the agent sees the tool, and the view can call it. The container lists the tool with its visibility in `_meta.ui.visibility`, and the client that shows the view is what applies it:

- `["app"]` marks the tool as one for the view only, such as "pin this place" on a map. Clients that support MCP Apps don't offer it to the agent, and the view can still call it.
- `["model"]` marks the tool as the agent's only. Clients that follow the MCP Apps spec won't let a view call it.

A client that doesn't support MCP Apps, such as a terminal agent, ignores `visibility` and lists the tool like any other, so an app-only tool still needs a sensible description and a useful result.

```json
{ "name": "pin_place", "ui": { "resource": "map", "visibility": ["app"] } }
```

The consent screen names every tool marked for the view only.

## What the person approving sees

The connect screen shows each view the agent ships: its title and name, its size, which tools show it, every domain it declares grouped by kind, and every permission it asks for in plain words, such as "See your location". The page's source is one click away, as read-only text. Wire never runs or renders a view on the consent screen. See [Connecting agents](/guides/connecting-agents/#what-the-connect-screen-shows).

## Changing a view

To change a view, register your manifest again with a higher `app.version`. The new HTML is served at a new URI, so clients never mix the two versions. A new version reaches a container when someone approves the update (see [Updates](/build/sdk/installs-webhooks/#updates)). The review lists every view added, changed, or removed, and calls out each domain and permission the new version adds, such as "New: the Places map (map) view can now send and receive data from https://api.maptiler.com." Until the update is approved, clients keep getting the view the container has installed.

Disconnecting your agent from a container keeps its views there, as it keeps its skill. Uninstalling removes them along with its tools.