Skip to content

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.

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:

  1. User navigates to your app (e.g., /app/my-app).
  2. The web client requests GET /v1/workspaces/<wsId>/apps/my-app/resources/primary, in the workspace the user is in.
  3. The platform resolves "primary" to your first placement’s resourceUri.
  4. The platform calls resources/read on your MCP server for that URI.
  5. The platform returns a ReadResourceResult envelope; the web client loads the HTML from it into an iframe.
  6. The MCP App Bridge initializes via postMessage.

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.

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>
"""
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>
`
}]
}));

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/main

Returns 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 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.

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/main
GET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/crm/settings
GET /v1/workspaces/ws_000f7ed6658f9d30/apps/synapse-crm/resources/crm/contact-detail

Use multiple resources when you need separate views for different placements (e.g., a sidebar widget and a main view).

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/initialize response’s hostContext, and again on ui/notifications/host-context-changed. See MCP App Bridge for the protocol.
  • Responsive — The iframe fills the available space. Design your UI to be fluid.

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:// resource

This 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.