Skip to content

Keep the UI in sync with the agent

The agent and your UI share one MCP server. A user asks the agent to “rename the deal” or “archive that task”, the agent calls a tool, the server state moves, and your UI keeps showing the old value until the user reloads. Nothing in raw ext-apps tells the iframe that anything happened.

The usual patch is polling, which lags by the poll interval and burns a tool call every tick whether or not anything changed:

// The stale-UI patch: poll and hope.
useEffect(() => {
const id = setInterval(() => call(), 2000);
return () => clearInterval(id);
}, [call]);

useDataSync(callback) fires when your server announces that its data changed, so you re-fetch exactly when state moved and stay idle otherwise. Wrap your tree in <AppProvider>.

import { AppProvider, useDataSync, useCallTool } from "@nimblebrain/synapse/react";
function Root() {
return (
<AppProvider name="my-app" version="1.0.0">
<Workspace />
</AppProvider>
);
}
function Workspace() {
const { call, data } = useCallTool("get_workspace");
// Re-fetch whenever the server says its data changed. Returning the
// call's promise keeps one re-fetch in flight at a time.
useDataSync(() => call());
if (!data) return <p>Loading…</p>;
return <Preview workspace={data} />;
}

The server that owns the data is the one that knows it changed, so it says so. It sends the standard MCP notifications/resources/list_changed from the write, and an MCP Apps host forwards it to that server’s views (host capability serverResources.listChanged). That covers every write, whoever made it: the agent, a webhook, a schedule, or a call from another view. It is list_changed rather than resources/updated because forwarding a list change is the only thing that capability offers a host — there is no per-resource subscription to relay.

With the Python MCP SDK, send it from inside the tool that wrote:

from mcp.types import ResourceListChangedNotification
@mcp.tool()
async def save_item(item: Item, ctx: Context) -> dict:
stored = store.save(item)
await ctx.session.send_notification(
ResourceListChangedNotification(),
related_request_id=ctx.request_id,
)
return stored

Tying the notification to the call (related_request_id) matters over HTTP: it then travels in that call’s response, rather than on an optional stream the host may not hold open.

Announce once per write, not once per row. A bulk import that announces inside its loop sends N notifications across the wire to every open view. useDataSync keeps each view to one re-read in flight (below), but the notifications still cost the host and the transport.

A server that does not announce its writes gives useDataSync nothing to fire on except a resume (below).

useDataSync subscribes to notifications/resources/list_changed and hands your callback the notification’s params as the spec defines them, or {} when there are none. They name no server, because a host forwards a server’s notification only to that server’s own views, and no tool, because a change need not come from a tool call. So there is nothing to filter: re-read what you render.

It is a push, not a poll: the UI updates the moment the data moves and does no work in between, apart from one re-read when a long-hidden view comes back (below). An announcement also reaches the view that made the change, which is what you want — that view re-reads too. Your own call cannot loop, because a re-read is not a write and announces nothing.

In the Vite dev preview, the dev server forwards the server’s announcement into the app the same way, so useDataSync fires locally.

The notification says that something changed, not what, so ten queued notifications ask for what one does: a re-read. useDataSync runs your callback accordingly.

  • Notifications that arrive together run it once. The callback runs on the next macrotask after a notification, with the latest params, so a burst (a host relaying a server’s write loop, or a backgrounded tab dispatching its queue on resume) costs one re-read rather than one per notification.
  • A returned promise holds the next run. While the promise your callback returned is pending, further notifications only mark the view stale. When it settles, resolved or rejected, the callback runs once more if anything arrived meanwhile, so the view always ends on a read taken after the last change.

A callback that returns nothing still gets the first rule. Return the re-read’s promise (() => call()) to get the second. The hook only waits on that promise and does not report a rejection; useCallTool keeps the failure in error. After the component unmounts, the callback does not run again.

A browser suspends a tab that stays in the background, and the machine under it may sleep. While it does, the view misses notifications and a read it had in flight fails, and nothing arrives on resume to say the data moved. So useDataSync also runs your callback, with {}, when the document becomes visible after staying hidden for at least a minute. It goes through the same path as a notification: notices queued on resume join it, and a re-read already in flight holds it, so a resume costs at most one re-read. A short hide (switching tabs and back) runs nothing, and neither does mounting: the view does its own first read.

This is on for every useDataSync, and on a host that sends no notifications at all. A callback that is right for “the data changed” is right for “the data may have changed while you weren’t looking”.