> ## Documentation Index
> Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Building Tool UI

> Create rich widgets for tool outputs using @frontmcp/ui components

FrontMCP tools can render rich HTML widgets for display in OpenAI Apps, Claude Artifacts, and other UI-capable hosts. This guide shows you how to use the `@frontmcp/ui` library to build professional-looking tool outputs.

<Info>
  **Prerequisites**:

  * A working FrontMCP tool ([see Your First Tool](/frontmcp/guides/your-first-tool))
  * Basic understanding of HTML/CSS
</Info>

## What You'll Build

A weather tool that displays temperature, conditions, and other data in a styled card with a badge.

***

## Step 1: Install the UI Package

<CodeGroup>
  ```bash npm theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  npm install @frontmcp/ui
  ```

  ```bash pnpm theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  pnpm add @frontmcp/ui
  ```

  ```bash yarn theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  yarn add @frontmcp/ui
  ```
</CodeGroup>

<Info>
  **Required for `.tsx`/`.jsx` FileSource widgets.** When you point `ui.template` at a `.tsx`/`.jsx` file (the recommended pattern for non-trivial widgets), FrontMCP injects an auto-generated React mount that imports `McpBridgeProvider` from `@frontmcp/ui/react`. Server-side bundling fails without this package installed. Match the version to `@frontmcp/sdk`. `react` and `react-dom` stay external and load from the CDN at runtime, so only `@frontmcp/ui` needs to be present on disk.
</Info>

<Info>
  **`.tsx`/`.jsx` widgets also need `esbuild` at runtime.** `@frontmcp/uipack` loads `esbuild` on demand to bundle the widget when the tool is called, so it has to be installed where the server runs — as a dependency, not a devDependency:

  ```bash theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
  npm install esbuild
  ```

  Projects created with `frontmcp create` already have it through the `frontmcp` package. Without it, the call fails with an error that names the widget.
</Info>

<Warning>
  **Anchor FileSource paths to the tool file.** Relative `template: { file: './widget.tsx' }` paths are resolved against `process.cwd()`, not the tool source's directory. A widget at `src/tools/foo.widget.tsx` referenced as `./foo.widget.tsx` from `src/tools/foo.tool.ts` will fail with `ENOENT` at tool-call time. Use `fileURLToPath(new URL('./widget.tsx', import.meta.url))` (from `node:url`) or pass an absolute path. In a **CommonJS** project, `import.meta.url` is unavailable — use `join(__dirname, 'widget.tsx')` (from `node:path`) instead.

  The widget file is read when the tool is called, from where the **compiled** tool resolves that path — so it has to ship with the build. `frontmcp build` copies `*.widget.tsx` / `*.widget.jsx` files into the output (next to the bundle for the bundled `node`, `cli`, `lambda` and `vercel` targets, so keep widget file names unique). A plain `tsc` build doesn't copy them.
</Warning>

<Info>
  **`resourceMode` is host-detected.** Leave it unset and FrontMCP auto-switches to `'inline'` when the connecting client is Claude (#456), so the widget actually renders in Claude's sandboxed iframe — React is bundled into the widget itself (#454). Other hosts keep `'cdn'` for a smaller payload. Set `resourceMode` explicitly to override the detection. Auto-detection only applies to per-call rendering modes (inline / hybrid / lean); `servingMode: 'static'` widgets are pre-compiled at startup with no client context, so set `resourceMode: 'inline'` explicitly when targeting Claude in static mode.
</Info>

<Info>
  **`ui.csp` is now emitted on the widget resource (#455).** FrontMCP attaches `ui.csp` to the `resources/read` content item's `_meta.ui.csp` (and `_meta['ui/csp']`) so MCP Apps hosts — particularly Claude — actually honor it. Previously the CSP only appeared on the tool listing, where Claude ignored it.
</Info>

<Info>
  **Use the `*.widget.tsx` naming convention.** The scaffolded `tsconfig.json` excludes `**/*.widget.tsx` / `**/*.widget.jsx` from the server typecheck (`frontmcp init` also adds the excludes to existing tsconfigs). Widget sources are bundled separately by uipack/esbuild at render time, so the server tsconfig doesn't need `jsx: 'react-jsx'` or `@types/react` for them. Add a sibling `tsconfig.widget.json` if you want IDE typecheck for widget files.
</Info>

***

## Step 2: Create a Tool with UI Template

Point `ui.template` at a `.tsx` widget file. The widget is a React component that receives the tool's `output` (and `loading`) and is built from the `@frontmcp/ui/components` library.

```tsx weather.widget.tsx theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { Badge, Card } from '@frontmcp/ui/components';

interface WeatherOutput {
  location: string;
  temperature: number;
  units: 'celsius' | 'fahrenheit';
  conditions: string;
  humidity: number;
  windSpeed: number;
}

export default function WeatherWidget({ output, loading }: { output: WeatherOutput | null; loading?: boolean }) {
  if (loading || !output) {
    return <Card title="Weather" subtitle="Loading..." />;
  }

  const symbol = output.units === 'celsius' ? '°C' : '°F';

  return (
    <Card title={output.location} subtitle="Current Weather" elevation={2}>
      <div style={{ textAlign: 'center', padding: '16px 0' }}>
        <div style={{ fontSize: '3rem' }}>
          {output.temperature}
          {symbol}
        </div>
        <Badge variant={output.conditions === 'sunny' ? 'success' : 'default'}>{output.conditions}</Badge>
      </div>
      <dl>
        <dt>Humidity</dt>
        <dd>{output.humidity}%</dd>
        <dt>Wind speed</dt>
        <dd>{output.windSpeed} km/h</dd>
      </dl>
    </Card>
  );
}
```

```typescript weather.tool.ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { fileURLToPath } from 'node:url';
import { Tool, ToolContext, z } from '@frontmcp/sdk';

const widgetPath = fileURLToPath(new URL('./weather.widget.tsx', import.meta.url));

const outputSchema = z.object({
  location: z.string(),
  temperature: z.number(),
  units: z.enum(['celsius', 'fahrenheit']),
  conditions: z.string(),
  humidity: z.number(),
  windSpeed: z.number(),
});

@Tool({
  name: 'get_weather',
  description: 'Get current weather for a location',
  inputSchema: {
    location: z.string().describe('City name or location'),
    units: z.enum(['celsius', 'fahrenheit']).optional().describe('Temperature units'),
  },
  outputSchema,
  ui: {
    servingMode: 'static',
    template: { file: widgetPath },
  },
})
export default class GetWeatherTool extends ToolContext {
  async execute(input: { location: string; units?: 'celsius' | 'fahrenheit' }) {
    // In production, call a real weather API
    return {
      location: input.location,
      temperature: 22,
      units: input.units || 'celsius',
      conditions: 'sunny',
      humidity: 55,
      windSpeed: 10,
    };
  }
}
```

<Info>
  **`@frontmcp/ui` has no `card()`, `badge()`, `descriptionList()`, `button()`, `form()` or `input()` functions.** It is a React library: the components live at `@frontmcp/ui/components` (`Card`, `Badge`, `Button`, `Alert`, `Avatar`, `Modal`, `Table`, `TextField`, `Select`, `List`, `Loader`) and the bridge hooks at `@frontmcp/ui/react`. Older examples that import those lower-case helpers from `@frontmcp/ui` do not compile.
</Info>

### Inline HTML alternative

For a one-off widget with no React, return markup from the template function. Build it with the `html` tagged template so every interpolated value is escaped:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { type TemplateContext } from '@frontmcp/sdk';

ui: {
  template: (ctx: TemplateContext<{ location: string }, WeatherOutput>) => {
    const { html } = ctx.helpers;
    return html`
      <div class="weather">
        <h2>${ctx.output.location}</h2>
        <p>${ctx.output.temperature}° — ${ctx.output.conditions}</p>
      </div>
    `;
  },
}
```

***

## UI Configuration Options

<ParamField path="ui.widgetDescription" type="string">
  Human-readable description of what the widget displays. Shown to users in UI-capable hosts.
</ParamField>

<ParamField path="ui.displayMode" type="'inline' | 'fullscreen' | 'pip'">
  Preferred display mode (hint to the host — may be ignored):

  * `inline` - Rendered inline in the conversation (default)
  * `fullscreen` - Request fullscreen display
  * `pip` - Picture-in-picture
</ParamField>

<ParamField path="ui.servingMode" type="'auto' | 'inline' | 'static' | 'hybrid' | 'direct-url' | 'custom-url'">
  How the HTML is delivered to the client (default: `auto`):

  * `auto` - Auto-select per host (OpenAI / Claude / unknown)
  * `inline` - Embedded in tool response `_meta['ui/html']` (works everywhere)
  * `static` - Pre-compiled at startup; client fetches `ui://widget/{toolName}.html` via `resources/read`
  * `hybrid` - Shell pre-compiled at startup; each call carries only a **reference** in `_meta['ui/component']` (`{ type, hash, toolName }`) — no component code and no data. The widget is still rendered from its `ui://` resource
  * `direct-url` / `custom-url` - **Not implemented.** Accepted by the schema, but the widget is served inline, as with `inline`; startup logs a warning
</ParamField>

<Note>
  **`resources/read` never returns another call's render.** `ui://widget/{toolName}.html` serves only HTML compiled at startup (`static`, and the `hybrid` shell), rendered without any caller's data; for other modes it serves a data-free placeholder that receives the result through the bridge. A per-call `inline` render embeds that call's input and output, so it is returned only in that call's `_meta['ui/html']` and is never stored where `resources/read` can reach it. Hosts that load the widget with `resources/read` (MCP Apps hosts such as Claude) should use `servingMode: 'static'` with a template that reads its data from `window.FrontMcpBridge`.

  The tool name in the URI is percent-encoded, as `tools/list` advertises it: a namespaced `app:tool` is `ui://widget/app%3Atool.html`. Both the encoded and the raw form read back; a name that decodes to anything other than tool-name characters (letters, digits, `_ - . / : @`) is rejected.
</Note>

<ParamField path="ui.template" type="function">
  A function that receives the execution context and returns markup — preferably built with the `ctx.helpers.html` tagged template (see [Trusted markup](#trusted-markup)), or a string.
</ParamField>

<ParamField path="ui.escapeStringResults" type="boolean">
  HTML-escape a plain string returned by the template function; `html` / `trustedHtml` results still render as markup. Unset (the 1.8 default) renders strings that look like HTML as markup and logs a one-time notice per tool; `false` keeps that behaviour without the notice. Overrides the server-wide `@FrontMcp({ ui: { escapeStringResults } })` default. **FrontMCP 1.9 escapes string results by default.** See [Escaping string results](#escaping-string-results).
</ParamField>

***

## Available UI Components

Widgets use the React components from `@frontmcp/ui/components`:

```tsx theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { Alert, Badge, Button, Card, Table, TextField } from '@frontmcp/ui/components';

<Card title="Order 1042" subtitle="Placed today" elevation={2}>
  <Badge variant="success">Paid</Badge>
  <Alert severity="info">Ships in 2 days</Alert>
  <TextField label="Note" name="note" />
  <Button variant="primary" onClick={() => {}}>Reorder</Button>
</Card>
```

The full set is `Alert`, `Avatar`, `Badge`, `Button`, `Card`, `List`, `Loader`, `Modal`, `Select`, `Table` and `TextField`. Each has an exported `<Name>Props` type.

### Widget hooks

`@frontmcp/ui/react` gives a widget access to the host through the MCP bridge: `McpBridgeProvider` (added for you around a `.tsx` FileSource widget), `useToolInput`, `useToolOutput`, `useStructuredContent`, `useCallTool`, `useTheme`, `useDisplayMode`, `useHostContext`, `useSendMessage` and `useOpenLink`.

`useCallTool` returns a **tuple** `[call, state, reset]`, not an object. `state` is `{ data, loading, error, called }`:

```tsx theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { useCallTool } from '@frontmcp/ui/react';

function Refresh() {
  const [getWeather, { data, loading, error }, reset] = useCallTool<{ location: string }, { temperature: number }>(
    'get_weather',
  );

  return (
    <div>
      <button disabled={loading} onClick={() => getWeather({ location: 'NYC' })}>
        Refresh
      </button>
      {error && <p role="alert">{String(error)}</p>}
      {data && <p>{data.temperature}°</p>}
      <button onClick={reset}>Clear</button>
    </div>
  );
}
```

Whether the host lets a widget call tools is the host's decision; the `widgetAccessible` option has no effect yet (see [Options that have no effect yet](#options-that-have-no-effect-yet)).

***

## Template Context

The template function receives a context object with:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { type TemplateContext } from '@frontmcp/sdk';

// Annotate `ctx` explicitly — see the TypeScript note below.
template: (ctx: TemplateContext<MyInput, MyOutput>) => {
  // Input that was passed to the tool
  const { location, units } = ctx.input;

  // Output from execute()
  const { temperature, conditions } = ctx.output;

  // Helper functions
  const { html, trustedHtml, escapeHtml, formatDate, formatCurrency, uniqueId, jsonEmbed } = ctx.helpers;

  // `html` escapes every interpolated value — no manual escapeHtml needed
  return html`<p>${location}: ${temperature}° ${conditions}</p>`;
}
```

<Warning>
  **TypeScript: `ctx` must be annotated explicitly (TS7006).** Under `strict` / `noImplicitAny`, `template: (ctx) => …` fails with `Parameter 'ctx' implicitly has an 'any' type`. The `ui.template` field is a union of multiple callable shapes (`TemplateBuilderFn | string | ((props: any) => any) | FileSource`), so TypeScript can't pick a single contextual type for the arrow's parameter. Either annotate `ctx: TemplateContext<MyInput, MyOutput>` (imported from `@frontmcp/sdk`), or move the widget into its own `.tsx` file and use the `FileSource` form (`template: { file: … }`).
</Warning>

### Available Helpers

| Helper | Description |
| - | - |
| `` html`…` `` | Tagged template that builds trusted markup; interpolated values are escaped unless trusted |
| `trustedHtml(markup)` | Mark markup you produced or sanitized yourself as trusted |
| `escapeHtml(str)` | Escape HTML entities to prevent XSS (handles `null`/`undefined`) |
| `formatDate(date, format?)` | Format a date (accepts `Date` or ISO string) |
| `formatCurrency(amount, ccy?)` | ISO-4217 currency formatting (defaults to `'USD'`) |
| `uniqueId(prefix?)` | Generate a unique ID for DOM elements |
| `jsonEmbed(data)` | Safely embed JSON in an inline `<script>` (escapes `<`, `>`, `&`) |

### Trusted markup

Build the markup a template returns with the `html` tagged template from `ctx.helpers`. Its literal parts are your markup; every value you interpolate is HTML-escaped unless it is itself trusted markup, so tool output can't inject tags:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
template: (ctx: TemplateContext<MyInput, MyOutput>) => {
  const { html } = ctx.helpers;

  return html`
    <h2>${ctx.output.title}</h2>
    <ul>
      ${ctx.output.items.map((item) => html`<li>${item.name}</li>`)}
    </ul>
    ${ctx.output.note && html`<p class="note">${ctx.output.note}</p>`}
  `;
},
```

* Nested `html` values are inserted as-is and never escaped twice. Arrays are joined without a separator; `null`, `undefined` and `false` render nothing.
* Don't pre-escape values with `escapeHtml` inside `html` — they would be escaped twice.
* Quote attribute values (`title="${value}"`). Escaping keeps a value inside a quoted attribute, but it doesn't validate URLs — check `href` / `src` values yourself.
* Wrap markup that is already safe — HTML you generated or sanitized yourself — with `ctx.helpers.trustedHtml(markup)`. Never wrap raw tool output or user input.
* Inside an inline `<script>`, embed data with `${trustedHtml(jsonEmbed(data))}`: `jsonEmbed` output is safe in a script, and `html` would otherwise HTML-escape its quotes.

`html`, `trustedHtml`, `isTrustedHtml` and the `TrustedHtml` type are also exported from `@frontmcp/uipack`; `TrustedHtml` is re-exported from `@frontmcp/sdk`.

### Escaping string results

A template function that returns a plain **string** has it rendered as markup when it looks like HTML, so `template: (ctx) => ctx.output` renders any tags in the output. Opt in to escaping plain string results per tool, or for the whole server:

```typescript theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
// Per tool
ui: { template, escapeStringResults: true }

// Server-wide default — a tool's own setting wins
@FrontMcp({ info, apps, ui: { escapeStringResults: true } })
```

| `escapeStringResults` | Plain string result | `html` / `trustedHtml` result |
| - | - | - |
| unset (1.8 default) | Rendered as markup; a one-time notice is logged for the tool | Rendered as markup |
| `true` | Escaped and shown as text | Rendered as markup |
| `false` | Rendered as markup, no notice | Rendered as markup |

<Warning>
  **The default changes in FrontMCP 1.9: plain string results will be escaped.** To migrate, return `html` / `trustedHtml` from every template function, then set `escapeStringResults: true` to confirm none still returns a plain markup string. Until then, each tool whose template returns a plain markup string logs a notice once, naming the tool.
</Warning>

Other results are always escaped: plain text is shown as text, an object as JSON inside `<pre>`, and a chart config or base64 PDF (`JVBERi…`) is embedded as script data. A value that starts with the PDF signature but is not base64 is shown as text. A static string template (`template: '<div>…</div>'`) is your own markup and is never escaped.

***

## Practical Example: Expense Summary

```tsx expense.widget.tsx theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { Badge, Card } from '@frontmcp/ui/components';

type Status = 'under_budget' | 'at_budget' | 'over_budget';
const variants = { under_budget: 'success', at_budget: 'warning', over_budget: 'error' } as const;

export default function ExpenseWidget({
  output,
}: {
  output: { userName: string; totalExpenses: number; pendingCount: number; approvedCount: number; status: Status } | null;
}) {
  if (!output) return <Card title="Expenses" subtitle="Loading..." />;

  return (
    <Card title={output.userName} elevation={2}>
      <Badge variant={variants[output.status]}>{output.status.replace('_', ' ').toUpperCase()}</Badge>
      <dl>
        <dt>Total</dt>
        <dd>{new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(output.totalExpenses)}</dd>
        <dt>Pending</dt>
        <dd>{output.pendingCount}</dd>
        <dt>Approved</dt>
        <dd>{output.approvedCount}</dd>
      </dl>
    </Card>
  );
}
```

```typescript expense.tool.ts theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import { fileURLToPath } from 'node:url';
import { Tool, ToolContext, z } from '@frontmcp/sdk';

const widgetPath = fileURLToPath(new URL('./expense.widget.tsx', import.meta.url));

@Tool({
  name: 'expense_summary',
  description: 'Get expense summary for a user',
  inputSchema: { userId: z.string() },
  outputSchema: z.object({
    userName: z.string(),
    totalExpenses: z.number(),
    pendingCount: z.number(),
    approvedCount: z.number(),
    status: z.enum(['under_budget', 'at_budget', 'over_budget']),
  }),
  ui: { servingMode: 'static', template: { file: widgetPath } },
})
export default class ExpenseSummaryTool extends ToolContext {
  async execute(input: { userId: string }) {
    // Fetch from database in production
    return {
      userName: 'John Doe',
      totalExpenses: 1234.56,
      pendingCount: 3,
      approvedCount: 12,
      status: 'under_budget' as const,
    };
  }
}
```

***

## Markdown and MDX strings

A string template that does not contain both `<` and `>` is treated as **Markdown** and converted to HTML on the server. The converter supports headings, paragraphs, fenced code, bullet and numbered lists, and inline bold, italic, code and links. Raw HTML in the Markdown is escaped, and a link is kept only when its target starts with `http:`, `https:`, `mailto:`, `/` or `#` — otherwise only the link text is kept.

A string that contains both `<` and `>` is treated as **HTML** and used as written. **MDX is not compiled**: JSX tags in a string reach the client as HTML, `{output.field}` expressions are not evaluated, and `mdxComponents` is ignored. For anything interactive, use a `.tsx` FileSource widget.

## Sanitization

* Markup you author (a template function's literal parts, an HTML string, a `.tsx` file) is trusted and is not sanitized.
* Values interpolated into `` html`…` `` are escaped. Values returned as plain strings from a template function are escaped only with `escapeStringResults: true` (see above).
* Markdown strings are always escaped as described in the previous section.
* Tool output shown by a `.tsx` widget is rendered by React, which escapes text nodes.

## Options that have no effect yet

These `ui` options pass schema validation but nothing reads them, so setting one changes nothing. Startup logs a single warning per tool that lists them:

`widgetDescription`, `widgetAccessible`, `displayMode`, `prefersBorder`, `sandboxDomain`, `contentSecurity`, `hydrate`, `runtimeOptions`, `mdxComponents`, `bundlingMode`, `uiType`, `htmlResponsePrefix`.

`servingMode: 'direct-url'` and `'custom-url'` are also not implemented (the widget is served inline), and `'hybrid'` sends only a reference (see [UI Configuration Options](#ui-configuration-options)). There is no dual HTML payload for Claude: the tool result carries no extra HTML text block, so `htmlResponsePrefix` does nothing.

***

## Theme

The widget page follows the host's theme. When an MCP Apps host sends `theme: 'light'` or `'dark'` — in its `ui/initialize` answer, or later in `ui/notifications/host-context-changed` — the bridge writes it to `<meta name="color-scheme">` and `<html data-theme>`. The frame's background and form controls then match the host, and your CSS can key off the attribute:

```css theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
[data-theme='dark'] .card {
  background: #1f2937;
  color: #f9fafb;
}
```

`window.FrontMcpBridge.getTheme()` returns the same value, and `onContextChange` listeners fire for the handshake context as well as later changes. When the host sends no theme, nothing is written. A widget that only has light styles can opt out with `:root { color-scheme: light }`, which takes precedence over the meta tag.

***

## Platform Considerations

Different MCP hosts have different capabilities and network policies:

* **OpenAI Apps SDK** — any CDN reachable; the widget is advertised like on every other host, through `_meta.ui.resourceUri` on the tool. FrontMCP does not emit `_meta['openai/outputTemplate']`
* **Claude (MCP-UI)** — **only `cdnjs.cloudflare.com` is reachable**; prefer `resourceMode: 'inline'` so the widget shell is self-contained, and pin any `externals` to cdnjs URLs via `dependencies`
* **MCP Inspector** — useful for local development; honors `servingMode: 'static'`
* **Gemini / unknown hosts** — `ui` is ignored; the tool returns JSON only

The default `servingMode: 'auto'` selects the right mode per host. See the [Tool UI reference](/frontmcp/servers/tools#tool-ui) for the full surface: serving modes, CSP, the `window.FrontMcpBridge` runtime, file-based `.tsx` widgets, and platform-specific troubleshooting.

***

## Next Steps

<CardGroup cols={2}>
  <Card title="React SDK Components" icon="react" href="/frontmcp/react/components">
    Pre-built React components
  </Card>

  <Card title="Tool Reference" icon="wrench" href="/frontmcp/servers/tools">
    Full @Tool decorator options
  </Card>

  <Card title="CodeCall CRM Demo" icon="users" href="/frontmcp/guides/codecall-crm-demo">
    See UI in a full application
  </Card>

  <Card title="Create Prompts" icon="message" href="/frontmcp/guides/prompts-and-resources">
    Build prompts alongside tools
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.