Skip to content

MCP App Bridge

The MCP App Bridge is NimbleBrain’s implementation of the ext-apps specification (2026-01-26) — the standard protocol that enables MCP Apps to render inside host applications with bidirectional tool access. Where the spec has no method for something the shell needs, NimbleBrain adds an ai.nimblebrain/ extension: semantic actions, a file picker, keyboard forwarding, and the app’s location for the top bar.

All messages use window.postMessage with JSON-RPC 2.0 envelopes:

{
jsonrpc: "2.0",
method: string,
id?: string, // Present for requests that expect a response
params?: object,
result?: unknown, // Present in responses
error?: object, // Present in error responses
}
Method Type Description
ui/initialize Response Host responds to the app’s init request with capabilities, theme, and context.
ui/notifications/tool-result Notification Forwarded tool result from the agent.
ui/notifications/tool-input Notification Tool arguments being sent to a tool on the app’s server.
ui/notifications/host-context-changed Notification Host context changed (theme toggle, locale, etc.).
notifications/resources/list_changed Notification The app’s own MCP server sent this; the host relays it verbatim. See below.
Method Type Description
ui/initialize Request App initiates the handshake. Host responds with capabilities and context.
ui/notifications/initialized Notification App confirms handshake complete. Host must not send tool data before this.
ui/notifications/size-changed Notification Report content dimensions for auto-sizing inline views.
ui/notifications/request-teardown Notification App requests the host to tear down the iframe. Currently a no-op.
tools/call Request Call a tool on the app’s MCP server. Returns CallToolResult, or a task when the call opts in to the tasks extension.
resources/read Request Read a resource from the app’s MCP server. Returns ReadResourceResult. See below.
resources/list Request List the app’s MCP server’s resources, paginated. Returns ListResourcesResult. See below.
resources/templates/list Request List the app’s MCP server’s resource templates, paginated. Returns ListResourceTemplatesResult.
tasks/get Request A task’s current state, with its outcome once it has ended. See Tasks.
tasks/cancel Request Ask the server to cancel a task. See Tasks.
ui/message Request Send a message to the conversation.
ui/open-link Request Open a URL in a new browser tab.
ui/update-model-context Request Push structured state visible to the LLM. See below.
ui/download-file Request Hand the user a file, as MCP resource blocks. See below.
ui/request-display-mode Request Ask to be displayed differently. Answered with the mode actually in effect. See below.
notifications/message Notification A log line for the host’s console.

A request for any other method (tasks/result and tasks/list among them) gets a JSON-RPC -32601 (method not found) error, so the call fails right away instead of waiting. The host ignores any other notification.

These have no spec equivalent. Each name is both the method and the identifier the host declares in hostCapabilities.experimental, and an SDK sends one only where the host declared it — so on a host that does not, they are no-ops rather than requests that hang.

Method Direction Description
ai.nimblebrain/action App → Host Request a semantic action from the shell.
ai.nimblebrain/notify App → Host Show the user a notice (success, info, warning or error), labelled with your app; answered {}. See below.
ai.nimblebrain/request-file App → Host Open native file picker; the host persists each chosen file to the workspace store and returns { files: [...] }. See below.
ai.nimblebrain/upload-files App → Host Store Files the app already holds (dropped on it, say), the way a pick is stored; answered { files: [...] }. Offered only to the platform’s Files app. See below.
ai.nimblebrain/keydown App → Host Forward keyboard shortcuts to the host.
ai.nimblebrain/location App → Host Report the app’s trail, root first, so the shell’s top bar shows its title and breadcrumb. See below.
ai.nimblebrain/navigate Host → App Go to one of the trail entries the app sent, when the user picks it in the top bar.

The app initiates the handshake per the ext-apps spec:

  1. App → Host: ui/initialize request with appInfo, appCapabilities, protocolVersion
  2. Host → App: Response with hostInfo, hostCapabilities, hostContext
  3. App → Host: ui/notifications/initialized notification
  4. Host sends notifications only after receiving initialized

Until initialized arrives, the host posts the app nothing but the answers to its own requests, so the first frame an app receives is the ui/initialize response. A notification raised earlier (a host-context change, tool input or result, a relayed server notification) is held and delivered, in order, once the handshake completes.

// 1. App sends
{
"jsonrpc": "2.0",
"method": "ui/initialize",
"id": "syn-1",
"params": {
"protocolVersion": "2026-01-26",
"appInfo": { "name": "my-app", "version": "1.0.0" },
"appCapabilities": {}
}
}
// 2. Host responds
{
"jsonrpc": "2.0",
"id": "syn-1",
"result": {
"protocolVersion": "2026-01-26",
"hostInfo": { "name": "nimblebrain", "version": "1.0.0" },
"hostCapabilities": {
"openLinks": {},
"downloadFile": {},
"serverTools": {},
"serverResources": { "listChanged": true },
"logging": {},
"message": { "text": {} },
"updateModelContext": { "text": {}, "structuredContent": {} },
"experimental": {
"io.modelcontextprotocol/tasks": {},
"ai.nimblebrain/action": {},
"ai.nimblebrain/request-file": {},
"ai.nimblebrain/keydown": {},
"ai.nimblebrain/location": {}
}
},
"hostContext": {
"theme": "dark",
"styles": {
"variables": {
"--color-background-primary": "#0f172a",
"--color-text-primary": "#e2e8f0"
},
"css": {
"fonts": "@font-face { font-family: 'Hanken Grotesk'; src: url('https://app.example.com/assets/hanken-grotesk.woff2') format('woff2'); font-weight: 100 900; font-style: normal; font-display: swap; }"
}
},
"ai.nimblebrain/styles": {
"variables": {
"--color-text-accent": "#818cf8",
"--nb-color-processing": "#a78bfa"
}
}
}
}
}
// 3. App sends
{
"jsonrpc": "2.0",
"method": "ui/notifications/initialized",
"params": {}
}

Everything the host serves, it declares. An SDK checks the declaration before it sends: without serverTools or serverResources a tool call or resource read throws at the call site, and without message or updateModelContext the send is dropped. So read hostCapabilities to decide whether to show a control, not to decide whether the host is NimbleBrain.

Non-spec capabilities live in experimental, keyed by identifier — the MCP tasks extension at io.modelcontextprotocol/tasks (an empty object: the host serves it), the NimbleBrain extensions under ai.nimblebrain/. A client that validates the response against the ext-apps schema, such as the official App, drops any field the schema does not name and keeps the contents of experimental (from ext-apps 1.7.5). That is the whole reason they travel there rather than as siblings.

The spec negotiates extensions under capabilities.extensions, not experimental. The ext-apps bridge capability object has no extensions field, so experimental carrying the extension identifier is as close as this channel reaches. When ext-apps adds one, the identifiers move there.

Theme colours arrive as the spec’s hostContext.styles.variables, and only under keys the spec’s variable enum names: one key outside it makes a spec client reject the whole host context. Status colours go out as --color-text-danger, --color-text-success, --color-text-warning and --color-background-info, and text on a filled accent or danger surface as --color-text-inverse, the same keys any MCP Apps host sends. The host sends no --nb-* twin of a spec key: the --nb-* extensions carry only values the spec has no key for.

The other tokens that change between light and dark arrive as hostContext["ai.nimblebrain/styles"].variables: --color-text-accent, --nb-color-processing and --nb-color-processing-light. The spec’s host context keeps unknown top-level keys, so a strict client passes this field through where it would reject the same keys in styles.variables. The host sends it on ui/initialize and on every host-context-changed, so these follow a theme toggle. The style block injected at load is written once, so it carries only tokens that look the same in both modes, plus a load-time value for the spec keys; see Theming.

Host fonts arrive as hostContext.styles.css.fonts, a block of @font-face CSS the app injects into its own document — an iframe inherits none from the shell, so a --font-* variable alone names a typeface the app cannot render. It is read once, at the handshake.

Sent when the agent calls one of your app’s tools during a conversation. The params are the tool’s standard MCP CallToolResult, including isError: true when the tool reported an error.

{
"jsonrpc": "2.0",
"method": "ui/notifications/tool-result",
"params": {
"content": [{ "type": "text", "text": "{\"temp\":72,\"condition\":\"sunny\"}" }],
"structuredContent": { "temp": 72, "condition": "sunny" }
}
}

Sent when the user toggles themes, changes locale, or other host context changes. Theme tokens are nested under styles.variables (spec keys) and ai.nimblebrain/styles.variables (the NimbleBrain tokens that change with the mode).

{
"jsonrpc": "2.0",
"method": "ui/notifications/host-context-changed",
"params": {
"theme": "dark",
"styles": {
"variables": {
"--color-background-primary": "#0f172a",
"--color-text-primary": "#e2e8f0"
}
},
"ai.nimblebrain/styles": {
"variables": {
"--color-text-accent": "#818cf8"
}
}
}
}

Sent when the agent is calling a tool on your app’s server. The params contain the tool arguments. Use this to show a loading state or preview the incoming action.

{
"jsonrpc": "2.0",
"method": "ui/notifications/tool-input",
"params": {
"arguments": { "city": "Honolulu", "days": 5 }
}
}

The app’s own MCP server sent notifications/resources/list_changed, and the host relays it to that server’s views — as the MCP Apps spec defines under serverResources.listChanged, which ui/initialize advertises. Re-read what you show; resources/list answers from the same server.

{
"jsonrpc": "2.0",
"method": "notifications/resources/list_changed"
}

The host is a relay, not an interpreter. A notification reaches the views as the server sent it (its params, when it sent any, are passed on), and it reaches only that server’s views, in the workspace whose connection received it. The host’s own apps that hold one person’s data (conversations, files, tasks) belong to no workspace, so their notifications reach only that person’s views, whichever workspace is open. It is the host’s only change signal: an app refreshes when its server sends this notification and the host relays it. It comes from the server rather than being inferred by the host, so it covers every write the server makes (a call from your own iframe, the agent’s, or one the server makes on its own), and a write the server does not announce reaches no view. To use it, have your server send the notification when its data changes; a server that advertises resources.listChanged (FastMCP and the TypeScript SDK both do when they serve resources) can send it from inside the tool call that made the write. A server sends it only for a real change, so a view that re-reads on it cannot loop.

What the host relays, and how often, is the host’s decision rather than the server’s, because the server is untrusted:

  • Only the methods the host relays pass. Today that is notifications/resources/list_changed. A server cannot reach its views with arbitrary JSON-RPC by choosing a method name.
  • params are relayed only as an object under 4 KB. Anything else is dropped, and the notification is relayed without params.
  • The rate is capped. Per server and workspace (per server and person, for a person’s own apps), the first notification is delivered at once, and any more inside the next 250 ms collapse into one delivery at the end of it. A server announcing in a loop reaches its views a few times a second.

Call a tool on your app’s MCP server. This is a JSON-RPC request — the response is a standard MCP CallToolResult.

Request:

{
"jsonrpc": "2.0",
"method": "tools/call",
"id": "call-1",
"params": {
"name": "get_weather",
"arguments": { "city": "Honolulu" }
}
}

Success response (CallToolResult):

{
"jsonrpc": "2.0",
"id": "call-1",
"result": {
"content": [{ "type": "text", "text": "{\"temp\":82,\"condition\":\"partly cloudy\"}" }],
"structuredContent": { "temp": 82, "condition": "partly cloudy" }
}
}

Tool error response. A tool that ran and reported an error answers a CallToolResult with isError: true, not a JSON-RPC error, so its structuredContent reaches your view:

{
"jsonrpc": "2.0",
"id": "call-1",
"result": {
"isError": true,
"content": [{ "type": "text", "text": "{\"error\":{\"code\":\"not_found\",\"message\":\"City not found\"}}" }],
"structuredContent": { "error": { "code": "not_found", "message": "City not found" } }
}
}

Error response. A JSON-RPC error means no result came back. Usually the call never ran: the tool is not yours or not callable from an app, or the host could not reach your server. A refusal from your server keeps its code and data; a transport failure is -32000. A timeout (-32001) or a connection closed mid-call (-32000) means the answer was lost, and the call may have run, so do not blindly retry a call that writes.

{
"jsonrpc": "2.0",
"id": "call-1",
"error": {
"code": -32602,
"message": "MCP error -32602: Tool \"get_weather\" is not callable from an app: its visibility does not include \"app\".",
"data": { "reason": "not_app_callable", "toolName": "get_weather" }
}
}

Read a resource from your app’s MCP server. This is a JSON-RPC request — the response is a standard MCP ReadResourceResult ({ contents: [...] }). The URI passes through verbatim to your server; resources are namespaced by the app that authored them.

Request:

{
"jsonrpc": "2.0",
"method": "resources/read",
"id": "res-1",
"params": {
"uri": "data://board/state"
}
}

Success response (ReadResourceResult):

{
"jsonrpc": "2.0",
"id": "res-1",
"result": {
"contents": [
{ "uri": "data://board/state", "mimeType": "application/json", "text": "{\"columns\":[]}" }
]
}
}

Like tools/call, resource reads are scoped to your app’s own server.

resources/list and resources/templates/list

Section titled “resources/list and resources/templates/list”

List your app’s MCP server’s resources, or its resource templates. Both are JSON-RPC requests, answered with the server’s own ListResourcesResult ({ resources, nextCursor? }) or ListResourceTemplatesResult ({ resourceTemplates, nextCursor? }). Pagination passes through: send the nextCursor you got back as cursor to read the next page.

Request:

{
"jsonrpc": "2.0",
"method": "resources/list",
"id": "list-1",
"params": { "cursor": "page-2" }
}

Success response (ListResourcesResult):

{
"jsonrpc": "2.0",
"id": "list-1",
"result": {
"resources": [{ "uri": "notes://42", "name": "Standup notes" }],
"nextCursor": "page-3"
}
}

A listing always answers from your app’s own server, never another app’s. A server that serves no resources lists as empty rather than failing.

Long-running tools (work that exceeds the stock MCP request timeout) run as tasks, through the MCP tasks extension (io.modelcontextprotocol/tasks, MCP 2026-07-28). The host declares it in ui/initialize as hostCapabilities.experimental["io.modelcontextprotocol/tasks"]: {}.

Opt a call in. A tools/call opts in by naming the extension in its _meta client capabilities. The server alone decides whether to run it as a task, so the answer is either a complete CallToolResult (the server answered outright) or a task. Without that claim the call is an ordinary one and answers a CallToolResult. A params.task object (the 2025-11-25 tasks utility) is ignored: the call runs as an ordinary one.

{
"jsonrpc": "2.0",
"method": "tools/call",
"id": "call-2",
"params": {
"name": "start_research",
"arguments": { "query": "competitive landscape" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}

A task answer is the task itself, flat, as the server sent it:

{
"jsonrpc": "2.0",
"id": "call-2",
"result": {
"resultType": "task",
"taskId": "dGFza18x",
"status": "working",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:00:00Z",
"ttlMs": 3600000,
"pollIntervalMs": 1000
}
}

ttlMs, pollIntervalMs and statusMessage are present when the server sets them. A result from the server may also carry resultType: "complete", the 2026-07-28 marker of a complete answer; the host passes it through.

Poll and cancel. The host sends no status notifications; poll with tasks/get, at the task’s pollIntervalMs.

Method Returns
tasks/get The task, flat. Once it ends, the outcome is inline: result (a CallToolResult) when status is completed, error ({ code, message }) when it is failed. A tool that ran and reported an error completed: its CallToolResult carries isError: true.
tasks/cancel An acknowledgement, with no task fields. Cancelling is cooperative: the server may still finish the work, and a task already ended stays as it ended.
{
"jsonrpc": "2.0",
"id": "get-2",
"result": {
"resultType": "complete",
"taskId": "dGFza18x",
"status": "completed",
"createdAt": "2026-07-28T00:00:00Z",
"lastUpdatedAt": "2026-07-28T00:02:10Z",
"ttlMs": 3600000,
"result": {
"content": [{ "type": "text", "text": "Report ready" }],
"structuredContent": { "reportId": "r_42" }
}
}
}

A task whose status is input_required passes through as it is; the host answers no input request, so your app decides what to do (typically cancel and report).

A task id is answered only for the server that ran the task: your app’s own server, where your tools/call started it. The host sends the taskId and names that server; any other field you put in params is not forwarded. A task another server ran answers -32602, the same as a task that does not exist or that the server has forgotten.

Send a message to the conversation. Follows the ext-apps spec format with role and content array.

{
"jsonrpc": "2.0",
"method": "ui/message",
"params": {
"role": "user",
"content": [{
"type": "text",
"text": "Show me the 5-day forecast for Honolulu"
}]
}
}

To tell the agent what the user is looking at or acting on, send ui/update-model-context first: the host attaches the app’s latest model context to the turn.

The host answers {}, and { "isError": true } when the handler threw — the message did not reach the conversation, so an app that cares can say so. Sent without an id it is a notification, which takes no answer — an app on an older SDK sends it that way.

Open a URL in a new browser tab with noopener.

The host answers {}. It cannot tell you whether the tab actually opened: the link is opened with noopener, and the browser withholds the handle either way, so an empty result means “delivered”, not “displayed”. Sent without an id it is a notification, which takes no answer.

{
"jsonrpc": "2.0",
"method": "ui/open-link",
"params": {
"url": "https://weather.gov/forecast"
}
}

Report content dimensions so the host can auto-size inline views.

{
"jsonrpc": "2.0",
"method": "ui/notifications/size-changed",
"params": {
"width": 800,
"height": 480
}
}

Push structured state into the agent’s context so the LLM knows what the user is viewing. This is an ext-apps spec method — when the user sends a chat message, this state is included in the system prompt.

{
"jsonrpc": "2.0",
"method": "ui/update-model-context",
"id": "ctx-1",
"params": {
"structuredContent": {
"view": "board",
"filter": "overdue",
"selectedTasks": ["tsk_01", "tsk_02"]
},
"summary": "User is viewing the board with overdue filter, 2 tasks selected"
}
}

The summary field is optional but recommended — it’s used as a fallback when the full state exceeds the token budget. If the message includes an id, the host responds with an empty result.

Hand the user a file, as standard MCP resource blocks.

{
"jsonrpc": "2.0",
"method": "ui/download-file",
"id": "dl-1",
"params": {
"contents": [
{
"type": "resource",
"resource": {
"uri": "file:///weather-report.csv",
"mimeType": "text/csv",
"text": "city,temp\nHonolulu,82"
}
}
]
}
}

Embed the bytes — inline text, or base64 in blob. A ResourceLink (a URI with no payload) is refused, and the host answers { "isError": true }: fetching a URL supplied by iframe code would make the host an SSRF proxy carrying the user’s session. The filename comes from the block’s name, else the last segment of the resource uri.

Ask to be shown inline, fullscreen or pip. The host decides placement from its own layout and does not hand that decision to an app, so the result reports the mode actually in effect. Today that is always inline, whatever you ask for.

{
"jsonrpc": "2.0",
"method": "ui/request-display-mode",
"id": "dm-1",
"params": { "mode": "fullscreen" }
}

Read the mode that comes back rather than assuming the request was granted.

A log line, forwarded to the host’s browser console with your app’s name. A notification, so there is nothing to wait for.

{
"jsonrpc": "2.0",
"method": "notifications/message",
"params": { "level": "warning", "logger": "query", "data": "slow plan" }
}

Ask the shell to open an app or a conversation. Your app names what to open and the shell resolves it, so the app never depends on the shell’s routes.

{
"jsonrpc": "2.0",
"method": "ai.nimblebrain/action",
"params": {
"action": "openConversation",
"id": "conv_abc123"
}
}

The action field is required. All other fields in params are action-specific.

Action Params Description
openConversation { id: string } Open the chat panel and load a specific conversation.
openApp { name: string, target?: string } Open an installed app, named by its route, server name or sidebar label. target is a view inside it, by the stable address that app reports in ai.nimblebrain/location; the shell sends it to that app as ai.nimblebrain/navigate once the app has reported its location, whether it was already open or not. An app opened by this action gets the target at its first report.
openConnectorSettings none Open this connector’s settings page in the current workspace. The connector is always the one that sent the action.

The bridge adds serverName, the server whose view sent the action, to every action’s params, replacing any serverName the app passed. It hands every action to the registered onAction callback, or dispatches it as an nb:action custom event on window when no callback is registered. The shell ignores an action it does not know. To put a message into the conversation, use ui/message; to open an external page, ui/open-link.

Open the OS file picker. The host uploads each chosen file to the workspace file store and answers with the persisted entries, so bytes never cross the iframe boundary.

// App → Host
{
"jsonrpc": "2.0",
"id": "syn-7",
"method": "ai.nimblebrain/request-file",
"params": { "accept": "image/*", "multiple": true, "maxSize": 10485760 }
}
// Host → App
{
"jsonrpc": "2.0",
"id": "syn-7",
"result": {
"files": [
{ "id": "fl_abc123", "filename": "chart.png", "mimeType": "image/png", "size": 48120 }
]
}
}

The result is always { files: [...] }, for one file or many, and a cancel answers { files: [] }. A JSON-RPC result is an object by definition and MCP types it as one, so a client that validates against the spec cannot parse a bare array or null — it never settles the call, and the picker hangs rather than failing.

Every param is optional: accept takes the same value as an <input type="file"> accept attribute and defaults to any file, multiple defaults to false, and maxSize defaults to the instance’s per-file limit. An app may ask for a smaller maxSize, never a larger one. The host also holds the chosen files to the instance’s total per upload, and refuses a set over it before uploading anything, with an error that names the limit. Both are enforced in the browser as a fast fail; the server remains the source of truth, and an oversize file or a failed upload comes back as a JSON-RPC error rather than an empty list.

The limits arrive in hostContext.uploads as { maxFileSize, maxTotalSize }, in bytes, so an app can state them before the user picks.

The call succeeds only when every chosen file was stored. When any file is refused (over maxSize, or refused by the server for its type or size), the host answers a JSON-RPC error whose data names each refused file and why, and lists the files it stored anyway:

// Host → App
{
"jsonrpc": "2.0",
"id": "syn-7",
"error": {
"code": -32602,
"message": "1 of 2 files refused: File \"setup.exe\" has disallowed type: application/x-msdownload. 1 stored.",
"data": {
"files": [
{ "id": "fl_abc123", "filename": "notes.txt", "mimeType": "text/plain", "size": 14 }
],
"errors": ["File \"setup.exe\" has disallowed type: application/x-msdownload"]
}
}
}

An error does not mean nothing was stored: read error.data.files. The browser’s maxSize check runs before any upload, so it stores nothing; the server stores every file that passes. The refusal is an error rather than an extra result field because the SDK’s pickFiles() returns the result’s files alone. With @nimblebrain/synapse, the rejection carries the same data on the thrown error.

Store files the app already holds, such as files dropped on it. It is offered only to the platform’s own Files app: a pick has a step the user takes, and this stores whatever the app hands over, so no other app is declared it, and a request from one is refused with -32601.

A sandboxed app cannot reach the upload endpoint itself, and a tool call is no way to carry a file (its arguments are JSON, capped at 1 MB), so it hands the File objects to the host, which uploads them exactly as it uploads a pick.

// App → Host. `files` holds File objects; postMessage clones them whole.
{
jsonrpc: "2.0",
id: "syn-8",
method: "ai.nimblebrain/upload-files",
params: { files: [file1, file2], maxSize: 10485760 }
}

files holds 1 to 100 Files; an entry that is not a File refuses the whole request before anything is stored. maxSize is optional and, as for a pick, can lower the instance’s per-file limit but not raise it. From there it is a pick: the same total limit, the same { files: [...] } answer, and the same refusal, a JSON-RPC error whose data lists the files stored anyway and why each other was refused.

With @nimblebrain/synapse, call uploadFiles(app, files) (0.24.0+), and use hostSupports(app, "uploadFiles") to decide whether to offer a drop target. The host declares it as experimental["ai.nimblebrain/upload-files"] to the Files app alone.

Show the user a notice: the toast the shell uses for its own saves and errors. Use it to confirm an action that finished (a save, a move, an upload) and for what the user would otherwise miss, such as an export finishing or a background step failing. Keep in your own view what the user has to act on there, such as a field that failed validation or an item that could not be deleted.

{
"jsonrpc": "2.0",
"id": 7,
"method": "ai.nimblebrain/notify",
"params": {
"level": "success",
"title": "Report exported",
"description": "Saved to Files."
}
}
Param Required
level yes success, info, warning or error. It sets the notice’s colour and icon, how long it stays (success 5 s, info 6 s; warning and error until dismissed), and whether a screen reader announces it at once (error) or politely.
title yes 1–120 characters, trimmed.
description no Up to 500 characters.

The host answers {} once the notice is shown. Every notice names the app that sent it, by the name the sidebar shows for it: the connector’s catalog title, else its server name. A built-in app, such as Files, is named by its sidebar label. It is the same from your app’s page and from a chat, and never comes from the message, so the request cannot pick it. The host answers with an error, and shows nothing, for:

  • -32602 and the reason: an unknown level, an empty or over-long title, or an over-long description;
  • -32000: more than 5 notices from your app in 10 seconds.

The MCP Apps spec has no notice. Its notifications/message is a log line for the host’s console (see above) and is never shown as a notice. Send ai.nimblebrain/notify only where the host declared experimental["ai.nimblebrain/notify"].

Forward a keyboard shortcut to the shell. An iframe that has focus receives every keystroke, so without this the shell’s shortcuts stop working while the user is inside an app. The host re-dispatches the key as a keydown on its own document.

{
"jsonrpc": "2.0",
"method": "ai.nimblebrain/keydown",
"params": { "key": "k", "metaKey": true, "ctrlKey": false, "shiftKey": false, "altKey": false }
}

It is a notification and takes no answer. With @nimblebrain/synapse, pass forwardKeys to connect() to forward shortcuts, and the SDK sends only when the host declared experimental["ai.nimblebrain/keydown"].

Tell the shell where the app is, so its top bar shows the current view’s title and, below the app’s root, a breadcrumb of the levels above it. A sandboxed iframe’s URL and history are invisible to the host, so only the app can say this. Send the whole trail, root first, on every navigation inside the app:

{
"jsonrpc": "2.0",
"method": "ai.nimblebrain/location",
"params": {
"trail": [
{ "id": "people://contacts", "label": "People" },
{ "id": "people://contacts/123", "label": "Jane Doe" }
]
}
}
  • Each message replaces the last. The host keeps no history of its own, so a message that never arrived corrects itself on the next, and a deep link straight to a detail view needs nothing special.
  • id is the view’s stable address. Use the MCP resource URI of what the view shows when there is one (people://contacts/123), otherwise a path-like address of your own (contacts/123), never a value that changes between visits such as a list index. The host does not parse it; it hands it back in ai.nimblebrain/navigate, and the same address lets anything that knows the resource, such as the agent, open that view.
  • label is what the bar shows: 1 to 200 characters. A trail holds 1 to 32 entries. A message outside these bounds is dropped.
  • Depth is the trail’s length. The bar shows the last entry’s label as the title and the entries before it as a breadcrumb, each of which goes to its own entry. Past three entries above the title, the middle ones fold into …; the root and the parent stay. Where the bar is too narrow for a breadcrumb, a back control goes to the entry before the last. Either way the bar moves up the app’s structure, not through browser history.
  • Send the trail when a view first renders, not only on navigation, so a reload or a fresh mount replaces whatever the bar showed before.

It is a notification and takes no answer. The host declares it as experimental["ai.nimblebrain/location"], and the declaration also means the host shows your title: drop your view’s own title and back link when it is present, and keep them on a host without it.

A view that sends nothing still gets a title: the bar shows the name of the sidebar entry that opened it, with no breadcrumb.

Sent by the host when the user picks an entry in the top bar (a breadcrumb, or back), or when an openApp action names a view inside your app, naming the view to go to by its id:

{
"jsonrpc": "2.0",
"method": "ai.nimblebrain/navigate",
"params": { "id": "people://contacts" }
}

Navigate there, then send the new trail. The id is either one of your trail’s own or another address your app reports, such as a record the agent read from your tools, so resolve any address you would report, not only the entries on screen.

The host sends it only to an app that has sent a trail, so it needs no declaration of its own. That is also when: an app opened at a view gets it at its first ai.nimblebrain/location, because the handshake completes before an app has subscribed to anything, and a notification no handler is waiting for is lost. Subscribe to navigate no later than you send your first location (useTrail does both in one render).

The bridge enforces four security boundaries:

  1. Workspace scoping — The host reaches the platform over the workspace’s own MCP endpoint, /mcp/<workspaceId>, for the workspace on screen, one request at a time on the endpoint’s 2026-07-28 protocol. There is no session: each request goes to the path of the workspace on screen when it is sent.
  2. Origin isolation — The host only processes messages from the iframe’s contentWindow. Messages from other sources are silently dropped.
  3. Tool scoping — tools/call requests are always routed to the app’s own MCP server. An iframe cannot call tools on another app’s server.
  4. Resource and task scoping — resources/read, resources/list and resources/templates/list answer from the app’s own MCP server, and tasks/get and tasks/cancel answer only for a task that server ran. Every iframe’s requests reach the platform as one client, so the bridge names that server on the request; the iframe cannot name another. A URI another server serves reads as not found, the same as one that does not exist, and so does a task another server ran.

Example: making a tool call from an iframe

Section titled “Example: making a tool call from an iframe”
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<button id="btn" disabled>Get Weather</button>
<pre id="output"></pre>
<script>
let initialized = false;
window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || msg.jsonrpc !== '2.0') return;
// Host responds to our ui/initialize request
if (msg.id === 'init-1' && msg.result) {
initialized = true;
// Send initialized notification
window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/notifications/initialized',
params: {}
}, '*');
document.getElementById('btn').disabled = false;
}
// Tool call response (CallToolResult)
// A tool error is a result too: check `isError`.
if (msg.id === 'weather-1' && msg.result) {
const data = msg.result.structuredContent || msg.result;
if (msg.result.isError) {
document.getElementById('output').textContent =
'Tool error: ' + JSON.stringify(data);
return;
}
document.getElementById('output').textContent =
JSON.stringify(data, null, 2);
}
if (msg.id === 'weather-1' && msg.error) {
document.getElementById('output').textContent =
'Error: ' + msg.error.message;
}
});
// Initiate handshake
window.parent.postMessage({
jsonrpc: '2.0',
method: 'ui/initialize',
id: 'init-1',
params: {
protocolVersion: '2026-01-26',
appInfo: { name: 'weather-demo', version: '1.0.0' },
appCapabilities: {}
}
}, '*');
// Send a tool call when the button is clicked
document.getElementById('btn').addEventListener('click', () => {
window.parent.postMessage({
jsonrpc: '2.0',
method: 'tools/call',
id: 'weather-1',
params: {
name: 'get_weather',
arguments: { city: 'Honolulu' }
}
}, '*');
});
</script>
</body>
</html>

Synapse applies the host’s theme tokens for you. Without it, apply them yourself: they arrive in the ui/initialize response as hostContext.styles.variables and hostContext["ai.nimblebrain/styles"].variables, and again in every ui/notifications/host-context-changed.

window.addEventListener('message', (event) => {
const msg = event.data;
if (!msg || typeof msg !== 'object') return;
// The handshake response (the id your ui/initialize request used)
if (msg.id === 'init-1' && msg.result) {
applyTokens(msg.result.hostContext?.styles?.variables);
applyTokens(msg.result.hostContext?.['ai.nimblebrain/styles']?.variables);
}
// A later change (theme toggle and the like)
if (msg.method === 'ui/notifications/host-context-changed') {
applyTokens(msg.params?.styles?.variables);
applyTokens(msg.params?.['ai.nimblebrain/styles']?.variables);
}
});
function applyTokens(tokens) {
for (const [key, value] of Object.entries(tokens ?? {})) {
document.documentElement.style.setProperty(key, value);
}
}