The MCP ext-apps protocol
Synapse is an enhancement layer, so it helps to know what it sits on. The substrate is the MCP ext-apps protocol, the standard for rendering an app’s UI inside a host next to the agent. Everything Synapse does maps back to a concrete ext-apps message.
The parts
Section titled “The parts”An ext-apps app is an HTML document the host loads in a sandboxed iframe. The app
and the host talk over postMessage using JSON-RPC. Method names are namespaced
under ui/*, and the protocol version Synapse targets is 2026-01-26.
There are two kinds of message you care about:
- Requests the app sends to the host, such as
ui/initialize(the handshake),tools/call(invoke an MCP tool),ui/open-link, andui/message(send a chat message to the agent). - Notifications the host pushes to the app, such as
ui/notifications/tool-result(a tool the agent ran produced output),ui/notifications/host-context-changed(theme or context changed), andui/resource-teardown(the host is tearing the view down).
The handshake
Section titled “The handshake”Before anything renders, the app and host complete a short handshake. The sequence is always:
- The app reports its size to the host.
- The app sends a
ui/initializerequest carrying its name, version, and capabilities. - The host replies with its own identity, its capabilities, and the initial host context (theme tokens, container dimensions, tool context if the app was launched from a tool call).
- The app registers its notification handlers.
- The app sends
ui/notifications/initializedto signal it is ready.
One consequence is worth internalizing: a host keeps the iframe hidden until it has
received both ui/initialize and a size report. A spec-correct component that never
runs the handshake renders blank, which looks like nothing happened. Running the
handshake is not optional.
How Synapse layers on top
Section titled “How Synapse layers on top”Synapse does not hide any of this. It removes the parts that are identical in every app and keeps the protocol visible where you want it.
Zero-config handshake. You call connect() and await it. The promise resolves
only after the full ui/initialize round trip completes, so by the time your code
runs, theme, host identity, and tool context are already populated. You never write
ui/initialize by hand.
Typed tool calls. callTool(name, args) wraps a tools/call request: it
tracks the request id, awaits the response, parses the content array, and hands you
a typed result. No pending-promise bookkeeping.
Reactive sync. Instead of adding a raw message listener and switching on
method strings, you subscribe with on(event, handler). Each event name maps
one-to-one to an ext-apps notification, and the payload is parsed for you rather
than delivered as raw JSON-RPC. The original params are still available on
data.raw when you need to drop down a level.
on() event |
ext-apps notification | Payload |
|---|---|---|
"tool-result" |
ui/notifications/tool-result |
Parsed tool result (content, structuredContent, raw) |
"tool-input" |
ui/notifications/tool-input |
The arguments the agent is calling with |
"tool-input-partial" |
ui/notifications/tool-input-partial |
Streaming partial arguments |
"tool-cancelled" |
ui/notifications/tool-cancelled |
(no payload) |
"theme-changed" |
ui/notifications/host-context-changed |
The updated theme |
"teardown" |
ui/resource-teardown |
(no payload) |
Because the mapping is direct, the abstraction never traps you. You can reason in protocol terms and reach for the raw params, but you do not have to write the transport.
One detail that bites people
Section titled “One detail that bites people”The ui/notifications/tool-result params are the CallToolResult. The
structured data lives at params.structuredContent, not wrapped in a
params.result. Synapse reads it correctly, which is one less thing to get wrong,
but it is worth knowing if you ever inspect the wire.
Under the hood, Synapse imports the ui/* method strings as constants from the
ext-apps package rather than typing them as literals, so a rename in the spec turns
into a compile error instead of a silent runtime break.
Related
Section titled “Related”- Why Synapse: the concrete problems this layering solves.
- Call MCP tools with types: the typed
callToolin practice. - Keep the UI in sync with the agent: using
tool-resultand data sync to refresh on a push.