# Order Tracker Widget

> A help desk server whose track_order tool answers the model with an order and its shipping timeline, and shows a support agent the same timeline as a widget with buttons that call tools back and update it.

Source: https://frontmcp.dev/examples/order-tracker-widget

A customer writes "where's my order?", and the support agent needs three answers fast: where is it, is it late, and what can I do about it. This example is a help desk server that answers with a [widget](https://frontmcp.dev/learn/your-first-widget). One tool, `track_order`, returns the order and its shipping timeline as data the model reads, and a page a host shows the agent: the timeline, and two buttons that call more tools through the host, to email the customer their tracking link and to open a ticket about a late order. What those tools answer updates the page, including when they refuse.

**You will learn**
- How one result serves the model, as data, and the person, as a page, and how to keep private fields out of both
- How buttons call tools through the host, and how the widget shows what came back, failures included
- How to keep what customers wrote as text, in the page's markup and in what the script adds later
- How to make the widget follow the host's theme and fit its frame
- How to test the data and the page, and what only a browser can check

## The server

Here is the whole server. The **Widget** tab is open on order O-1001, which Acme Corp is asking about: it's late, and the customer's delivery note is shown as they wrote it. Press **Resend tracking link**, then **Open a late-order ticket**, and watch the line under the buttons. Press **Reload** under the widget, which brings back the page as it was, and press **Open a late-order ticket** again: the ticket is open by now, so the tool refuses, as it would for a colleague whose page is out of date, and the widget says why. The **Call** tab has the model's side of the same call, and the **Tests** tab checks both.

```ts order-timeline.ts active
import type { TemplateContext } from "@frontmcp/sdk";
import type { TrackedOrder } from "./stores";

function headline(order: TrackedOrder) {
  if (order.status === "delivered") return "Delivered";
  return order.late ? "Late" : "On time";
}

export function orderTimeline(ctx: TemplateContext<{ orderId: string }, TrackedOrder>) {
  const { html } = ctx.helpers;
  const order = ctx.output;
  const shipping = order.carrier ? `${order.carrier}, ${order.trackingNumber}` : "Not shipped yet";
  const arrival = order.status === "delivered" ? "delivered" : "expected";
  const toolArguments = JSON.stringify({ orderId: order.id });

  return html`
    <style>
      body { font: 15px/1.45 system-ui, sans-serif; }
      .card { padding: 12px 16px; border-radius: 8px; background: light-dark(#f6f7f9, #2b303b); }
      .meta, time { margin: 0; font-size: 13px; color: light-dark(#5e687e, #99a1b3); }
      time { display: block; }
      h2 { margin: 2px 0 0; font-size: 18px; }
      .schedule { margin-bottom: 12px; }
      h2.late, [data-failed="true"] { color: light-dark(#b42318, #ff8a80); }
      ol { margin: 0 0 12px; padding: 0 0 0 14px; list-style: none; border-left: 2px solid light-dark(#cdd3df, #454c5c); }
      li { margin: 0 0 8px; }
      .note { margin: 0 0 12px; }
      .actions { display: flex; flex-wrap: wrap; gap: 8px; }
      button { padding: 6px 12px; font: inherit; color: inherit; cursor: pointer; border: 1px solid light-dark(#b8c0cf, #565e70); border-radius: 6px; background: light-dark(#ffffff, #363c4a); }
      #feedback { margin: 12px 0 0; }
    </style>
    <article class="card">
      <p class="meta">${order.id} · ${order.customer} · ${shipping}</p>
      <h2 class="${order.late ? "late" : ""}">${headline(order)}</h2>
      <p class="meta schedule">Promised ${order.promisedBy}, ${arrival} ${order.expectedBy}</p>
      <ol>
        ${order.timeline.map((event) => html`
          <li><time>${event.at}</time><strong>${event.label}</strong> ${event.detail}</li>`)}
      </ol>
      ${order.deliveryNote && html`<p class="note">Delivery note from the customer: “${order.deliveryNote}”</p>`}
      <div class="actions">
        ${order.trackingNumber && html`
          <button data-tool-call="send_tracking_link" data-tool-args="${toolArguments}">Resend tracking link</button>`}
        ${order.late && !order.openTicketId && html`
          <button data-tool-call="open_late_order_ticket" data-tool-args="${toolArguments}">Open a late-order ticket</button>`}
        ${order.openTicketId && html`<span>Ticket ${order.openTicketId} is open with logistics.</span>`}
      </div>
      <p id="feedback" role="status" hidden></p>
    </article>
    <script>
      const feedback = document.getElementById("feedback");

      function say(text, failed) {
        feedback.textContent = text;
        feedback.dataset.failed = failed;
        feedback.hidden = false;
      }

      document.addEventListener("tool:success", (event) => {
        const result = event.detail.result;
        if (result.isError) return say(result.content[0].text, true);
        const outcome = result.structuredContent;
        if (event.detail.name === "open_late_order_ticket") {
          event.target.remove();
          say("Ticket " + outcome.ticketId + " opened for the " + outcome.team + " team.", false);
        } else {
          say("Tracking link sent to " + outcome.sentTo + " (" + outcome.timesSent + " so far).", false);
        }
      });

      document.addEventListener("tool:error", (event) => say("The help desk can't be reached: " + event.detail.error, true));
    </script>`;
}
```

```ts order.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { orderTimeline } from "./order-timeline";
import { OrderStore, TicketStore, isLate, orderSchema, type TrackedOrder } from "./stores";

const orderIdInput = z.string().regex(/^O-\d+$/).describe("Order id, like O-1001");

function findOrder(orders: OrderStore, orderId: string) {
  const order = orders.get(orderId);
  if (!order) throw new PublicMcpError(`There's no order ${orderId}.`, "ORDER_NOT_FOUND");
  return order;
}

@Tool({
  name: "track_order",
  description:
    "Look up an order and its shipping timeline: status, carrier, promised and expected delivery dates, whether it's late, " +
    "the customer's delivery note and any open ticket about it. The order id is in the customer's ticket, like O-1001.",
  inputSchema: { orderId: orderIdInput },
  outputSchema: orderSchema,
  annotations: { readOnlyHint: true },
  ui: { autoResize: true, template: orderTimeline },
})
export class TrackOrder extends ToolContext {
  async execute({ orderId }: { orderId: string }): Promise<TrackedOrder> {
    const order = findOrder(this.get(OrderStore), orderId);
    return {
      ...order,
      late: isLate(order),
      openTicketId: this.get(TicketStore).forOrder(orderId)?.id ?? null,
    };
  }
}

@Tool({
  name: "send_tracking_link",
  description: "Email the customer a link to track a shipped order. Fails if the order hasn't shipped yet.",
  inputSchema: { orderId: orderIdInput },
})
export class SendTrackingLink extends ToolContext {
  async execute({ orderId }: { orderId: string }) {
    const orders = this.get(OrderStore);
    const order = findOrder(orders, orderId);
    if (!order.trackingNumber) {
      throw new PublicMcpError(`${orderId} hasn't shipped yet, so it has no tracking link.`, "NOT_SHIPPED");
    }
    return { orderId, ...orders.sendTrackingLink(order) };
  }
}

@Tool({
  name: "open_late_order_ticket",
  description:
    "Open a ticket for the logistics team about a late order, so they chase the carrier. " +
    "Fails if the order isn't late, or already has an open ticket.",
  inputSchema: { orderId: orderIdInput },
})
export class OpenLateOrderTicket extends ToolContext {
  async execute({ orderId }: { orderId: string }) {
    const order = findOrder(this.get(OrderStore), orderId);
    if (!isLate(order)) {
      throw new PublicMcpError(`${orderId} isn't late, so there's nothing to open a ticket about.`, "NOT_LATE");
    }
    const tickets = this.get(TicketStore);
    const openTicket = tickets.forOrder(orderId);
    if (openTicket) {
      throw new PublicMcpError(`${openTicket.id} is already open for ${orderId}.`, "TICKET_ALREADY_OPEN");
    }
    const ticket = tickets.open(orderId, `Late order ${orderId} for ${order.customer}`);
    return { ticketId: ticket.id, orderId, team: ticket.team };
  }
}
```

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

export const orderSchema = z.object({
  id: z.string(),
  customer: z.string(),
  status: z.enum(["processing", "shipped", "delivered"]),
  carrier: z.string().nullable(),
  trackingNumber: z.string().nullable(),
  promisedBy: z.string(),
  expectedBy: z.string(),
  late: z.boolean(),
  deliveryNote: z.string(),
  openTicketId: z.string().nullable(),
  timeline: z.array(z.object({ at: z.string(), label: z.string(), detail: z.string() })),
});
export type TrackedOrder = z.infer<typeof orderSchema>;

type Order = Omit<TrackedOrder, "late" | "openTicketId"> & { customerEmail: string; trackingLinksSent: number };

export function isLate(order: Pick<Order, "status" | "promisedBy" | "expectedBy">) {
  return order.status !== "delivered" && order.expectedBy > order.promisedBy;
}

@Provider({ name: "OrderStore", scope: ProviderScope.GLOBAL })
export class OrderStore {
  private orders: Order[] = [
    {
      id: "O-1001",
      customer: "Acme Corp",
      customerEmail: "ops@acme.example",
      status: "shipped",
      carrier: "Northwind Freight",
      trackingNumber: "NW-48213",
      promisedBy: "2026-09-24",
      expectedBy: "2026-09-29",
      deliveryNote: "Please leave it at <reception> & ring twice",
      trackingLinksSent: 0,
      timeline: [
        { at: "2026-09-21 09:10", label: "Ordered", detail: "Order placed online." },
        { at: "2026-09-22 14:30", label: "Packed", detail: "Packed at the main warehouse." },
        { at: "2026-09-23 08:05", label: "Shipped", detail: "Handed to Northwind Freight." },
        { at: "2026-09-26 17:40", label: "Delayed", detail: "Held at the regional depot: storm damage." },
      ],
    },
    {
      id: "O-1002",
      customer: "Globex",
      customerEmail: "orders@globex.example",
      status: "delivered",
      carrier: "Northwind Freight",
      trackingNumber: "NW-47990",
      promisedBy: "2026-09-25",
      expectedBy: "2026-09-24",
      deliveryNote: "Front desk, ask for Maya",
      trackingLinksSent: 0,
      timeline: [
        { at: "2026-09-20 11:02", label: "Ordered", detail: "Order placed online." },
        { at: "2026-09-21 16:45", label: "Shipped", detail: "Handed to Northwind Freight." },
        { at: "2026-09-24 10:20", label: "Delivered", detail: "Signed for by Maya." },
      ],
    },
    {
      id: "O-1003",
      customer: "Initech",
      customerEmail: "it@initech.example",
      status: "processing",
      carrier: null,
      trackingNumber: null,
      promisedBy: "2026-10-02",
      expectedBy: "2026-10-02",
      deliveryNote: "",
      trackingLinksSent: 0,
      timeline: [{ at: "2026-09-28 15:12", label: "Ordered", detail: "Order placed online." }],
    },
  ];

  get(orderId: string) {
    return this.orders.find((order) => order.id === orderId);
  }

  sendTrackingLink(order: Order) {
    order.trackingLinksSent += 1;
    return { sentTo: order.customerEmail, timesSent: order.trackingLinksSent };
  }
}

type Ticket = { id: string; orderId: string; subject: string; team: "logistics" };

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private tickets: Ticket[] = [];
  private nextNumber = 8;

  forOrder(orderId: string) {
    return this.tickets.find((ticket) => ticket.orderId === orderId);
  }

  open(orderId: string, subject: string) {
    const ticket: Ticket = { id: `T-${this.nextNumber++}`, orderId, subject, team: "logistics" };
    this.tickets.push(ticket);
    return ticket;
  }
}
```

```ts main.ts
import "reflect-metadata";
import { App, FrontMcp } from "@frontmcp/sdk";
import { OpenLateOrderTicket, SendTrackingLink, TrackOrder } from "./order.tools";
import { OrderStore, TicketStore } from "./stores";

@App({
  id: "help-desk",
  name: "Help Desk",
  providers: [OrderStore, TicketStore],
  tools: [TrackOrder, SendTrackingLink, OpenLateOrderTicket],
})
export class HelpDeskApp {}

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

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

async function pageOf(mcp: any, orderId: string): Promise<string> {
  const result = await mcp.tools.call("track_order", { orderId });
  return result.raw._meta["ui/html"];
}

function cardOf(page: string) {
  return page.slice(page.indexOf("<article"), page.indexOf("</article>"));
}

function buttonsOf(page: string) {
  const buttons = cardOf(page).matchAll(/data-tool-call="([^"]+)" data-tool-args="([^"]*)"/g);
  return [...buttons].map(([, tool, args]) => ({ tool, args: JSON.parse(args.replaceAll("&quot;", '"')) }));
}

test("the model reads the order and its timeline as data", async ({ mcp }) => {
  const result = await mcp.tools.call("track_order", { orderId: "O-1001" });
  expect(result).toBeSuccessful();
  expect(result.raw.structuredContent).toMatchObject({
    id: "O-1001",
    customer: "Acme Corp",
    status: "shipped",
    late: true,
    promisedBy: "2026-09-24",
    expectedBy: "2026-09-29",
    openTicketId: null,
  });
  const labels = result.raw.structuredContent.timeline.map((event: { label: string }) => event.label);
  expect(labels).toEqual(["Ordered", "Packed", "Shipped", "Delayed"]);
});

test("the customer's email address is in neither the result nor the page", async ({ mcp }) => {
  const result = await mcp.tools.call("track_order", { orderId: "O-1001" });
  expect(result.text()).not.toContain("ops@acme.example");
  expect(result.raw._meta["ui/html"]).not.toContain("ops@acme.example");
});

test("tools/list advertises the widget and asks it to report its size", async ({ mcp }) => {
  const trackOrder = (await mcp.tools.list()).find((tool) => tool.name === "track_order");
  expect(trackOrder._meta.ui).toEqual({ resourceUri: "ui://widget/track_order.html", autoResize: true });
});

test("the page shows every event of the timeline", async ({ mcp }) => {
  const card = cardOf(await pageOf(mcp, "O-1001"));
  expect(card).toContain("<strong>Ordered</strong>");
  expect(card).toContain("<strong>Delayed</strong> Held at the regional depot: storm damage.");
  expect(card).toContain('<h2 class="late">Late</h2>');
  expect(card).toContain("Promised 2026-09-24, expected 2026-09-29");
});

test("the customer's delivery note is shown as text", async ({ mcp }) => {
  const card = cardOf(await pageOf(mcp, "O-1001"));
  expect(card).toContain("Please leave it at &lt;reception&gt; &amp; ring twice");
  expect(card).not.toContain("<reception>");
});

test("the buttons depend on the order", async ({ mcp }) => {
  const late = { orderId: "O-1001" };
  expect(buttonsOf(await pageOf(mcp, "O-1001"))).toEqual([
    { tool: "send_tracking_link", args: late },
    { tool: "open_late_order_ticket", args: late },
  ]);
  expect(buttonsOf(await pageOf(mcp, "O-1002")).map((button) => button.tool)).toEqual(["send_tracking_link"]);
  expect(buttonsOf(await pageOf(mcp, "O-1003"))).toEqual([]);
});

test("the tools the buttons call answer with data only", async ({ mcp }) => {
  const result = await mcp.tools.call("send_tracking_link", { orderId: "O-1001" });
  expect(result.json()).toEqual({ orderId: "O-1001", sentTo: "ops@acme.example", timesSent: 1 });
  expect(Object.keys(result.raw._meta ?? {}).filter((key) => key.startsWith("ui/"))).toEqual([]);
  expect((await mcp.tools.call("send_tracking_link", { orderId: "O-1001" })).json().timesSent).toBe(2);
});

test("a tool that can't do what a button asked fails with a message to show", async ({ mcp }) => {
  const notShipped = await mcp.tools.call("send_tracking_link", { orderId: "O-1003" });
  expect(notShipped).toBeError("NOT_SHIPPED");
  expect(notShipped.text()).toBe("O-1003 hasn't shipped yet, so it has no tracking link.");

  const notLate = await mcp.tools.call("open_late_order_ticket", { orderId: "O-1002" });
  expect(notLate).toBeError("NOT_LATE");
  expect(notLate.text()).toBe("O-1002 isn't late, so there's nothing to open a ticket about.");
});

test("a ticket is opened once, and the next page has no button for it", async ({ mcp }) => {
  const opened = await mcp.tools.call("open_late_order_ticket", { orderId: "O-1001" });
  expect(opened.json()).toEqual({ ticketId: "T-8", orderId: "O-1001", team: "logistics" });

  const again = await mcp.tools.call("open_late_order_ticket", { orderId: "O-1001" });
  expect(again).toBeError("TICKET_ALREADY_OPEN");
  expect(again.text()).toBe("T-8 is already open for O-1001.");

  const page = await pageOf(mcp, "O-1001");
  expect(buttonsOf(page).map((button) => button.tool)).toEqual(["send_tracking_link"]);
  expect(cardOf(page)).toContain("Ticket T-8 is open with logistics.");
});

test("an unknown order is refused", async ({ mcp }) => {
  const result = await mcp.tools.call("track_order", { orderId: "O-9999" });
  expect(result).toBeError("ORDER_NOT_FOUND");
  expect(result.text()).toBe("There's no order O-9999.");
});
```

In the **Call** tab, `track_order` for O-1002 shows a delivered order with one button, and for O-1003 an order that hasn't shipped, with none. Call `send_tracking_link` for O-1003 and the tool refuses, as it would if the page had a button for it. 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 Order Tracker at work. The host calls track_order for O-1001 and the server answers with structuredContent for the model and the page in _meta ui/html; the host shows the page in a frame. The person presses Open a late-order ticket: the widget asks the host to call open_late_order_ticket, the host sends that tools/call to the server, and the server answers ticket T-8, which the widget shows under its buttons. If someone else opened the ticket first, the server answers with an error, isError is true and the widget shows the message T-8 is already open for O-1001.]*
1. A client calls `track_order` with the order id from the customer's ticket. `execute()` finds the order and works out whether it's late and whether a ticket is open. The result goes out as `orderSchema` declares it: FrontMCP drops the fields the schema doesn't have, the customer's email among them.
2. The result goes out as any result does: `content` and `structuredContent` for the model, and next to them, in `_meta["ui/html"]`, the page, built from the same data.
3. A host that shows widgets puts the page in a frame. The page's bridge finishes its handshake with the host, sets the page's color scheme from the host's theme, and reports the page's height.
4. The person presses a button. The bridge asks the host to call the tool, the host sends that `tools/call` to the server as its own, and the answer comes back as a `tool:success` event. The script shows what the tool answered.
5. When a tool refuses, the answer is a result with `isError: true` and the message in `content`. The same event carries it, and the script shows the message where it would have shown the outcome.
6. The model can call the same two tools itself. The buttons are for the person, and nothing about a tool says who is asking.

## The files

### `stores.ts`: orders, tickets and the schema

`OrderStore` and `TicketStore` are [providers](https://frontmcp.dev/reference/sdk/provider), both `GLOBAL`. The store has three orders: one late (O-1001), one delivered (O-1002) and one that hasn't shipped (O-1003). In a real server they read your order system and your help desk. `sendTrackingLink()` is where you would call your email service: here it counts the sends, so the result and the tests can see it happened.

`orderSchema` is what `track_order` promises, and the rows in `OrderStore` have two more fields that it doesn't: the customer's email address and the count of links sent. `late` and `openTicketId` aren't stored at all: the tool works them out on each call, from the dates and the ticket store, so an answer is never older than the call that made it.

### `main.ts`

One app with the two providers and three tools. Nothing here is specific to widgets: a tool with a `ui` option is registered like any other. See [Grouping capabilities into apps](https://frontmcp.dev/learn/grouping-capabilities-into-apps) for what an app is.

### `order.tools.ts`: one tool with a page, two without

```ts order.tools.ts
outputSchema: orderSchema,
ui: { autoResize: true, template: orderTimeline },
```

`track_order` is the tool people look at, so it has the `ui`. `send_tracking_link` and `open_late_order_ticket` have none, on purpose: a tool with a widget sends its page, about 38 KB, in every result a model asks for, and under the OpenAI Apps SDK, where the bridge can't mark the widget's calls as its own, a widget that calls one would get that page back on every click ([Your First Widget](https://frontmcp.dev/learn/your-first-widget#calling-a-tool-from-the-widget)). Their results are a few dozen bytes, and a test checks that they carry no `ui/*` keys.

The tool returns the whole order, and FrontMCP removes the fields that `outputSchema` leaves out. The email address, which the model doesn't need to answer "where's my order?", isn't in `structuredContent`, in the text the model reads, or in `window.__mcpToolOutput` in the page, where anyone who opens the frame's source could read it. A field the widget shows has to be in `orderSchema`, which is why `late` and `openTicketId` are. [Choosing How a Widget Is Served](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 orderSchema.parse({ ... })`, which drops them. That's no longer needed.

The errors are [`PublicMcpError`s](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) with a code, and messages written to be read by whoever is looking: the model, which can tell the agent what to do next, and the widget, which shows the message as it is. `NOT_SHIPPED`, `NOT_LATE` and `TICKET_ALREADY_OPEN` are refusals a person can act on; a plain `Error` would have its message hidden in production.

### `order-timeline.ts`: the widget

The template is a function named in lower case and annotated with `TemplateContext`, in a file of its own so that `order.tools.ts` stays about the tool. It builds the page with `html`, so `${order.deliveryNote}` reaches the page as `Please leave it at &lt;reception&gt; &amp; ring twice` and the browser shows the customer's words. Without `html`, `<reception>` would be taken for a tag and disappear, and a note that contained `<button data-tool-call="open_late_order_ticket">` would add a button of its own ([Your First Widget](https://frontmcp.dev/learn/your-first-widget#escaping-what-customers-wrote)).

What arrives later, in the browser, isn't escaped by anything, so the script never builds markup from it:

```ts order-timeline.ts
feedback.textContent = text;
```

Every message goes through `textContent`. The messages come from your tools, and a tool's message can echo what a caller sent.

The buttons are `data-tool-call` and `data-tool-args`, with the arguments built by `JSON.stringify()`, so no script starts a call. The bridge disables a button while its call runs, so a double click is one call. The script only listens for what came back:

- **`tool:success`** fires even when the tool refused. `result.isError` tells the two apart, and the message is `result.content[0].text`. That's how "`T-8` is already open for O-1001" reaches the person.
- **`tool:error`** fires when the call couldn't be made at all, like in a host that doesn't pass tool calls on. `event.detail.error` is the message as text.
- **`event.detail.name`** says which tool answered, and `event.target` is the button that was pressed, so one listener handles both. Opening a ticket removes its button, since the ticket is open now; sending a link leaves its button, since sending again is fine.

The style follows the lessons' advice: `light-dark()` colors and no `color-scheme` of its own, so the bridge's follows the host, and `autoResize: true` on the tool, which reports the page's whole height. When the line under the buttons appears the card grows, and the page reports its new height. The bridge puts a spinner in a button while its call runs. It's `1em` square, so the page needs no rule for it.

The page stays useful to a host that shows no widgets at all: everything the page shows is in `structuredContent`, and the model can call the two tools itself.

### `order-tracker.test.ts`

The tests check what a client receives, in two halves. The data: the model's result, what `outputSchema` kept out, and the refusals with their codes and messages. The page: the timeline in the markup, the escaped note, which buttons an order gets and with what arguments, and that a ticket that's been opened leaves the next page without its button. What the page does in a browser, the widget connecting to the host, a button reaching the server and the frame showing the page, can't be checked from a test, and `scripts/e2e-playgrounds.mjs` checks it for this page: it opens the Widget tab, waits for the handshake, presses the first button and looks for the call in the Wire tab.

The tests share one server, in order, and the sends and the ticket change it for the tests after them: the send counts 1 and 2, and T-8 exists once the ticket test has run, which is why the test that lists O-1001's buttons comes before it. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the test API.

## Running it for real

The widget code doesn't change. What does:

- **The stores are yours.** Replace `OrderStore` and `TicketStore` with providers that call your order system and help desk, with [`this.fetch()`](https://frontmcp.dev/reference/sdk/fetch) or a client library, and have `sendTrackingLink()` call your email service. The tools stay as they are.
- **Who may press a button is the tool's decision.** A call from a widget is the host's own `tools/call`, with the host's identity and the same authentication, authorization and limits as any call ([Authorizing calls](https://frontmcp.dev/learn/authorizing-calls)). Check in `open_late_order_ticket` whether this caller may open tickets, and don't trust that only a support agent sees the button.
- **The host decides whether there's a page.** Many hosts ignore the page and use the result, so the model's answer has to be enough on its own. Hosts that do show it choose their own frame, and how they answer the bridge's handshake. This page has been run in the Playground's host, with FrontMCP 1.9.3, and in a small host page written for the check, with a FrontMCP 1.9.2 server on Node; no other host has been tried.

## Ideas to try

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

1. Add an order, O-1004, whose delivery note is `<button data-tool-call="open_late_order_ticket" data-tool-args='{"orderId":"O-1001"}'>Fix it</button>`, and check that its page has only the buttons the server put there.
2. Refuse a third `send_tracking_link` for an order, with a code of your own and a message that says who already got it. Check that the widget shows the message.
3. Let the agent add a note when opening the ticket: a text field in the widget, an optional `note` on the tool, and a script that reads the field and calls `FrontMcpBridge.callTool()`, since `data-tool-args` can't read a field. Check that the ticket keeps the note.
4. Show the carrier's tracking page as a link that calls `FrontMcpBridge.openLink()`, and catch what it returns: the Playground's host refuses, and the widget should say so. Check that the page has the link.
