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. 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.
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>`;
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
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
- A client calls
track_orderwith 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 asorderSchemadeclares it: FrontMCP drops the fields the schema doesn't have, the customer's email among them. - The result goes out as any result does:
contentandstructuredContentfor the model, and next to them, in_meta["ui/html"], the page, built from the same data. - 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.
- The person presses a button. The bridge asks the host to call the tool, the host sends that
tools/callto the server as its own, and the answer comes back as atool:successevent. The script shows what the tool answered. - When a tool refuses, the answer is a result with
isError: trueand the message incontent. The same event carries it, and the script shows the message where it would have shown the outcome. - 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, 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 for what an app is.
order.tools.ts: one tool with a page, two without
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). 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 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 PublicMcpErrors 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 <reception> & 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).
What arrives later, in the browser, isn't escaped by anything, so the script never builds markup from it:
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:successfires even when the tool refused.result.isErrortells the two apart, and the message isresult.content[0].text. That's how "T-8is already open for O-1001" reaches the person.tool:errorfires when the call couldn't be made at all, like in a host that doesn't pass tool calls on.event.detail.erroris the message as text.event.detail.namesays which tool answered, andevent.targetis 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 covers the test API.
Running it for real
The widget code doesn't change. What does:
- The stores are yours. Replace
OrderStoreandTicketStorewith providers that call your order system and help desk, withthis.fetch()or a client library, and havesendTrackingLink()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). Check inopen_late_order_ticketwhether 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.
- 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. - Refuse a third
send_tracking_linkfor an order, with a code of your own and a message that says who already got it. Check that the widget shows the message. - Let the agent add a note when opening the ticket: a text field in the widget, an optional
noteon the tool, and a script that reads the field and callsFrontMcpBridge.callTool(), sincedata-tool-argscan't read a field. Check that the ticket keeps the note. - 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.