Any server can serve remotes. The serving layer turns whatever HTML you have into a remote component.
Install
npm install @assistant-ui/remote-components-host-reactOn the server that serves the pages:
npm install @assistant-ui/remote-components-serveFor React pages, optionally @assistant-ui/remote-components-react, and @assistant-ui/remote-components-vite as a dev dependency.
Info
The packages are not on npm yet. In the assistant-ui monorepo, depend on them with workspace:*.
The serving layer
@assistant-ui/remote-components-serve normalizes a document. It adds a doctype when there is none, since no doctype means quirks mode. It adds the runtime tag first in the head, unless the page loads it already. And it returns the headers: the CSP for the network policy, frame-ancestors from the frameAncestors option, and Allow-CSP-From so a host can narrow the policy.
<h1>Hello</h1>
<p>No doctype, no head.</p><!doctype html><script src="/rc-runtime.js"></script>
<h1>Hello</h1>
<p>No doctype, no head.</p>
Content-Security-Policy: connect-src https: wss:; frame-ancestors https://app.example
Allow-CSP-From: *
X-Content-Type-Options: nosniffFor Node servers, a middleware serves the runtime files and normalizes every page under a directory:
import express from "express";
import { remoteComponentsMiddleware } from "@assistant-ui/remote-components-serve/node";
const app = express();
app.use(
remoteComponentsMiddleware({
root: path.resolve("widgets"), // every *.html under it
network: "public", // or "none", or a list of origins
frameAncestors: ["https://app.example"], // who may embed the pages
}),
);For HTML you build at request time, call it yourself:
import { normalizeHtml } from "@assistant-ui/remote-components-serve";
// html from a database, a model, a template…
const { html: page, headers } = normalizeHtml(html, {
runtime: { src: "/rc-runtime.js" },
network: ["https://api.example.com"],
frameAncestors: ["https://app.example"],
});
res.writeHead(200, headers).end(page);rsvp.html on the Introduction is a fragment on disk. Served this way, the browser gets a full document.
Streaming
Send streamPrefix() first: the doctype and the runtime tag. The runtime connects while the rest of the page is still on its way. The host shows the page like any other, after load or 1.5 s after DOMContentLoaded. A doctype later in the stream is ignored.
import { streamPrefix } from "@assistant-ui/remote-components-serve";
res.writeHead(200, headers);
res.write(streamPrefix()); // doctype + runtime tag
for await (const chunk of model.stream()) res.write(chunk);
res.end();Static hosting
Pages that already carry the runtime tag can sit on any static host, next to rc-runtime.js and its chunks. Set the headers yourself. Netlify and Cloudflare Pages read a _headers file. The Vite plugin writes one.
/*
Access-Control-Allow-Origin: *
X-Content-Type-Options: nosniff
Allow-CSP-From: *
Content-Security-Policy: connect-src https: wss:; frame-ancestors https://app.example- Use a different site from your app, which means a different registrable domain. Put remotes on
widgets.example.netnext toapp.example.com. A subdomain likewidgets.example.comshares cookies with your app. Access-Control-Allow-Origin: *on scripts and fonts. Opaque frames load them withOrigin: null.frame-ancestorslists the pages that may embed your remotes. Without it, any page can frame them and pass any props.- Cache hashed assets forever. Keep HTML short-lived, so a deploy reaches users on their next load.
Safe Content Frames
For code you don't trust, show remotes in a Safe Content Frame. Each publisher, or each instance, gets its own hashed origin on a public-suffix domain. You host nothing extra.
<RemoteHostProvider
isolation="scf"
scf={{
salt: "publisher", // or "instance"
// shimDomain: "scf.example-usercontent.com", // self-hosted shim
}}
>
<RemoteComponent src="https://widgets.example/weather.html" instanceId="w1" />
</RemoteHostProvider>- The host fetches the page, adds
<base href>so relative URLs still reach your server, and inlines the runtime. For a URL that ends in/it triessingle.htmlfirst. - The CSP is written into the document, so the network policy holds in every browser.
salt: "instance"needs a stableinstanceId. Otherwise the origin, and its storage, changes on every load.- The public shim is
scf.auiusercontent.com. To run your own, setshimDomain.
Versioning
Host and page ship separately. At the handshake each side sends the protocol versions it supports, and they use the highest one both know. With none in common, the host shows errorFallback with a protocol error. The protocol is at version 3.
New optional fields, events and methods don't change the version. A method the other side doesn't know fails with unsupported.