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]);The recipe
Section titled “The recipe”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’s half
Section titled “The server’s half”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 storedTying 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).
How it works
Section titled “How it works”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.
One re-read at a time
Section titled “One re-read at a time”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.
After the view comes back
Section titled “After the view comes back”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”.
Related
Section titled “Related”- Call MCP tools with types: the
useCallToolyou refresh with. - Handle long-running tools (tasks): how a UI learns about the entity a task created.
- Give the agent context about the UI: the reverse direction, pushing UI state to the agent.