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/@myorg/my-app).
  2. The web client requests GET /v1/apps/my-app/resources/primary.
  3. The platform resolves "primary" to your primaryView.resourceUri.
  4. The platform calls resources/read on your MCP server for that URI.
  5. The HTML response is returned to the web client and loaded into an iframe.
  6. The MCP App Bridge initializes via postMessage.

The primaryView is the main HTML page for your app. Declare it in your manifest:

{
"_meta": {
"ai.nimblebrain/synapse": {
"name": "My App",
"icon": "database",
"primaryView": {
"resourceUri": "ui://dashboard"
}
}
}
}

Your MCP server must register a resource handler for ui://dashboard that returns an HTML string.

from mcp.server.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?.method === 'ui/initialize') {
document.getElementById('app').textContent = 'Connected!';
}
});
</script>
</body>
</html>
"""
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({ name: "my-app", version: "1.0.0" });
server.resource("dashboard", "ui://dashboard", async () => ({
contents: [{
uri: "ui://dashboard",
mimeType: "text/html",
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/apps/:name/resources/:path
ParameterDescription
:nameThe server name (derived from the bundle name).
:pathThe resource path, matching the part after ui:// in your resource URI.

For example, if your app’s server name is my-app and it serves ui://dashboard:

GET /v1/apps/my-app/resources/dashboard

Returns the HTML with Content-Type: text/html.

The path primary is special. It resolves to whatever URI is set in primaryView.resourceUri:

GET /v1/apps/my-app/resources/primary
→ reads ui://dashboard (from primaryView.resourceUri)

This means apps don’t need to know their own resource URI at runtime. The web client always requests primary when loading the main view.

The resolution logic strips the ui:// prefix from the primaryView.resourceUri and uses the remainder as the actual resource path:

// From handleResourceProxy in routes.ts
if (resourcePath === "primary" && instance.ui?.primaryView?.resourceUri) {
resolvedPath = instance.ui.primaryView.resourceUri.replace(/^ui:\/\//, "");
}

Your app can serve multiple HTML pages. Register each as a separate ui:// resource:

@mcp.resource("ui://dashboard")
def dashboard() -> str:
return "<html>...</html>"
@mcp.resource("ui://settings")
def settings() -> str:
return "<html>...</html>"
@mcp.resource("ui://detail")
def detail() -> str:
return "<html>...</html>"

Access them through the proxy:

GET /v1/apps/my-app/resources/dashboard
GET /v1/apps/my-app/resources/settings
GET /v1/apps/my-app/resources/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 — Listen for ui/initialize and ui/notifications/host-context-changed to apply the host’s CSS custom properties. See MCP App Bridge for the protocol.
  • Responsive — The iframe fills the available space. Design your UI to be fluid.

NimbleBrain itself uses the same ui:// pattern for built-in views like the conversation list, settings, and home dashboard. These are served from in-memory templates by the core and home servers rather than from MCP subprocess resources.

When the resource proxy receives a request for core or home, it serves from the internal getCoreResource() registry instead of calling an MCP server:

GET /v1/apps/core/resources/conversations → built-in conversations panel
GET /v1/apps/home/resources/primary → built-in home dashboard

Third-party apps always go through the MCP resource read path.