UI Resources
NimbleBrain apps serve UI as HTML pages through the ui:// protocol. The platform proxies these resources from your MCP server and renders them in sandboxed iframes inside the web client.
How ui:// works
Section titled “How ui:// works”When your manifest declares a resource URI like ui://dashboard, NimbleBrain resolves it through the MCP resource protocol. Your MCP server registers a resource at ui://dashboard that returns HTML content. The web client fetches this HTML through the platform’s resource proxy and renders it in an iframe.
The flow:
- User navigates to your app (e.g.,
/app/my-app). - The web client requests
GET /v1/workspaces/<wsId>/apps/my-app/resources/primary, in the workspace the user is in. - The platform resolves
"primary"to your first placement’sresourceUri. - The platform calls
resources/readon your MCP server for that URI. - The platform returns a
ReadResourceResultenvelope; the web client loads the HTML from it into an iframe. - The MCP App Bridge initializes via
postMessage.
The main view
Section titled “The main view”The main view is the primary HTML page for your app. Declare it as a "main" placement in your manifest — placements are what the host registers and routes from:
{ "_meta": { "ai.nimblebrain/host": { "host_version": "1.0", "placements": [ { "slot": "main", "resourceUri": "ui://dashboard", "route": "my-app", "label": "My App" } ] } }}The virtual primary path derives from the first declared placement’s resourceUri, so the shell can load this view without knowing its URI. Your MCP server must register a resource handler for ui://dashboard that returns an HTML string.
Label it text/html;profile=mcp-app — the MIME type the MCP Apps spec gives a ui:// resource, and what public MCP Apps hosts read to tell an app panel from plain HTML. FastMCP applies it to the ui:// scheme for you.
Python (FastMCP)
Section titled “Python (FastMCP)”from fastmcp import FastMCP
mcp = FastMCP("my-app")
@mcp.resource("ui://dashboard")def dashboard() -> str: return """ <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>Dashboard</title></head> <body> <h1>My App Dashboard</h1> <div id="app"></div> <script> window.addEventListener('message', (event) => { const msg = event.data; if (msg?.id === 'init-1' && msg.result) { window.parent.postMessage( { jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} }, '*'); document.getElementById('app').textContent = 'Connected!'; } }); window.parent.postMessage({ jsonrpc: '2.0', id: 'init-1', method: 'ui/initialize', params: { protocolVersion: '2026-01-26', appInfo: { name: 'my-app', version: '1.0.0' }, appCapabilities: {} } }, '*'); </script> </body> </html> """TypeScript (MCP SDK)
Section titled “TypeScript (MCP SDK)”import { McpServer } from "@modelcontextprotocol/server";
const server = new McpServer({ name: "my-app", version: "1.0.0" });
server.registerResource("dashboard", "ui://dashboard", {}, async () => ({ contents: [{ uri: "ui://dashboard", mimeType: "text/html;profile=mcp-app", text: ` <!DOCTYPE html> <html> <head><meta charset="utf-8"><title>Dashboard</title></head> <body> <h1>My App Dashboard</h1> </body> </html> ` }]}));Resource proxy
Section titled “Resource proxy”The platform API exposes app resources at:
GET /v1/workspaces/:wsId/apps/:name/resources/:path| Parameter | Description |
|---|---|
:wsId |
The workspace the app is installed in. The caller must be a member; any other id gets 404 workspace_error. |
:name |
The source name — a connector’s serverName, or a platform app’s name. |
:path |
The full path after ui:// in your resource URI. |
Resource URIs are independent of your app’s package name. You can name them whatever makes sense for your domain:
# Package: @nimblebraininc/synapse-crm# Resource URI doesn't need to include "synapse-crm"@mcp.resource("ui://crm/main")def crm_ui() -> str: return "<html>...</html>"GET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/crm/mainReturns a JSON envelope in the shape of the MCP ReadResourceResult, so a client reads the protocol directly — including _meta (e.g. ext-apps _meta.ui.csp) — without a translation layer. POST /v1/workspaces/:wsId/resources/read returns the same shape.
{ "contents": [ { "uri": "ui://crm/main", "mimeType": "text/html;profile=mcp-app", "text": "<html>...</html>" } ]}The HTML is contents[0].text. A binary resource carries base64 in blob instead of text. mimeType and _meta appear only when the source declared them, so guard those reads rather than assuming the shape above.
The virtual primary path
Section titled “The virtual primary path”The path primary is special. It resolves to the resourceUri from the first placement in your manifest:
GET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/primary→ reads ui://crm/main (from placement resourceUri)This means apps don’t need to know their own resource URI at runtime. The web client uses primary when loading the main view from a placement.
Serving multiple resources
Section titled “Serving multiple resources”Your app can serve multiple HTML pages. Register each as a separate ui:// resource:
@mcp.resource("ui://crm/main")def main_view() -> str: return "<html>...</html>"
@mcp.resource("ui://crm/settings")def settings() -> str: return "<html>...</html>"
@mcp.resource("ui://crm/contact-detail")def contact_detail() -> str: return "<html>...</html>"Access them through the proxy:
GET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/crm/mainGET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/crm/settingsGET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/crm/contact-detailUse multiple resources when you need separate views for different placements (e.g., a sidebar widget and a main view).
HTML resource guidelines
Section titled “HTML resource guidelines”Your HTML resources are rendered inside iframes. Keep these points in mind:
- Self-contained — Each resource should be a complete HTML document with its own styles and scripts. There is no shared CSS or JS between resources.
- No external dependencies required — You can include external scripts and stylesheets, but the resource must work within an iframe sandbox.
- Theme tokens — Apply the host’s CSS custom properties from the
ui/initializeresponse’shostContext, and again onui/notifications/host-context-changed. See MCP App Bridge for the protocol. - Responsive — The iframe fills the available space. Design your UI to be fluid.
Built-in views use the same path
Section titled “Built-in views use the same path”NimbleBrain’s own surfaces — the conversation list, files, and tasks — use the exact same ui:// pattern as third-party apps. There is no separate “core” code path. Each built-in is an in-process MCP server: it runs inside the platform process (over an in-memory transport) and registers ui:// resources just like a remote connector does.
When the resource proxy receives a request for a built-in’s view, it calls resources/read on that in-process app through the same dispatch path it uses for any other server:
GET /v1/workspaces/ws_000f7ed6658f9d30/apps/files/resources/primary → files app's ui:// resourceThis means the built-ins and your app are governed by one contract. Anything the platform’s own UI can do through ui:// and the bridge, your app can do too.