Skip to main content

UI methods

A UI method declares a React component that an MCP client opens inside the conversation when the AI calls the associated tool. Unlike reader, writer, transaction, and workflow methods, a UI method has no backend servicer code — the React App.tsx component is the implementation.

MCP UIs only

UI methods exist for MCP, where they appear as tools. There is no way to invoke one from a browser or from your backend. Your web and React Native frontends render their own components and call ordinary methods.

Syntax

You declare a UI method in your API definition, alongside your other methods:

show_clicker=UI(
request=None,
path="frontend/mcp/clicker",
title="Counter Clicker",
description="Interactive clicker UI "
"for the counter.",
),

Parameters

ParameterTypeDescription
requestModel subclass or NoneIf provided, the AI passes parameters to the React component as props. If None, the UI takes no parameters from the AI.
pathstrPath to the React app directory relative to the project root. Must contain an index.html.
titlestr or NoneHuman-readable title shown in tool listings.
descriptionstr or NoneDescription used by the AI to decide when to open this UI. Write it as instructions to the AI.

Examples

Zero-config UI

From the mcp-ui-counter example:

show_clicker=UI(
request=None,
path="frontend/mcp/clicker",
title="Counter Clicker",
description="Interactive clicker UI "
"for the counter.",
),

Parameterized UI

From the mcp-ui-counter-dashboard example:

show_dashboard=UI(
request=DashboardConfig,
path="frontend/mcp/dashboard",
title="Counter Dashboard",
description="Dashboard UI. Use "
"`personalized_message` to impart wisdom "
"on the topic of counting things.",
),

The React component receives DashboardConfig fields as props:

import {
type DashboardConfig,
type UseCounterApi,
useCounter,
} from "@api/mcp_ui_counter/v1/counter_rbt_react";

export const DashboardApp: React.FC<DashboardConfig> = ({
personalizedMessage,
}) => {
const { counter, isLoading } = useCounter();

if (isLoading) return <div>loading...</div>;
if (counter === undefined) return <div>No counter available.</div>;

return (
<Dashboard counter={counter} personalizedMessage={personalizedMessage} />
);
};

const Dashboard: React.FC<DashboardConfig & { counter: UseCounterApi }> = ({
counter,
personalizedMessage,
}) => {
const { response } = counter.useGet();
// ...
};

React implementation

Each UI method points to a directory containing:

  • index.html — the Vite entry point.
  • main.tsx — renders the React component wrapped in RebootClientProvider.
  • App.tsx — the component that implements the UI.

The component uses generated hooks (like useCounter()) to interact with backend state. The state ID is automatically provided by the framework when the UI is opened — you do not need to pass it. Called without an id, the hook returns { counter, isLoading }, where counter is undefined until the ID resolves, so check both before using the handle:

import {
type UseCounterApi,
useCounter,
} from "@api/mcp_ui_counter/v1/counter_rbt_react";

export const ClickerApp: FC = () => {
const { counter, isLoading } = useCounter();

if (isLoading) return <div>loading...</div>;
if (counter === undefined) return <div>No counter available.</div>;

return <Clicker counter={counter} />;
};

const Clicker: React.FC<{ counter: UseCounterApi }> = ({ counter }) => {
const { response } = counter.useGet();

const handleIncrement = async () => {
await counter.increment({ amount: 1 });
};

return (
<div>
<span>{response?.value ?? 0}</span>
<button onClick={handleIncrement}>+</button>
</div>
);
};

How it works

When you define a UI method, Reboot generates two MCP artifacts:

  1. An MCP tool that the AI calls to open the UI. The tool returns metadata telling the AI client to display the visual interface.
  2. An MCP resource that serves the compiled HTML/JS/CSS. The AI client loads this resource in an embedded browser.

The React app talks to your backend through the same generated hooks as any other frontend, so its reads are reactive and its mutations retry safely.

Bundling UI methods

Each UI method's React app must be bundled into a single HTML file so that the Reboot server can serve it as an MCP resource. Use Vite with vite-plugin-singlefile.

The agent skills write the vite.config.ts and build.mjs below for you. If you are setting a project up by hand, copy them from an example such as bank-pydantic.

Dependencies

npm install -D @vitejs/plugin-react vite vite-plugin-singlefile

Vite configuration

One vite.config.ts drives three jobs: the dev server, and two builds selected by the RBT_BUILD_TARGET env var (set per UI by build.mjs) — a per-UI MCP build (RBT_BUILD_TARGET=mcp:<name>) and the standalone web/ SPA build (RBT_BUILD_TARGET=web). It auto-discovers MCP UIs from the mcp/ directory (each subdirectory with an index.html).

The full frontend/vite.config.ts
// Vite configuration for Reboot UIs.
//
// One config drives three jobs. The dev server is selected by
// `command === "serve"`; the two build shapes are selected by the
// `RBT_BUILD_TARGET` env var, set per UI by `build.mjs`. `mode` keeps
// its conventional Vite values — `development` (serve) / `production`
// (build) — so the matching `.env.<mode>` files load as usual:
//
// * `vite` (serve): a single dev server that delivers HMR for every
// `mcp/<name>` UI AND the standalone `web/` SPA, all under
// `base: "/__/frontend/"`. Envoy proxies that prefix to this dev
// server (`run --config=hmr`).
// * `RBT_BUILD_TARGET=mcp:<name> vite build`: builds one MCP UI into
// a single, self-contained `dist/mcp/<name>/index.html` (assets
// inlined via `vite-plugin-singlefile`). The framework serves it
// at `/__/frontend/mcp/<name>/index.html` in dist mode.
// * `RBT_BUILD_TARGET=web vite build`: builds the `web/` SPA into
// `dist/web/` with normal (non-inlined) assets. Its `base` is
// `/__/frontend/web/` so asset URLs resolve when served at that
// prefix. In Vite's `production` mode, it reads
// `web/.env.production`.
import fs from "fs";
import path from "path";
import react from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

// A served directory under `/__/frontend/` requested without its
// trailing slash (e.g. `/__/frontend/web`) doesn't match Vite's static
// index.html serving, which expects `/__/frontend/web/`, so it 404s.
// Redirect the slash-less form to the canonical trailing-slash form so
// the `web/` SPA and each `mcp/<name>` UI load with or without the
// trailing slash — matching how the framework's dist-mode server
// behaves. Only a path that resolves to a real directory with an
// `index.html` is redirected, so Vite's own internal module URLs
// (`@vite/client`, `@react-refresh`) and source or asset files fall
// through untouched.
function redirectFrontendDirTrailingSlash(root: string): Plugin {
const prefix = "/__/frontend/";
return {
name: "reboot-frontend-dir-trailing-slash",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url ?? "";
const queryAt = url.indexOf("?");
const pathname = queryAt === -1 ? url : url.slice(0, queryAt);
if (pathname.startsWith(prefix) && !pathname.endsWith("/")) {
const subpath = pathname.slice(prefix.length);
if (fs.existsSync(path.join(root, subpath, "index.html"))) {
const query = queryAt === -1 ? "" : url.slice(queryAt);
// 302 (not 301): a permanent redirect would be cached by the
// browser, which is wrong for a dev server whose routes can
// change between runs.
res.statusCode = 302;
res.setHeader("Location", `${pathname}/${query}`);
res.end();
return;
}
}
next();
});
},
};
}

// When a `web/` SPA exists, treat it as the dev server's home: the bare
// `/`, `/__/frontend`, and `/__/frontend/` otherwise 404 (there's no
// index there), so silently redirect them to `/__/frontend/web/`. Also
// print the friendlier `http://<host>/` at startup instead of the
// `/__/frontend/` base URL Vite would otherwise show (which is a dead
// link).
function serveWebAppAtRoot(root: string): Plugin {
const webIndex = path.resolve(root, "web", "index.html");
const target = "/__/frontend/web/";
const homes = new Set(["/", "/__/frontend", "/__/frontend/"]);
return {
name: "reboot-serve-web-app-at-root",
configureServer(server) {
if (!fs.existsSync(webIndex)) return;
server.middlewares.use((req, res, next) => {
const url = req.url ?? "";
const queryAt = url.indexOf("?");
const pathname = queryAt === -1 ? url : url.slice(0, queryAt);
if (homes.has(pathname)) {
const query = queryAt === -1 ? "" : url.slice(queryAt);
res.statusCode = 302;
res.setHeader("Location", target + query);
res.end();
return;
}
next();
});
// Print `http://<host>/` instead of the `/__/frontend/` base.
const printUrls = server.printUrls.bind(server);
server.printUrls = () => {
const urls = server.resolvedUrls;
if (urls) {
const toRoot = (u: string) => u.replace(/\/__\/frontend\/$/, "/");
urls.local = urls.local.map(toRoot);
urls.network = urls.network.map(toRoot);
}
printUrls();
};
},
};
}

// Auto-discover MCP UIs: every `mcp/<name>/` with an `index.html`.
// There may be no `mcp/` directory at all (a web-only frontend, or
// one whose last MCP UI was removed), so guard the read.
const mcpDir = path.resolve(__dirname, "mcp");
const mcpNames: string[] = fs.existsSync(mcpDir)
? fs
.readdirSync(mcpDir)
.filter((name) => fs.existsSync(path.resolve(mcpDir, name, "index.html")))
: [];

// Path alias for API imports (`@api/...` -> `./api/...`).
const resolve = {
alias: {
"@api": path.resolve(__dirname, "./api"),
},
dedupe: ["react", "react-dom", "zod"],
};

export default defineConfig(({ command }) => {
// Dev server: serves both the MCP UIs and the `web/` SPA.
//
// UIs use a double iframe architecture:
// MCP Host -> srcdoc (origin=null) -> iframe (origin=localhost:9991)
//
// The inner iframe loads from Envoy ("/__/frontend/**"), which
// proxies to Vite. Because the inner iframe has a real origin,
// Vite's URLs work normally. `base: "/__/frontend/"` ensures all
// paths route through Envoy.
//
// The standalone `web/` SPA is served at `/__/frontend/web/` by this
// same server. It reaches the backend via `VITE_REBOOT_URL` (see
// `web/.env.development`) rather than its own origin, so opening it
// straight from this dev server exercises the real cross-origin
// frontend/backend path — the same shape as a production deploy.
//
// Hot Module Replacement works automatically: Vite's client connects
// to the page's origin, and Envoy proxies WebSocket upgrades to
// Vite. This also works with tunnels (ngrok) since the tunnel
// points to Envoy.
if (command === "serve") {
const port = parseInt(process.env.RBT_VITE_PORT || "4444", 10);

return {
plugins: [
react(),
redirectFrontendDirTrailingSlash(__dirname),
serveWebAppAtRoot(__dirname),
],
root: ".",
// Read `.env*` from `web/` (alongside the SPA), so the `web/`
// SPA's `VITE_REBOOT_URL` is picked up by both this serve and the
// `web/` production build, which is also rooted there.
envDir: path.resolve(__dirname, "web"),
resolve,
base: "/__/frontend/",
server: {
port,
strictPort: true,
// Listen on all interfaces since requests come through
// Envoy (and tunnels).
host: true,
allowedHosts: true,
},
};
}

// Which UI a build targets is read from `RBT_BUILD_TARGET` (set by
// `build.mjs`), not Vite's `mode` — so `mode` stays `production`
// and the `web/` build below reads `web/.env.production`.
const target = process.env.RBT_BUILD_TARGET ?? "";

// Build the standalone `web/` SPA into `dist/web/`. We root the
// build at `web/` so `index.html` and its `assets/` land directly
// under `dist/web/` (rather than `dist/web/web/`). Keep assets as
// separate files (a normal multi-file build) and set `base` so
// their URLs resolve when the SPA is served at `/__/frontend/web/`.
if (target === "web") {
return {
plugins: [react()],
root: path.resolve(__dirname, "web"),
base: "/__/frontend/web/",
build: {
outDir: path.resolve(__dirname, "dist/web"),
emptyOutDir: true,
},
resolve,
};
}

// Build one MCP UI (`RBT_BUILD_TARGET=mcp:<name>`). We root the
// build at `mcp/<name>/` so the output lands directly at
// `dist/mcp/<name>/index.html`, a single self-contained file
// (assets inlined by `vite-plugin-singlefile`). The framework
// serves it at `/__/frontend/mcp/<name>/index.html` in dist mode.
const name = target.startsWith("mcp:") ? target.slice("mcp:".length) : "";
if (!mcpNames.includes(name)) {
const valid = mcpNames.map((n) => `mcp:${n}`).join(", ");
throw new Error(
`Unknown build target: ${target || "(unset)"}. Set ` +
`RBT_BUILD_TARGET=web or one of: ${valid}.`
);
}

return {
plugins: [react(), viteSingleFile()],
root: path.resolve(__dirname, "mcp", name),
base: "/__/frontend/",
// Read `.env*` from `web/` (the shared frontend env), so an MCP UI
// can pick up e.g. `VITE_WEB_APP_URL` for a pop-out link.
envDir: path.resolve(__dirname, "web"),
build: {
outDir: path.resolve(__dirname, "dist/mcp", name),
emptyOutDir: true,
assetsInlineLimit: 100000000,
cssCodeSplit: false,
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
resolve,
};
});

Building

Build one UI by name by setting RBT_BUILD_TARGET (or build every UI and the web/ SPA at once with npm run build):

cd frontend && RBT_BUILD_TARGET=mcp:clicker npx vite build

The bundled HTML file is written to frontend/dist/mcp/clicker/index.html. The Reboot server discovers and serves it as an MCP resource.

No backend servicer

UI methods are skipped in your servicer class. You only implement reader, writer, transaction, and workflow methods in the servicer.