Expense Dashboard

Intermediate

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

Open
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>`;
}

Starting FrontMCP in your browser…

FrontMCP starts when this example comes into view.

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

  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, 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, nothing here is specific to widgets.

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

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

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). 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).

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

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 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".

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). 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 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.