# Expense Dashboard

> A help desk server whose expense_report tool answers the model with what support spent in a month and shows the team lead the same numbers as a static dashboard widget, with filters that call the tool for another month or team.

Source: https://frontmcp.dev/examples/expense-dashboard

Support gives money back: refunds for charges that shouldn't have happened, credits for outages, goodwill gestures for customers who waited too long. At the end of the month the team lead wants to know how much went out, which team and which agent spent it, against the budget. This example is a help desk server that answers with a [static widget](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#static-one-page-the-data-from-the-host). `expense_report` returns a month's spending as data the model reads, and the dashboard is one page, served once, that draws whatever result the host hands it. Its month and team filters call the tool again through the host.

**You will learn**
- How a static widget is built: what the server renders once, and what the page draws from each result
- How filters call the tool that produced the widget, and how the page handles a call that fails
- How to keep internal fields out of the result and off the page when `ui` won't strip them
- How to test a page that has no data in it, and where the React version fits
- Why a widget's calls should go to tools that have no widget of their own, and what a static tool does differently

## The server

Here is the whole server. The **Widget** tab is open on September's report for every team: enterprise spent more than its budget, so its bar is red and says so. Change the month, then the team, and watch the **From the widget** list under the frame: each change is a `tools/call` to `expense_report`. The **Call** tab shows what the model reads for the same call, `largest` included, and the **Tests** tab checks it.

```ts expense-dashboard.ts active
import type { TemplateContext } from "@frontmcp/sdk";
import { teams } from "./ledger";

export function expenseDashboard(ctx: TemplateContext<{}, {}>) {
  const { html } = ctx.helpers;

  return html`
    <style>
      body { font: 14px/1.4 system-ui, sans-serif; }
      .card { padding: 12px 16px; border-radius: 8px; background: light-dark(#f6f7f9, #2b303b); }
      .card[data-loading] #report { opacity: 0.5; }
      fieldset { display: flex; gap: 8px; margin: 0 0 12px; padding: 0; border: 0; }
      select { padding: 4px 8px; font: inherit; color: inherit; border: 1px solid light-dark(#b8c0cf, #565e70); border-radius: 6px; background: light-dark(#ffffff, #363c4a); }
      #message { margin: 0 0 8px; color: light-dark(#b42318, #ff8a80); }
      h3 { margin: 14px 0 6px; font-size: 13px; color: light-dark(#5e687e, #99a1b3); }
      .figure { margin: 0 0 6px; font-size: 24px; font-weight: 600; }
      .muted { margin: 6px 0 0; color: light-dark(#5e687e, #99a1b3); }
      .row { display: grid; grid-template-columns: 9em minmax(2em, 1fr) 9.5em; align-items: center; gap: 8px; margin: 4px 0; }
      .row > :last-child { text-align: right; white-space: nowrap; }
      .track { height: 10px; border-radius: 4px; background: light-dark(#cde2fb, #0d366b); }
      .fill { display: block; height: 100%; border-radius: 4px; background: light-dark(#2a78d6, #3987e5); }
      .fill.over { background: #d03b3b; }
      .over-text { font-weight: 600; }
    </style>
    <main class="card">
      <fieldset id="filters" disabled>
        <select id="month" aria-label="Month"></select>
        <select id="team" aria-label="Team">
          <option value="all">all teams</option>
          ${teams.map((team) => html`<option value="${team}">${team}</option>`)}
        </select>
      </fieldset>
      <p id="message" role="status" hidden></p>
      <div id="report">Waiting for the report…</div>
    </main>
    <script>
      const card = document.querySelector(".card");
      const filters = document.getElementById("filters");
      const monthSelect = document.getElementById("month");
      const teamSelect = document.getElementById("team");
      const message = document.getElementById("message");
      const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
      let shown = null;

      function element(tag, className, text) {
        const node = document.createElement(tag);
        node.className = className;
        node.textContent = text ?? "";
        return node;
      }

      function money(amount) {
        return dollars.format(amount);
      }

      function row({ label, amount, limit, text, over = false }) {
        const fill = element("span", over ? "fill over" : "fill");
        fill.style.width = Math.min(100, (amount / limit) * 100) + "%";
        const track = element("span", "track");
        track.append(fill);
        const line = element("div", "row");
        line.title = label + ": " + text;
        line.append(element("span", "", label), track, element("span", over ? "over-text" : "", text));
        return line;
      }

      function say(text) {
        message.textContent = text;
        message.hidden = false;
      }

      function section(title, rows) {
        const block = element("section", "");
        block.append(element("h3", "", title), ...rows);
        return block;
      }

      function draw(report) {
        shown = report;
        monthSelect.replaceChildren(...report.months.map((month) => new Option(month, month)));
        monthSelect.value = report.month;
        teamSelect.value = report.team;
        message.hidden = true;
        filters.disabled = false;

        const remaining = report.budget - report.spent;
        const overBudget = remaining < 0;
        const summary = element("section", "");
        summary.append(
          element("p", "figure", money(report.spent) + " of " + money(report.budget)),
          row({
            label: "Budget used",
            amount: report.spent,
            limit: report.budget,
            text: overBudget ? money(-remaining) + " over" : money(remaining) + " left",
            over: overBudget,
          }),
          element("p", "muted", report.byKind.map((kind) => kind.kind + " " + money(kind.amount)).join(" · ")),
        );

        const teamRows = report.byTeam.map((team) =>
          row({
            label: team.team,
            amount: team.spent,
            limit: team.budget,
            text: money(team.spent) + " of " + money(team.budget),
            over: team.spent > team.budget,
          }),
        );

        const biggestSpend = Math.max(...report.byAgent.map((agent) => agent.spent), 1);
        const agentRows = report.byAgent.map((agent) =>
          row({ label: agent.agent + " · " + agent.team, amount: agent.spent, limit: biggestSpend, text: money(agent.spent) }),
        );

        document.getElementById("report").replaceChildren(summary, section("By team", teamRows), section("By agent", agentRows));
      }

      async function applyFilters() {
        card.dataset.loading = "";
        filters.disabled = true;
        try {
          const result = await FrontMcpBridge.callTool("expense_report", { month: monthSelect.value, team: teamSelect.value });
          if (result.isError) throw new Error(result.content[0].text);
          draw(result.structuredContent);
        } catch (error) {
          say(error.message);
          monthSelect.value = shown.month;
          teamSelect.value = shown.team;
          filters.disabled = false;
        }
        delete card.dataset.loading;
      }

      monthSelect.addEventListener("change", applyFilters);
      teamSelect.addEventListener("change", applyFilters);

      window.addEventListener("tool:result", (event) => {
        if (event.detail.structuredContent) draw(event.detail.structuredContent);
        else say(event.detail.content[0].text);
      });
    </script>`;
}
```

```ts expense-report.tool.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { expenseDashboard } from "./expense-dashboard";
import { ExpenseLedger, kinds, teams } from "./ledger";

const teamFilter = z.enum(["all", ...teams]);

const reportSchema = z.object({
  month: z.string(),
  team: teamFilter,
  months: z.array(z.string()),
  budget: z.number(),
  spent: z.number(),
  byKind: z.array(z.object({ kind: z.enum(kinds), amount: z.number() })),
  byTeam: z.array(z.object({ team: z.enum(teams), spent: z.number(), budget: z.number() })),
  byAgent: z.array(z.object({ agent: z.string(), team: z.enum(teams), spent: z.number() })),
  largest: z.array(
    z.object({ id: z.string(), kind: z.enum(kinds), amount: z.number(), agent: z.string(), customer: z.string() }),
  ),
});

@Tool({
  name: "expense_report",
  description:
    "What support spent in one month on refunds, credits and goodwill gestures, against the budget: totals, and the split by " +
    "kind, team and agent, with the three largest. Defaults to the latest month, for every team.",
  inputSchema: {
    month: z.string().regex(/^\d{4}-\d{2}$/).optional().describe("Month like 2026-09. Defaults to the latest month."),
    team: teamFilter.optional().describe("One team, or all. Defaults to all."),
  },
  outputSchema: reportSchema,
  annotations: { readOnlyHint: true },
  ui: { servingMode: "static", autoResize: true, template: expenseDashboard },
})
export class ExpenseReport extends ToolContext {
  async execute({ month, team }: { month?: string; team?: z.infer<typeof teamFilter> }) {
    const ledger = this.get(ExpenseLedger);
    const chosenMonth = month ?? ledger.latestMonth();
    if (!ledger.months().includes(chosenMonth)) {
      throw new PublicMcpError(
        `There are no expenses for ${chosenMonth}. Months with expenses: ${ledger.months().join(", ")}.`,
        "NO_EXPENSES",
      );
    }
    return ledger.report(chosenMonth, team ?? "all");
  }
}
```

```ts ledger.ts
import { Provider, ProviderScope } from "@frontmcp/sdk";

export const teams = ["support", "enterprise", "billing"] as const;
export const kinds = ["refund", "credit", "goodwill"] as const;

type Team = (typeof teams)[number];
type Kind = (typeof kinds)[number];

type Expense = {
  id: string;
  month: string;
  team: Team;
  agent: string;
  kind: Kind;
  amount: number;
  customer: string;
  internalNote: string;
};

function total(expenses: Expense[]) {
  return expenses.reduce((sum, expense) => sum + expense.amount, 0);
}

@Provider({ name: "ExpenseLedger", scope: ProviderScope.GLOBAL })
export class ExpenseLedger {
  private budgets: Record<Team, number> = { support: 4000, enterprise: 6000, billing: 3000 };

  private expenses: Expense[] = [
    { id: "E-701", month: "2026-07", team: "support", agent: "Nour", kind: "refund", amount: 300, customer: "Initech", internalNote: "Bought the wrong plan" },
    { id: "E-702", month: "2026-07", team: "enterprise", agent: "Dana", kind: "goodwill", amount: 800, customer: "Acme Corp", internalNote: "Comped a workshop" },
    { id: "E-703", month: "2026-07", team: "billing", agent: "Priya", kind: "credit", amount: 400, customer: "Globex", internalNote: "Tax rate was wrong" },
    { id: "E-801", month: "2026-08", team: "support", agent: "Sam", kind: "refund", amount: 450, customer: "Globex", internalNote: "Export never worked for them" },
    { id: "E-802", month: "2026-08", team: "support", agent: "Nour", kind: "goodwill", amount: 120, customer: "Initech", internalNote: "Waited three days for a reply" },
    { id: "E-803", month: "2026-08", team: "enterprise", agent: "Dana", kind: "credit", amount: 2500, customer: "Acme Corp", internalNote: "Rate-limit incident, VP asked for it" },
    { id: "E-804", month: "2026-08", team: "billing", agent: "Priya", kind: "refund", amount: 1900, customer: "Globex", internalNote: "Annual invoice sent twice" },
    { id: "E-901", month: "2026-09", team: "billing", agent: "Priya", kind: "refund", amount: 240, customer: "Globex", internalNote: "Duplicate charge on INV-7, processor confirmed" },
    { id: "E-902", month: "2026-09", team: "billing", agent: "Omar", kind: "refund", amount: 610, customer: "Initech", internalNote: "Cancelled the annual plan in week one" },
    { id: "E-903", month: "2026-09", team: "billing", agent: "Omar", kind: "credit", amount: 970, customer: "Initech", internalNote: "Two months of overbilling" },
    { id: "E-904", month: "2026-09", team: "support", agent: "Nour", kind: "goodwill", amount: 60, customer: "Initech", internalNote: "Waited a week for a first reply" },
    { id: "E-905", month: "2026-09", team: "support", agent: "Nour", kind: "credit", amount: 500, customer: "Initech", internalNote: "Half a day of API downtime" },
    { id: "E-906", month: "2026-09", team: "support", agent: "Sam", kind: "refund", amount: 320, customer: "Globex", internalNote: "Export never worked for them" },
    { id: "E-907", month: "2026-09", team: "support", agent: "Sam", kind: "refund", amount: 1240, customer: "Initech", internalNote: "Returned the hardware kit" },
    { id: "E-908", month: "2026-09", team: "support", agent: "Nour", kind: "goodwill", amount: 630, customer: "Globex", internalNote: "Missed two callbacks" },
    { id: "E-909", month: "2026-09", team: "enterprise", agent: "Dana", kind: "credit", amount: 3800, customer: "Acme Corp", internalNote: "Two-day login outage, renewal is in November" },
    { id: "E-910", month: "2026-09", team: "enterprise", agent: "Dana", kind: "goodwill", amount: 1200, customer: "Acme Corp", internalNote: "Comped an onsite training day" },
    { id: "E-911", month: "2026-09", team: "enterprise", agent: "Dana", kind: "refund", amount: 1700, customer: "Acme Corp", internalNote: "Unused seats after their reorg" },
  ];

  months() {
    return [...new Set(this.expenses.map((expense) => expense.month))].sort();
  }

  latestMonth() {
    return this.months().at(-1)!;
  }

  report(month: string, team: Team | "all") {
    const shownTeams = team === "all" ? [...teams] : [team];
    const expenses = this.expenses.filter((expense) => expense.month === month && shownTeams.includes(expense.team));
    const agents = [...new Set(expenses.map((expense) => expense.agent))];

    return {
      month,
      team,
      months: this.months(),
      budget: shownTeams.reduce((sum, teamName) => sum + this.budgets[teamName], 0),
      spent: total(expenses),
      byKind: kinds.map((kind) => ({ kind, amount: total(expenses.filter((expense) => expense.kind === kind)) })),
      byTeam: shownTeams.map((teamName) => ({
        team: teamName,
        spent: total(expenses.filter((expense) => expense.team === teamName)),
        budget: this.budgets[teamName],
      })),
      byAgent: agents
        .map((agent) => {
          const agentExpenses = expenses.filter((expense) => expense.agent === agent);
          return { agent, team: agentExpenses[0].team, spent: total(agentExpenses) };
        })
        .sort((first, second) => second.spent - first.spent),
      largest: [...expenses].sort((first, second) => second.amount - first.amount).slice(0, 3),
    };
  }
}
```

```ts main.ts
import "reflect-metadata";
import { App, FrontMcp } from "@frontmcp/sdk";
import { ExpenseReport } from "./expense-report.tool";
import { ExpenseLedger } from "./ledger";

@App({
  id: "help-desk",
  name: "Help Desk",
  providers: [ExpenseLedger],
  tools: [ExpenseReport],
})
export class HelpDeskApp {}

@FrontMcp({
  info: { name: "Help Desk", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class HelpDeskServer {}
```

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

async function reportFor(mcp: any, args: { month?: string; team?: string } = {}) {
  const result = await mcp.tools.call("expense_report", args);
  expect(result).toBeSuccessful();
  return result.raw.structuredContent;
}

test("the model reads September, for every team, by default", async ({ mcp }) => {
  const report = await reportFor(mcp);
  expect(report).toMatchObject({ month: "2026-09", team: "all", months: ["2026-07", "2026-08", "2026-09"], budget: 13000, spent: 11270 });
  expect(report.byTeam).toEqual([
    { team: "support", spent: 2750, budget: 4000 },
    { team: "enterprise", spent: 6700, budget: 6000 },
    { team: "billing", spent: 1820, budget: 3000 },
  ]);
  expect(report.byKind).toEqual([
    { kind: "refund", amount: 4110 },
    { kind: "credit", amount: 5270 },
    { kind: "goodwill", amount: 1890 },
  ]);
});

test("the agents are ordered by what they spent, and every split adds up", async ({ mcp }) => {
  const report = await reportFor(mcp);
  const sum = (rows: { spent?: number; amount?: number }[]) => rows.reduce((all, row) => all + (row.spent ?? row.amount ?? 0), 0);
  expect(report.byAgent.map((agent: { agent: string }) => agent.agent)).toEqual(["Dana", "Omar", "Sam", "Nour", "Priya"]);
  expect(sum(report.byAgent)).toBe(report.spent);
  expect(sum(report.byTeam)).toBe(report.spent);
  expect(sum(report.byKind)).toBe(report.spent);
});

test("a team narrows every number", async ({ mcp }) => {
  const report = await reportFor(mcp, { team: "enterprise" });
  expect(report).toMatchObject({ team: "enterprise", budget: 6000, spent: 6700 });
  expect(report.byTeam).toEqual([{ team: "enterprise", spent: 6700, budget: 6000 }]);
  expect(report.byAgent).toEqual([{ agent: "Dana", team: "enterprise", spent: 6700 }]);
});

test("another month has its own numbers", async ({ mcp }) => {
  const report = await reportFor(mcp, { month: "2026-08", team: "billing" });
  expect(report).toMatchObject({ month: "2026-08", team: "billing", budget: 3000, spent: 1900 });
});

test("a month without expenses is refused, and the message lists the months there are", async ({ mcp }) => {
  const result = await mcp.tools.call("expense_report", { month: "2026-05" });
  expect(result).toBeError("NO_EXPENSES");
  expect(result.text()).toBe("There are no expenses for 2026-05. Months with expenses: 2026-07, 2026-08, 2026-09.");
});

test("the internal notes never leave the server", async ({ mcp }) => {
  const result = await mcp.tools.call("expense_report", {});
  expect(result.raw.structuredContent.largest).toEqual([
    { id: "E-909", kind: "credit", amount: 3800, agent: "Dana", customer: "Acme Corp" },
    { id: "E-911", kind: "refund", amount: 1700, agent: "Dana", customer: "Acme Corp" },
    { id: "E-907", kind: "refund", amount: 1240, agent: "Sam", customer: "Initech" },
  ]);
  expect(result.text()).not.toContain("renewal");
  expect(result.text()).not.toContain("internalNote");
});

test("results carry no page: the widget is served once, as a resource", async ({ mcp }) => {
  const result = await mcp.tools.call("expense_report", {});
  expect(Object.keys(result.raw._meta ?? {}).filter((key) => key.startsWith("ui/"))).toEqual([]);
  const expenseReport = (await mcp.tools.list()).find((tool) => tool.name === "expense_report");
  expect(expenseReport._meta.ui).toEqual({ resourceUri: "ui://widget/expense_report.html", autoResize: true });
});

test("the resource is the page, with the teams in it and no data", async ({ mcp }) => {
  const page = (await mcp.resources.read("ui://widget/expense_report.html")).text();
  expect(page).toContain('<option value="enterprise">enterprise</option>');
  expect(page).toContain('<div id="report">Waiting for the report…</div>');
  expect(page).not.toContain("Dana");
  expect(page).not.toContain("11270");
});
```

The **Call** tab has `expense_report` with the arguments left out: the latest month, every team. Call it for `{ "month": "2026-05" }` and the tool refuses, listing the months that do have expenses. The Widget tab shows the last call's widget, so after a call in the Call tab, open it again.

## How it fits together

*[Illustration: The Expense Dashboard at work. When the server starts, the widget's page is rendered once, with no data. The host calls expense_report and gets structuredContent with no page, reads the page once as a resource, and shows it in a frame, then hands the widget the result, which it draws. The person picks 2026-08: the widget asks the host to call expense_report with that month, the server answers with structuredContent only, and the widget draws the new result.]*
1. When the server starts, FrontMCP renders the template once, with no data, and keeps the page. It serves it as the resource `ui://widget/expense_report.html`, and no result carries a page.
2. A client calls `expense_report`. `execute()` picks the month and asks the ledger for the report, and FrontMCP keeps only what `reportSchema` declares, which drops the internal notes. The result is `content` and `structuredContent`, with no page.
3. A host that shows widgets reads the page once, puts it in a frame, and once the bridge has finished its handshake sends the widget the call's result. The widget draws it from `structuredContent`.
4. The person picks another month or team. The script asks the host to call `expense_report` with both filters, through the bridge, and draws the answer the way it drew the first result.
5. When the tool refuses, the answer is a result with `isError: true`. The script shows the message, and puts the filters back where the report on screen says they are.
6. The model calls the same tool with the same arguments, and reads the same numbers. Nothing in the page is data it can't get.

## The files

### `ledger.ts`: the money

`ExpenseLedger` is a [provider](https://frontmcp.dev/reference/sdk/provider), `GLOBAL`, so the tool gets the same instance on every call. It holds three teams' budgets and eighteen expenses over three months, and builds a month's report: the totals, the split by kind, team and agent, and the three largest. The agents come out ordered by what they spent, and every split adds up to `spent`, as a test checks. In a real server this reads your finance system.

Each expense carries an `internalNote`, the reason an agent gave. It is the kind of field a database row has and a report shouldn't: `largest` returns whole rows, notes included, and it's the tool that decides what leaves.

### `main.ts`

One app, one provider and one tool. As in the [order tracker](https://frontmcp.dev/examples/order-tracker-widget), nothing here is specific to widgets.

### `expense-report.tool.ts`: one tool, served static

```ts expense-report.tool.ts
ui: { servingMode: "static", autoResize: true, template: expenseDashboard },
```

`servingMode: "static"` is the choice that shapes everything else. FrontMCP renders the page once, when the server starts, and every result goes out without one. A dashboard is the case for it: the page is large, the same for every call, and its filters call the tool again. Inline, every result a model asks for would carry about 38 KB, and so would every change of month under the OpenAI Apps SDK, where the bridge can't mark the widget's own calls to leave the page out. See [Choosing How a Widget Is Served](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#static-one-page-the-data-from-the-host). Its cost is that the page can't know the report when it's rendered, so the widget draws each result itself.

The tool returns `ledger.report(...)` as it is, and FrontMCP drops the keys `reportSchema` doesn't declare, at every level: the `internalNote` of the three largest expenses is gone from `structuredContent` and from the text the model reads, and a test fails if it comes back. Without the `outputSchema`, the reasons behind the three largest expenses would go to every client that asks. `largest` is there for the model, which can answer "who got the biggest credits?" from it; the widget doesn't draw it. [The widget gets what `outputSchema` declares](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#the-widget-gets-what-outputschema-declares) shows both sides of that.

Changed in 1.8.7: a tool with a `ui` kept every field `execute()` returned, so this tool ended with `return reportSchema.parse(...)`, which drops them. That's no longer needed.

`month` and `team` are optional, so the model can ask for "the report" and get the latest month for everyone, and the widget can ask for exactly what its filters say. A month with no expenses is refused with a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) that names the months there are, which is what the model, or the widget, needs to try again. The tool is `readOnlyHint`: a dashboard's filters can be pressed as often as anyone likes.

### `expense-dashboard.ts`: what the server renders, and what the page draws

The template runs once, at startup, with nothing to draw. It can still use what doesn't change from call to call: the team names come from `teams` in `ledger.ts`, through `html`, so the page is served with its team filter already in it, and a test reads them from the resource. What depends on a report, the months, the totals and every bar, is drawn by the script, when the host sends a result:

```ts expense-dashboard.ts
window.addEventListener("tool:result", (event) => {
  if (event.detail.structuredContent) draw(event.detail.structuredContent);
  else say(event.detail.content[0].text);
});
```

`event.detail` is the whole result, and `event.detail.structuredContent` is the object `execute()` returned. A result that failed has no `structuredContent`, which is how the script tells the two apart ([Choosing How a Widget Is Served](https://frontmcp.dev/learn/choosing-how-a-widget-is-served#static-one-page-the-data-from-the-host)). The months in the month filter come from the result too, so a new month in the ledger shows up without touching the page.

The data arrives in the browser, where nothing escapes it, so the script builds the page with `createElement`, `textContent` and `new Option()`, which take text and never markup. Agent names come from your database and, in the end, from people.

The filters are two `<select>`s. When one changes, `applyFilters()` asks the host to call the tool with both values, and draws what comes back with the same `draw()`:

- **While the call runs**, the `<fieldset>` around the selects is disabled and the report fades, so a second change can't start before the first has answered, and the report keeps its shape instead of flashing.
- **When the tool refuses**, `result.isError` is true and the message is in `content`. The script shows it under the filters and puts the selects back on the month and team of the report that's still on screen, so what the filters say and what the page shows never disagree. The same path catches a call that couldn't be made at all, in a host that doesn't pass tool calls on: `callTool()` rejects, and its message is shown.
- **When a host hands the widget a failed result** to start with, `tool:result` has no `structuredContent`, and the script shows the tool's message instead of drawing. The Playground's host shows its own message for a failed call, so this was checked in a small host page, with the server on Node, where a call for `2026-05` left the widget saying `There are no expenses for 2026-05` and the filters disabled.

The bars follow the usual chart rules for a page this small: one hue, thin bars that start from the same baseline, the value written at the end of each bar, and a `title` with the same words for anyone who hovers. The team that spent more than its budget is red and its number is bold, so the state is in the text as well as in the color. The colors come in light and dark versions with `light-dark()`, and there's no `color-scheme` in the page, so the bridge's follows the host. `autoResize: true` on the tool makes the page report its height, and it reports again when a filter changes it ([Your First Widget](https://frontmcp.dev/learn/your-first-widget#following-the-hosts-theme)).

### `expense-dashboard.test.ts`

The tests check what a client receives. The data: the default month, the split by team and by kind, that every split adds up, that a team narrows every number, and the refusal with its code and message. The internal notes: that the three largest expenses have exactly the declared keys, and that no note is in the text the model reads. And the widget's delivery: that results carry no `ui/*` keys, that `tools/list` points at the resource, and that the resource is the page, with the team filter in it and no data.

There's no test of the page's behavior, because a test of the server has nothing to run it in. `scripts/e2e-playgrounds.mjs` checks this page in a browser instead: the Widget tab shows a widget, the handshake completes, the frame is the size it reports, and a call from the widget, one to `expense_report` with the call's own input, reaches the server and shows in the Wire tab. What the filters do beyond that was checked by hand, in the Playground and in the small host page.

All the tests share one server, and none of them changes anything: this server only reads, so they can run in any order. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the test API.

## Running it for real

The server code doesn't change, and the template version above is what you'd ship if you don't want a build step for the widget. A React version of the same dashboard needs a `.tsx` file and a build that can bundle it, and that's what the Playground can't do: FrontMCP bundles a widget file with esbuild from the file system, on a Node server, and the Playground runs FrontMCP in your browser. So a `.tsx` widget can't be bundled there ([Building Widgets with React](https://frontmcp.dev/learn/building-widgets-with-react#pointing-a-tool-at-a-widget-file) shows the error), and this section is code blocks, not Playgrounds. They were run in a CommonJS project with `@frontmcp/sdk` and `@frontmcp/ui` 1.9.2, against the ledger and the tool above, and the widget was rendered in Chromium, in a small host page that answers the handshake, forwards tool calls and sends the result.

The tool points at the file instead of a function, and nothing else changes:

```ts expense-report.tool.ts
import { join } from "node:path";

ui: { servingMode: "static", autoResize: true, template: { file: join(__dirname, "expense.widget.tsx") } },
```

The project needs what [the lesson](https://frontmcp.dev/learn/building-widgets-with-react#pointing-a-tool-at-a-widget-file) lists: `@frontmcp/ui`, `esbuild`, a Node server, CommonJS or ES module, and the widget files next to the compiled tool, which `frontmcp build` copies for you. A static widget is bundled when the server starts, and its page is about 63 KB, with React loaded from `esm.sh`. Hosts that can't reach it need `resourceMode: "inline"`.

```tsx expense.widget.tsx
import { useEffect, useState } from "react";
import { useCallTool, useStructuredContent } from "@frontmcp/ui/react";

type Team = "support" | "enterprise" | "billing";

type Report = {
  month: string;
  team: "all" | Team;
  months: string[];
  budget: number;
  spent: number;
  byKind: { kind: string; amount: number }[];
  byTeam: { team: Team; spent: number; budget: number }[];
  byAgent: { agent: string; team: Team; spent: number }[];
};

type Filters = { month: string; team: string };

const dollars = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const teamOptions = ["all", "support", "enterprise", "billing"];

const styles = `
  body { font: 14px/1.4 system-ui, sans-serif; }
  .card { padding: 12px 16px; border-radius: 8px; background: light-dark(#f6f7f9, #2b303b); }
  fieldset { display: flex; gap: 8px; margin: 0 0 12px; padding: 0; border: 0; }
  select { padding: 4px 8px; font: inherit; color: inherit; border: 1px solid light-dark(#b8c0cf, #565e70); border-radius: 6px; background: light-dark(#ffffff, #363c4a); }
  .message { margin: 0 0 8px; color: light-dark(#b42318, #ff8a80); }
  h3 { margin: 14px 0 6px; font-size: 13px; color: light-dark(#5e687e, #99a1b3); }
  .figure { margin: 0 0 6px; font-size: 24px; font-weight: 600; }
  .muted { margin: 6px 0 0; color: light-dark(#5e687e, #99a1b3); }
  .row { display: grid; grid-template-columns: 9em minmax(2em, 1fr) 9.5em; align-items: center; gap: 8px; margin: 4px 0; }
  .row > :last-child { text-align: right; white-space: nowrap; }
  .track { height: 10px; border-radius: 4px; background: light-dark(#cde2fb, #0d366b); }
  .fill { display: block; height: 100%; border-radius: 4px; background: light-dark(#2a78d6, #3987e5); }
  .fill.over { background: #d03b3b; }
  .over-text { font-weight: 600; }
`;

export default function ExpenseDashboard() {
  const fromHost = useStructuredContent<Report>();
  const [loadReport, { data, loading, error }] = useCallTool<Filters, Report>("expense_report");
  const [report, setReport] = useState<Report | null>(null);

  useEffect(() => {
    if (fromHost) setReport(fromHost);
  }, [fromHost]);

  async function applyFilters(filters: Filters) {
    const result = await loadReport(filters);
    if (result && !result.isError) setReport(result.structuredContent as Report);
  }

  if (!report) return <p>Waiting for the report…</p>;

  const left = report.budget - report.spent;
  const topAgent = Math.max(...report.byAgent.map((agent) => agent.spent), 1);
  const failure = data?.isError ? data.content[0]?.text : error?.message;

  return (
    <main className="card">
      <style>{styles}</style>
      <fieldset disabled={loading}>
        <select aria-label="Month" value={report.month} onChange={(event) => applyFilters({ month: event.target.value, team: report.team })}>
          {report.months.map((month) => (
            <option key={month}>{month}</option>
          ))}
        </select>
        <select aria-label="Team" value={report.team} onChange={(event) => applyFilters({ month: report.month, team: event.target.value })}>
          {teamOptions.map((team) => (
            <option key={team} value={team}>
              {team === "all" ? "all teams" : team}
            </option>
          ))}
        </select>
      </fieldset>
      {failure && <p className="message" role="status">{failure}</p>}
      <div style={{ opacity: loading ? 0.5 : 1 }}>
        <p className="figure">
          {dollars.format(report.spent)} of {dollars.format(report.budget)}
        </p>
        <Row
          label="Budget used"
          amount={report.spent}
          limit={report.budget}
          text={left >= 0 ? `${dollars.format(left)} left` : `${dollars.format(-left)} over`}
          over={left < 0}
        />
        <p className="muted">{report.byKind.map((kind) => `${kind.kind} ${dollars.format(kind.amount)}`).join(" · ")}</p>
        <h3>By team</h3>
        {report.byTeam.map((team) => (
          <Row
            key={team.team}
            label={team.team}
            amount={team.spent}
            limit={team.budget}
            text={`${dollars.format(team.spent)} of ${dollars.format(team.budget)}`}
            over={team.spent > team.budget}
          />
        ))}
        <h3>By agent</h3>
        {report.byAgent.map((agent) => (
          <Row key={agent.agent} label={`${agent.agent} · ${agent.team}`} amount={agent.spent} limit={topAgent} text={dollars.format(agent.spent)} />
        ))}
      </div>
    </main>
  );
}

function Row({ label, amount, limit, text, over = false }: { label: string; amount: number; limit: number; text: string; over?: boolean }) {
  return (
    <div className="row" title={`${label}: ${text}`}>
      <span>{label}</span>
      <span className="track">
        <span className={over ? "fill over" : "fill"} style={{ width: `${Math.min(100, (amount / limit) * 100)}%` }} />
      </span>
      <span className={over ? "over-text" : undefined}>{text}</span>
    </div>
  );
}
```

The component does what the template's script did, and the differences are the reason to write it this way:

- **`useStructuredContent()` is the host's result**, and it fills the `report` state when one arrives; the filters' answers replace it there. Since 1.9 the `output` prop has the same object, but before 1.9 a static widget's `output` started as `{}`, which is why this version reads the hook.
- **`useCallTool()` is `applyFilters()`.** `data` is the host's whole result, so a refusal is `data.isError` with its message in `data.content[0].text`, and `error` is a call that couldn't be made. `loading` disables the filters and fades the report.
- **The selects are controlled by `report`**, so a refused call leaves them where they were. No code puts them back.
- **React escapes what it renders.** There is no `textContent` to remember.
- **The theme and the size** come from the bridge, as in the template version: the page has no `color-scheme` of its own, so the host's applies to the `light-dark()` colors, and `autoResize: true` on the tool makes the page report its height.

Some things this page hasn't done, and says so. It was run in the Playground's host and in that small host page, not in a chat app. Under the OpenAI Apps SDK it was tried only with a `window.openai` object that the test page defined, with the report as its `toolOutput`, which the widget drew ([The OpenAI Apps SDK](https://frontmcp.dev/reference/ui/hosts#the-openai-apps-sdk)). And the data is yours to connect:

- **The ledger is yours.** Replace `ExpenseLedger` with a provider that reads your finance system, and keep `reportSchema` between it and the client.
- **Who may see it is the tool's decision.** A call from the widget is the host's own `tools/call`, and spending by agent is the kind of thing that shouldn't go to everyone who can reach the server. Check the caller in `expense_report`, as [Authorizing calls](https://frontmcp.dev/learn/authorizing-calls) shows, and don't rely on the page being shown only to team leads.
- **Many hosts ignore the page.** The model's answer has to be enough: it is, since everything the widget draws is in `structuredContent`.

## Ideas to try

Each of these is a change to the Playground above. Add a test for each.

1. Add a third filter, for the kind of expense: an optional `kind` on the tool, a `kind` filter in `report()`, and a select in the page whose options come from `kinds` the way the teams do. Check that the totals change, and that the resource has the options.
2. Serve the dashboard inline: `servingMode: "inline"`. Find what changes first: the template now runs on every call and can read `ctx.output`, and every result, including each filter's, carries the page. Change the tests to say so, and check how big a result is.
3. Give each month its own budgets, so that July's are smaller than September's. Check that the same team can be over budget in one month and under in another.
4. Add a `customerEmail` to every expense and show the three largest expenses in the widget, with the customer's name. Check that `largest` still has exactly the five declared keys, and no email is in the result the model reads.
