Your First Widget

IntermediateMCP 2026-07-28

A support agent asks about ticket T-1. The model calls get_ticket, reads the JSON and writes a sentence about it. That works for the model, but the person asking would rather see the ticket, with a button to close it, than read a paraphrase and type "close it" back. A tool's ui option gives its result a widget: an HTML page that a host able to show one renders next to the answer, while the model reads the same result as before. This lesson gives get_ticket a widget, looks at what the host receives, and makes the widget call a tool, follow the host's theme and fit its frame.

You will learn

  • When a tool's result is worth a widget
  • How to add one with the ui option and an HTML template
  • What a host receives, and what the model still sees
  • How a widget calls a tool on your server
  • How to follow the host's theme and fit the widget's frame

When a result deserves a UI

Most tools don't need a widget. The model reads a result and tells the user what matters, and for "how many tickets are open?" that's all anyone wants. A widget earns its place when a person, not the model, is the one reading:

A widget helps withLeave it out for
A record someone checks at a glance: a ticket, an invoice, a customerAnswers of a line or two
A list to scan or sort, like a queue of ticketsData only the model uses to decide what to call next
An action the person should confirm with a click, like closing a ticketAnything the model has to see: it never sees the page

Two things stay true for every tool with a widget. The model gets the same content and structuredContent it got without one, so the result still has to make sense as text. And many hosts can't show widgets at all: they ignore the page and use the result as it is.

Adding a widget with ui

Here is get_ticket with a ui option. The Playground below opens on its Widget tab, which works like a small host: it puts the page into a sandboxed frame, gives it the call's input and result, and passes the page's tool calls to the server.

Open
import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; status: "open" | "closed"; priority: "high" | "normal"; customer: string };

const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", status: "open", priority: "high", customer: "Acme Corp" },
  { id: "T-2", title: "Invoice total is wrong", status: "closed", priority: "normal", customer: "Globex" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, with its status, priority and customer.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <article style="padding: 12px 16px; border-radius: 8px; background: #f6f7f9; color: #23272f; font: 15px system-ui">
        <p>${ctx.output.id} · ${ctx.output.customer} · ${ctx.output.priority} priority</p>
        <h2>${ctx.output.title}</h2>
        <p>Status: ${ctx.output.status}</p>
      </article>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

template is a function that FrontMCP calls on every call, after execute(), with ctx:

  • ctx.output is what execute() returned, and ctx.input is the call's arguments.
  • ctx.helpers.html builds the markup. Every value you put in ${…} is escaped, so a ticket title can't add tags to the page (more on this below).
  • What it returns becomes the page's <body>. FrontMCP wraps it in a whole HTML document.

Annotate ctx with TemplateContext<Input, Output>. template accepts several kinds of value, so TypeScript can't infer the parameter's type, and under strict a bare (ctx) => … fails with TS7006: Parameter 'ctx' implicitly has an 'any' type.

What the host receives

Open the Tests tab, or the Wire tab, to see what a client gets:

  • In tools/list, before any call, the tool carries _meta.ui.resourceUri, ui://widget/get_ticket.html. That's how a host knows the tool has a widget.
  • In the tools/call result, content and structuredContent are what they'd be without ui, outputSchema included (the next lesson shows what that means for the page). That's the model's part.
  • Next to them, in _meta, is the page: ui/html holds a whole HTML document, ui/type is "html" and ui/mimeType is "text/html;profile=mcp-app".

The page is about 38 KB even for a card this small. Most of it is the bridge, a script FrontMCP puts in every page so the widget can talk to the host it runs in. The page also carries the call's data: a script at the top sets window.__mcpToolOutput to what execute() returned.

Escaping what customers wrote

Ticket titles are typed by customers, so they're text, not markup. Customers paste error messages, and error messages have angle brackets. A template has to keep the two apart: its own tags are markup, and the values it puts between them are text. Here are two templates for the same title. Only the second one shows a heading:

// 🚩 A plain template string: FrontMCP can't tell your tags from the title, and escapes it all
template: (ctx: TemplateContext<{ id: string }, Ticket>) => `<h2>${ctx.output.title}</h2>`,
// The page shows the text "<h2>Export fails with <no data></h2>", tags and all.

// ✅ html keeps your markup and escapes every value you interpolate
template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`<h2>${ctx.output.title}</h2>`,
// <h2>Export fails with &lt;no data&gt;</h2>: a heading with the title as the customer wrote it.

For the plain string, the server also logs a notice, The UI template of tool "get_ticket" returned a plain string containing markup. Since 1.9.2, FrontMCP HTML-escapes plain string results by default, so it is shown as text. Escaping the whole string is the safe choice: if FrontMCP rendered it as markup, a title like <button data-tool-call="close_ticket"> would add a button that calls one of your tools, as the next section shows. Build every template with ctx.helpers.html. The Tool UI reference has the escapeStringResults switch, which renders plain strings as markup instead, with that risk.

Calling a tool from the widget

A widget can call your tools, and that's what makes it more than a picture of the result. Give a button a data-tool-call attribute with the tool's name and data-tool-args with its arguments as JSON, and the bridge calls the tool when it's clicked. No script is needed for the call; this one only shows the answer. Press Close ticket, then open the Wire tab:

Open
import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { tickets, type Ticket } from "./tickets";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, with its status, priority and customer.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <article style="padding: 12px 16px; border-radius: 8px; background: #f6f7f9; color: #23272f; font: 15px system-ui">
        <p>${ctx.output.id} · ${ctx.output.customer} · ${ctx.output.priority} priority</p>
        <h2>${ctx.output.title}</h2>
        <p>Status: <span id="status">${ctx.output.status}</span></p>
        <button data-tool-call="close_ticket" data-tool-args="${JSON.stringify({ id: ctx.output.id })}">Close ticket</button>
        <p id="note"></p>
      </article>
      <script>
        document.addEventListener("tool:success", (event) => {
          const result = event.detail.result;
          if (result.isError) {
            document.getElementById("note").textContent = result.content[0].text;
          } else {
            document.getElementById("status").textContent = result.structuredContent.status;
          }
        });
      </script>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The status changes to closed, and the Wire tab has a tools/call for close_ticket tagged widget. Press the button again and the note says T-1 is already closed. Here's the path the click took:

  1. The bridge reads the button's attributes and asks the host to call close_ticket with { "id": "T-1" }.
  2. The host sends that tools/call to your server over its own connection, as it sends any call. The same authentication, authorization and rate limits apply as to a model's call. The bridge marks the call as the widget's own, with _meta["frontmcp/widgetCall"], but only so that a tool with a widget answers it without its page.
  3. The host hands the result back to the page, and the bridge fires a tool:success event on the button, which bubbles up to document. event.detail.result is the whole result.

A few details matter here:

  • Build data-tool-args with JSON.stringify(), inside double quotes. html escapes the JSON's quotes to &quot;, and the browser turns them back into quotes when the bridge reads the attribute, so any value is safe to put there.
  • A tool that fails still fires tool:success, with isError: true in the result and the message in content. That's why the script checks result.isError. tool:error is for calls that couldn't be made at all, like in a host that doesn't pass tool calls on.
  • From a script, call FrontMcpBridge.callTool(name, args), which returns a promise of the same result. Hosts and platforms lists everything else the bridge can do.
  • Give the tools a widget calls no ui of their own. A host that passes the bridge's marker on gets their results without a page, but under the OpenAI Apps SDK the widget can't mark its calls, and a result it gets back would carry the called tool's page too, another 38 KB for every click.

The Order Tracker Widget puts two buttons like these on a shipping timeline, and shows the page changing with each tool's answer, refusals included.

Following the host's theme

Switch this site between light and dark with the button in its header, and look at the widget above. Its colors are fixed, so in dark mode it's a light card on a dark page. The host says which theme it uses when it answers the bridge's first message, and sends a message each time it changes. The bridge writes what it says into the page, in two places:

  • <meta name="color-scheme"> is set to light or dark. The browser then draws the page's defaults in that scheme: the text color, form controls, scrollbars, and a frame that's see-through instead of white. light-dark(a, b) in your CSS picks a in a light scheme and b in a dark one.
  • <html data-theme> is set to the same word, for CSS of your own, like :root[data-theme="dark"] { … }.

So a page that sets no color-scheme of its own follows the host without a script. Give the card colors in two versions with light-dark(), and switch the theme:

Open
import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { tickets, type Ticket } from "./tickets";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, with its status, priority and customer.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <style>
        body { font: 15px/1.45 system-ui, sans-serif; }
        .ticket { padding: 12px 16px; border-radius: 8px; background: light-dark(#f6f7f9, #2b303b); }
        .meta { color: light-dark(#5e687e, #99a1b3); }
      </style>
      <article class="ticket">
        <p class="meta">${ctx.output.id} · ${ctx.output.customer} · ${ctx.output.priority} priority</p>
        <h2>${ctx.output.title}</h2>
        <p>Status: ${ctx.output.status}</p>
        <p class="meta">Host theme: <span id="theme">…</span></p>
      </article>
      <script>
        const theme = document.getElementById("theme");
        FrontMcpBridge.onContextChange((change) => {
          if (change.theme) theme.textContent = change.theme;
        });
      </script>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The card follows, and the line under the frame says which theme the host sent. The page's background is transparent, so the host's shows through, and the text is the scheme's default color. The script is only there to show the host's answer. FrontMcpBridge.onContextChange(callback) calls callback with what the host sent, { theme, displayMode, … }, for its first answer and for every change after it, and FrontMcpBridge.getTheme() is the current theme. Use them when the page has to know, to pick a chart's colors, say.

Fitting the frame

In the Playground, the frame around a widget is 240 pixels tall until the page says otherwise: look at the space under the card above. Other hosts pick their own default, but none of them can know how tall a page is, so the page has to say. FrontMCP's page measures itself and reports its height to the host with the standard ui/notifications/size-changed notification, but only when the tool has a size hint, and autoResize: true is enough. It measures the whole <html> element, margins included: the browser's 8 pixel margin around the body, a heading's margin and a box with a fixed height all count, and the height goes down as well as up when the content shrinks.

This version fits its frame and follows the theme. It also has a link, which a widget has to ask the host to open:

Open
import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";
import { tickets, type Ticket } from "./tickets";

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, with its status, priority and customer.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    autoResize: true,
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <style>
        body { font: 15px/1.45 system-ui, sans-serif; }
        .ticket { padding: 12px 16px; border-radius: 8px; background: light-dark(#f6f7f9, #2b303b); }
        .meta { color: light-dark(#5e687e, #99a1b3); }
      </style>
      <article class="ticket">
        <p class="meta">${ctx.output.id} · ${ctx.output.customer} · ${ctx.output.priority} priority</p>
        <h2>${ctx.output.title}</h2>
        <p>Status: ${ctx.output.status} · <a id="open" href="https://desk.example.com/tickets/${ctx.output.id}">Open in the help desk</a></p>
        <p id="note" hidden></p>
      </article>
      <script>
        document.getElementById("open").addEventListener("click", (event) => {
          event.preventDefault();
          FrontMcpBridge.openLink(event.currentTarget.href).catch((error) => {
            const note = document.getElementById("note");
            note.textContent = "Couldn't open the link: " + error.message;
            note.hidden = false;
          });
        });
      </script>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

The frame now fits the card, and the line under it gives the height the widget reported (the Playground labels it ui/setSize whichever of the two size messages arrived). When the widget grows, like when the note appears, it reports again. The card sits 8 pixels in from the frame's edge, the body's default margin, and the height includes it. Set body { margin: 0 } if you'd rather have the card touch the frame.

Now press Open in the help desk. The widget runs in a sandbox that can't open windows, and a plain link would replace the widget with the page it points to, inside the widget's own frame. So the script stops the click and asks the host with FrontMcpBridge.openLink(url). A host may refuse, and this Playground always does: the promise rejects with The Playground doesn't open links (code: -32601), the widget shows it, and the line under the frame offers the link for you to open. Always catch what openLink() returns.

Changed in 1.8.6: the page used to measure #root or <body>, which left the body's margins out, and its first report could be lost, because it went out before the host had answered the bridge's first message. Widgets set body { margin: 0 } and sent their first height themselves, with FrontMcpBridge.setSize() on bridge:ready. Neither is needed now, and a widget that still does both works as before.

The other size hints, preferredHeight, minHeight, maxHeight and aspectRatio, are in the Tool UI reference.

Recap

  • Add a widget when a person reads or acts on a result. The model reads the same result as without it, and hosts that can't show widgets ignore the page.
  • ui.template is a function FrontMCP calls on every call with ctx.input, ctx.output and ctx.helpers. Annotate it with TemplateContext, name it in lower case, and build the markup with ctx.helpers.html, which escapes what you interpolate.
  • The page goes out in the result's _meta["ui/html"], about 38 KB with the bridge, and tools/list advertises it as ui://widget/<tool>.html.
  • data-tool-call and data-tool-args make a button call a tool through the host, as the host's own tools/call. The answer arrives as tool:success, with isError when the tool failed.
  • Follow the host's theme with light-dark() colors and no color-scheme of your own: the bridge sets <meta name="color-scheme"> and <html data-theme> from what the host says. FrontMcpBridge.getTheme() and onContextChange() are there when a script needs the theme.
  • Fit the frame with autoResize: true: the page reports the height of its whole <html>, margins included, and again whenever it changes. Ask the host to open links with openLink(), and catch it.

Try some challenges

Each challenge runs hidden checks against your code. Edit the code, then press Check.

Challenge 1 of 3

Show the customer's words as text

T-4's title is Export fails with <no data>, copied from an error message. The widget shows the template's own tags as text, <p>T-4 · Globex</p> and all, instead of a heading. Make it a heading again, with every ticket's title and customer exactly as they were written.

Open
import { PublicMcpError, Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

type Ticket = { id: string; title: string; customer: string };

const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", customer: "Acme Corp" },
  { id: "T-4", title: "Export fails with <no data>", customer: "Globex" },
];

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => `
      <p>${ctx.output.id} · ${ctx.output.customer}</p>
      <h2>${ctx.output.title}</h2>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    const ticket = tickets.find((t) => t.id === id);
    if (!ticket) this.fail(new PublicMcpError(`There's no ticket ${id}.`));
    return ticket;
  }
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.