# Your First Widget

> When a tool's result deserves a UI, and how to give it one with the ui option. An HTML template FrontMCP renders on every call, what the host receives, a widget that calls a tool back, and a widget that follows the host's theme and fits its frame.

Source: https://frontmcp.dev/learn/your-first-widget

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 with | Leave it out for |
| --- | --- |
| A record someone checks at a glance: a ticket, an invoice, a customer | Answers of a line or two |
| A list to scan or sort, like a queue of tickets | Data only the model uses to decide what to call next |
| An action the person should confirm with a click, like closing a ticket | Anything 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.

```ts get-ticket.tool.ts active
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;
  }
}
```

```ts widget.test.ts
import { test, expect } from "@frontmcp/testing";

test("the model's result is the same as without ui", async ({ mcp }) => {
  const result = await mcp.tools.call("get_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", title: "Cannot log in", status: "open", priority: "high", customer: "Acme Corp" });
});

test("tools/list says the tool has a widget", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta).toEqual({ ui: { resourceUri: "ui://widget/get_ticket.html" }, "frontmcp/type": "html" });
});

test("the result carries the page", async ({ mcp }) => {
  const { _meta } = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw;
  expect(_meta["ui/type"]).toBe("html");
  expect(_meta["ui/mimeType"]).toBe("text/html;profile=mcp-app");
  expect(_meta["ui/html"]).toMatch(/^<!DOCTYPE html>/);
  expect(_meta["ui/html"]).toContain("<h2>Cannot log in</h2>");
});
```

`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](#escaping-what-customers-wrote)).
- **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`.

> **Pitfall: A React component isn't a template function**
`template: TicketCard`, where `TicketCard` returns JSX, sends an empty page, and the server logs `ui.template is a React component reference, which cannot be bundled` when it starts: FrontMCP has no file to bundle, so the widget shows "Loading widget..." forever. A template function returns markup, as above. React widgets go in a `.tsx` file, and are a lesson of their own, [Building Widgets with React](https://frontmcp.dev/learn/building-widgets-with-react).

### 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](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#the-widget-gets-what-outputschema-declares) 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:

```ts
// 🚩 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](https://frontmcp.dev/reference/ui#escaping-string-results) 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:

```ts get-ticket.tool.ts active
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;
  }
}
```

```ts close-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket 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}.`));
    if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`));
    ticket.status = "closed";
    return { id, status: ticket.status };
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; status: "open" | "closed"; priority: "high" | "normal"; customer: string };

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

```ts close.test.ts
import { test, expect } from "@frontmcp/testing";

test("the button carries the ticket's id", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"];
  expect(html).toContain(`<button data-tool-call="close_ticket" data-tool-args="{&quot;id&quot;:&quot;T-1&quot;}">`);
});

test("the widget's call is an ordinary tools/call", async ({ mcp }) => {
  const result = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(result.json()).toEqual({ id: "T-1", status: "closed" });
});

test("a second close fails with a message the widget shows", async ({ mcp }) => {
  await mcp.tools.call("close_ticket", { id: "T-1" });
  const again = await mcp.tools.call("close_ticket", { id: "T-1" });
  expect(again).toBeError();
  expect(again).toHaveTextContent("T-1 is already closed.");
});
```

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](https://frontmcp.dev/reference/ui/hosts#windowfrontmcpbridge) 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](https://frontmcp.dev/examples/order-tracker-widget) puts two buttons like these on a shipping timeline, and shows the page changing with each tool's answer, refusals included.

> **Pitfall: A widget's buttons do nothing until the host answers**
In an MCP Apps host, the bridge starts by sending the host `ui/initialize`, and only once the host answers does it handle `data-tool-call` clicks and `callTool()`. Until then the buttons do nothing, and `callTool()` rejects with `Tool calls not supported on this platform (ext-apps)`. Opened on its own, outside any host, the page can't call tools at all.

## 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:

```ts get-ticket.tool.ts active
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;
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; status: "open" | "closed"; priority: "high" | "normal"; customer: string };

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

```ts theme.test.ts
import { test, expect } from "@frontmcp/testing";

async function templateStyle(mcp: any): Promise<string> {
  const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"];
  return html.match(/<style>([^<]*\.ticket[^<]*)<\/style>/)![1];
}

test("the card's colors come in a light and a dark version", async ({ mcp }) => {
  expect(await templateStyle(mcp)).toContain("background: light-dark(#f6f7f9, #2b303b)");
});

test("the page sets no color-scheme of its own, so the bridge's applies", async ({ mcp }) => {
  expect(await templateStyle(mcp)).not.toContain("color-scheme");
});
```

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.

> **Pitfall: A color-scheme of your own replaces the host's**
The bridge's `<meta>` is a default, and your CSS wins over it. With `:root { color-scheme: light dark }` the page follows the reader's system setting instead of the host's: in a dark host on a light system, `light-dark()` picks the light colors and the frame is painted opaque white. A host that sends no theme leaves the page light, since the bridge never writes the system's setting. If you want the system's setting for such hosts, let `data-theme` override it:

```css
:root { color-scheme: light dark; }
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
```

> **Note**
`bridge:ready` fires on `window` once the bridge has finished its handshake with the host. Before it, `FrontMcpBridge.getTheme()` is the system's setting, and the handshake's context is the first call to your `onContextChange` callback.

## 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:

```ts get-ticket.tool.ts active
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;
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; status: "open" | "closed"; priority: "high" | "normal"; customer: string };

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

```ts sizing.test.ts
import { test, expect } from "@frontmcp/testing";

test("tools/list carries the size hint", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  expect(tool._meta.ui).toEqual({ resourceUri: "ui://widget/get_ticket.html", autoResize: true });
});

test("so does the page, which reports its height because of it", async ({ mcp }) => {
  const html: string = (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"];
  expect(html).toContain('window.__mcpWidgetSizing = {"autoResize":true};');
});
```

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](https://frontmcp.dev/reference/ui#sizing).

## 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: 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.

```ts get-ticket.tool.ts
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;
  }
}
```

```ts get-ticket.tool.ts solution
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>) => ctx.helpers.html`
      <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;
  }
}
```

```ts escape.test.ts hidden
import { test, expect } from "@frontmcp/testing";

const page = async (mcp: any, id: string): Promise<string> => (await mcp.tools.call("get_ticket", { id })).raw._meta["ui/html"];

test("T-1's widget shows its title in a heading", async ({ mcp }) => {
  expect(await page(mcp, "T-1")).toMatch(/<h2>\s*Cannot log in\s*<\/h2>/);
});

test("`<no data>` in T-4's title shows as text, in the heading", async ({ mcp }) => {
  const html = await page(mcp, "T-4");
  expect(html).toMatch(/<h2>\s*Export fails with &lt;no data&gt;\s*<\/h2>/);
  expect(html).not.toContain("<no data>");
});
```

**Hint:**
The template returns a plain template string. Which helper keeps your tags as markup and escapes what you put in `${…}`?

**Solution:**
`ctx.helpers.html` keeps the template's own tags as markup and escapes every interpolated value, so `<no data>` reaches the page as `&lt;no data&gt;` inside a real `<h2>`, and the browser shows it as text. A plain template string is all text to FrontMCP, which escapes the whole of it and logs a notice. `escapeStringResults: false` would bring the heading back, but would put `<no data>` into the page as a tag, and the second check would fail.

### Challenge: Add a Close button
Support agents want to close a ticket from its widget. Add a button that calls `close_ticket` for the ticket the widget shows, without writing a script for the call.

```ts get-ticket.tool.ts
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.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <h2>${ctx.output.title}</h2>
      <p>Status: ${ctx.output.status}</p>`,
  },
})
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;
  }
}
```

```ts get-ticket.tool.ts solution
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.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
  ui: {
    template: (ctx: TemplateContext<{ id: string }, Ticket>) => ctx.helpers.html`
      <h2>${ctx.output.title}</h2>
      <p>Status: ${ctx.output.status}</p>
      <button data-tool-call="close_ticket" data-tool-args="${JSON.stringify({ id: ctx.output.id })}">Close ticket</button>`,
  },
})
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;
  }
}
```

```ts close-ticket.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { tickets } from "./tickets";

@Tool({
  name: "close_ticket",
  description: "Close a support ticket by its id.",
  inputSchema: { id: z.string().describe("Ticket id, like T-1") },
})
export class CloseTicket 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}.`));
    if (ticket.status === "closed") this.fail(new PublicMcpError(`${id} is already closed.`));
    ticket.status = "closed";
    return { id, status: ticket.status };
  }
}
```

```ts tickets.ts
export type Ticket = { id: string; title: string; status: "open" | "closed" };

export const tickets: Ticket[] = [
  { id: "T-1", title: "Cannot log in", status: "open" },
  { id: "T-3", title: "Login link expired", status: "open" },
];
```

```ts button.test.ts hidden
import { test, expect } from "@frontmcp/testing";

// The arguments of the page's close_ticket button, as the bridge reads them.
async function closeButtonArgs(mcp: any, id: string) {
  const html: string = (await mcp.tools.call("get_ticket", { id })).raw._meta["ui/html"];
  const body = html.slice(html.indexOf("<body>"));
  const tag = body.match(/<button[^>]*data-tool-call=["']close_ticket["'][^>]*>/)?.[0];
  if (!tag) return "no close_ticket button";
  const raw = tag.match(/data-tool-args=(?:"([^"]*)"|'([^']*)')/);
  if (!raw) return "no data-tool-args";
  return JSON.parse((raw[1] ?? raw[2]).replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&"));
}

test("T-1's widget has a button that calls `close_ticket` for T-1", async ({ mcp }) => {
  expect(await closeButtonArgs(mcp, "T-1")).toEqual({ id: "T-1" });
});

test("T-3's widget closes T-3, not T-1", async ({ mcp }) => {
  expect(await closeButtonArgs(mcp, "T-3")).toEqual({ id: "T-3" });
});
```

**Hint:**
The bridge reads two attributes on the button: the tool's name, and its arguments as JSON. Build the JSON from `ctx.output` so each ticket's button closes that ticket.

**Solution:**
`data-tool-call="close_ticket"` names the tool, and `data-tool-args="${JSON.stringify({ id: ctx.output.id })}"` gives it the id of the ticket on screen. `html` escapes the JSON's quotes, and the browser restores them when the bridge reads the attribute. Press the button in the Widget tab to see the call in the Wire tab.

### Challenge: Make the widget fit in
The widget is a white card in a frame that's always 240 pixels tall, and in a dark host it stays white. Make it report its height to the host, and give the card colors that follow the host's light or dark theme.

```ts get-ticket.tool.ts
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

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

@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>) => ctx.helpers.html`
      <style>
        body { font: 15px/1.45 system-ui, sans-serif; }
        .ticket { padding: 12px 16px; background: #ffffff; color: #23272f; }
      </style>
      <article class="ticket">
        <h2>${ctx.output.title}</h2>
        <p>${ctx.output.id} · ${ctx.output.status}</p>
      </article>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}
```

```ts get-ticket.tool.ts solution
import { Tool, ToolContext, z, type TemplateContext } from "@frontmcp/sdk";

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

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id.",
  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; background: light-dark(#ffffff, #2b303b); color: light-dark(#23272f, #e6e8ee); }
      </style>
      <article class="ticket">
        <h2>${ctx.output.title}</h2>
        <p>${ctx.output.id} · ${ctx.output.status}</p>
      </article>`,
  },
})
export class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return { id, title: "Cannot log in", status: "open" };
  }
}
```

```ts fit.test.ts hidden
import { test, expect } from "@frontmcp/testing";

const page = async (mcp: any): Promise<string> => (await mcp.tools.call("get_ticket", { id: "T-1" })).raw._meta["ui/html"];

test("`get_ticket` has a size hint, so the widget reports its height", async ({ mcp }) => {
  const [tool] = await mcp.tools.list();
  const ui = tool._meta.ui;
  const hinted = ui.autoResize === true || ["preferredHeight", "minHeight", "maxHeight", "aspectRatio"].some((k) => k in ui);
  expect(hinted && ui.autoResize !== false).toBe(true);
});

test("the card's colors come in a light and a dark version (`light-dark()`)", async ({ mcp }) => {
  expect(await page(mcp)).toMatch(/\.ticket\s*\{[^}]*light-dark\(/);
});

test("the page sets no `color-scheme` of its own, so the bridge's applies", async ({ mcp }) => {
  const html = await page(mcp);
  const styles = html.slice(html.indexOf("<body>")).match(/<style>[^<]*<\/style>/g) ?? [];
  expect(styles.length).toBeGreaterThan(0);
  expect(styles.join("")).not.toContain("color-scheme");
});
```

**Hint:**
Sizing needs one option on `ui`. For the theme, don't set `color-scheme` yourself, since the bridge does. Give each of the card's colors a light and a dark version.

**Solution:**
`autoResize: true` makes the page report its height, margins included, so no CSS is needed for it. `light-dark(light, dark)` gives each color two versions, and the bridge sets the page's `color-scheme` from the host's theme, so the right one is used without a script. A `color-scheme` of your own would replace the bridge's. Switch this site's theme to see the widget follow.
