Porting between agent-UI protocols
Most ports are mechanical if the app was built with an adapter, and a rewrite if it was not. Establish the adapter first, then port.
The adapter layer
Every host interaction goes through one module. Components never touch
window.openai, app.*, or postMessage directly.
// bridge.ts — the only file that knows which protocol you are on
export interface HostBridge {
onToolInput(cb: (input: unknown) => void): void;
onToolResult(cb: (result: ToolResult) => void): void;
callTool(name: string, args: unknown): Promise<ToolResult>;
sendMessage(text: string): Promise<void>;
updateModelContext(ctx: unknown): Promise<void>;
openLink(url: string): Promise<void>;
setSize(w: number, h: number): void;
requestDisplayMode(mode: DisplayMode): Promise<DisplayMode>;
getContext(): HostContext | undefined;
capabilities(): Capabilities;
}
With this in place a port is: write a new HostBridge implementation, swap the
import, adjust the server registration. Without it, every component is a
migration site.
Building an app that must reach two protocols at once? Implement both and select
at init by probing (app.getHostCapabilities(), then window.openai, then
mcp-ui postMessage liveness).
1. OpenAI Apps SDK → portable MCP Apps
The most common port, and the cheapest — ChatGPT already implements MCP Apps, so this is de-risking, not rewriting.
Server
| From | To |
|---|---|
_meta["openai/outputTemplate"] | _meta.ui.resourceUri |
_meta["openai/widgetCSP"] | _meta.ui.csp |
_meta["openai/widgetPrefersBorder"] | _meta.ui.prefersBorder |
_meta["openai/widgetDomain"] | _meta.ui.domain |
_meta["openai/visibility"] | _meta.ui.visibility |
_meta["openai/widgetAccessible"] | _meta.ui.visibility: ["model","app"] |
connect_domains / resource_domains / frame_domains | connectDomains / resourceDomains / frameDomains |
text/html+skybridge | text/html;profile=mcp-app (RESOURCE_MIME_TYPE) |
Keep the openai/* aliases alongside the standard fields during rollout —
ChatGPT honors both, and dual-writing means no flag day.
View
| From | To |
|---|---|
window.openai.toolInput | app.ontoolinput |
window.openai.toolOutput | app.ontoolresult |
window.openai.toolResponseMetadata | app.ontoolresult → params._meta |
window.openai.callTool(name, args) | app.callServerTool({ name, arguments }) |
window.openai.sendFollowUpMessage({ prompt }) | app.sendMessage({ … }) |
window.openai.requestDisplayMode(…) | app.requestDisplayMode({ mode }) |
window.openai.notifyIntrinsicHeight(…) | app.sendSizeChanged({ width, height }) |
window.openai.openExternal({ href }) | app.openLink({ url }) |
window.openai.theme | app.getHostContext()?.theme |
window.openai.displayMode | app.getHostContext()?.displayMode |
window.openai.maxHeight | app.getHostContext()?.viewport?.maxHeight |
window.openai.safeArea | app.getHostContext()?.safeAreaInsets |
window.openai.locale | app.getHostContext()?.locale |
window.openai.userAgent | app.getHostContext()?.userAgent |
No standard equivalent — keep these feature-detected:
requestCheckout, uploadFile, selectFiles, getFileDownloadUrl,
requestModal, widgetState / setWidgetState.
For setWidgetState, the portable substitute is app.updateModelContext() for
the model-visible slice. There is no portable durable-state mechanism — that
slice moves to your server.
Order of operations
- Introduce the adapter; components stop touching
window.openai. - Dual-write server
_meta(standard + aliases). - Swap the View to the
Appclass, keepingwindow.openaifallbacks for the six extension APIs. - Verify in ChatGPT (nothing should change) and in basic-host (it should now work at all).
- Drop the aliases once no host you support needs them.
2. mcp-ui legacy actions → the ui/* bridge
| From (mcp-ui) | To (MCP Apps) |
|---|---|
postMessage({type:'tool', payload:{toolName, params}}) | app.callServerTool({ name, arguments }) |
postMessage({type:'prompt', payload:{prompt}}) | app.sendMessage({ … }) |
postMessage({type:'link', payload:{url}}) | app.openLink({ url }) |
postMessage({type:'intent', payload:{intent, params}}) | No direct equivalent — map to a tool call, or keep on hosts that support it |
postMessage({type:'notify', payload:{message}}) | app.updateModelContext(…) or an app-only tool |
ui-lifecycle-iframe-ready | app.connect() / ui/initialize |
ui-lifecycle-iframe-render-data | ui/notifications/tool-input + host context |
ui-message-received / ui-message-response | JSON-RPC response to tools/call |
intent is the one real loss. Its value was letting the host route a
user-expressed intent without the View knowing the tool name. Replacements, in
order of preference: a generic server-side handle_intent tool; or keep the
intent post as a fallback branch for mcp-ui hosts.
externalUrl and remoteDom have no MCP Apps equivalent. An externalUrl
resource ports by inlining the app or serving it as the resource HTML (and
declaring its origins in CSP). A remoteDom resource is a genuine rewrite —
consider A2UI instead, which is what remoteDom was reaching toward.
3. MCP Apps → A2UI
Not a port. A reimplementation, because the model inverts: you stop shipping markup and start describing components the client owns.
What survives:
- The tool/data layer, essentially unchanged.
structuredContentbecomes the A2UI data model (JSON Pointer paths).- Your server logic and authorization.
What is rewritten:
- HTML/CSS/JS → a component catalog plus
updateComponentsmessages. - Event handlers →
action.event(agent round trip) oraction.functionCall(local). - Client-side validation →
CheckRuleobjects. - Styling → deleted. A2UI v1.0 defers all visuals to the renderer's theme.
Do this when the client must render natively (Flutter, SwiftUI), when a design system must own every pixel, or when executing model-influenced markup is unacceptable.
A2UI v0.9 → v1.0
- Function calls are now bidirectional and explicit (
callRendererFunction/callAgentFunction), verified against catalog definitions. createSurfacemay carrycomponentsanddataModelin one message.- Theme properties removed — strip every hardcoded color; move design intent
into the catalog's
instructionsfield. - Catalog
functionsbecome object maps with$schema/$id. - Identifiers must satisfy UAX #31;
@is reserved.
4. Adding AG-UI to an existing app
Additive, not a replacement. Keep MCP Apps for the final artifact and add AG-UI for the run.
- Wrap the agent so it emits
RunStarted→ content events →RunFinished/RunError. - Map existing progress logs to
StepStarted/StepFinishedandActivity*. - Emit
StateSnapshoton connect,StateDelta(RFC 6902) thereafter. - Point CopilotKit or your own subscriber at the endpoint.
- Keep the MCP tools intact — the widget still renders the result.
Preserve rawEvent while wrapping. It is the only way to debug a translated
stream.
Port checklist
- Adapter layer in place; no component touches a host API directly
- Field mapping applied server-side; aliases dual-written during rollout
- Every non-portable API feature-detected with a working fallback
- Resource URI bumped if the UI changed in a breaking way
- Text-only path (
content) still completes the workflow - Verified on the origin host (no regression) and the new host (works)
- CSP re-derived — origins often change with delivery mode
- Old aliases removed only after every supported host is confirmed
Run /ui:port --from <protocol> --to <protocol> to apply the mechanical parts
and produce a report of what needs human judgment.
Related
protocol-selection— whether the port is the right move at all.host-capability-matrix— what the destination host supports.openai-apps-sdk,mcp-apps-protocol,a2ui-protocol,ag-ui-protocol.