Skip to content
kitn AI/UI

Remote cards

A third-party provider hosts its cards on its own origin — a booking widget, a payment form, a partner micro-frontend. <kai-remote> frames them in a sandboxed iframe, bridges their events back to your host over postMessage, and hands you the same CardEnvelope / CardEvent contract as every built-in card. No second-origin live demo is possible on this static site — this page is a focused walkthrough.

The transport has two sides: the host (your chat app) and the provider (the card server).

Host app Provider (different origin)
──────────────────────────────── ─────────────────────────────────────
<kai-remote> <html id="root">
└─ shadow root <script> createCardBridge({ … }) </script>
└─ <iframe sandbox …> ←→ </html>
postMessage (WireFrame)

On mount, <kai-remote> validates provider-origin, injects a sandboxed <iframe> pointing at src, completes a version-negotiated handshake, then pushes the CardEnvelope + CardContext (theme, locale) down the wire. The provider renders the card using its own renderers; user interactions come back as CardEvent messages. On the host side every event re-surfaces as a bubbling kai-card CustomEvent so your existing listeners work unchanged.

Key security properties baked in — no config needed:

  • The iframe runs with sandbox="allow-scripts allow-forms" (no allow-same-origin).
  • Inbound postMessage frames are accepted only from the pinned provider-origin + the source window locked at handshake + a per-instance nonce.
  • Wildcards and comma-lists in provider-origin are rejected before any mount.

Register the elements and give the component its three required pieces — provider-origin, src, and the envelope property:

<kai-remote
provider-origin="https://cards.provider.example"
src="https://cards.provider.example/card"
></kai-remote>
<script type="module">
import '@kitn.ai/ui/elements';
const remote = document.querySelector('kai-remote');
// Assign the CardEnvelope in JavaScript — objects can't be HTML attributes.
remote.envelope = {
type: 'booking',
id: 'booking-1',
title: 'Reserve a table',
data: {
restaurantId: 'r_42',
date: '2026-07-04',
slots: ['18:00', '19:30', '21:00'],
},
};
// Every routed CardEvent surfaces as a bubbling 'kai-card' CustomEvent.
remote.addEventListener('kai-card', (e) => {
const ev = e.detail; // { kind, cardId, … }
if (ev.kind === 'submit') {
// e.g. { kind: 'submit', cardId: 'booking-1', data: { slot: '19:30' } }
confirmBooking(ev.cardId, ev.data);
}
if (ev.kind === 'action') {
// e.g. { kind: 'action', cardId: 'booking-1', action: 'cancel' }
}
});
</script>

Alternatively, pass a CardPolicy object for programmatic routing — the same shape <kai-cards> uses:

remote.policy = {
onSubmit(cardId, data) { confirmBooking(cardId, data); },
onAction(cardId, action, payload) { handleAction(cardId, action, payload); },
onDismiss(cardId) { removeCard(cardId); },
};

When the model streams a mix of built-in and remote cards, use <kai-cards> with a types override that maps the remote card type to 'kai-remote':

import '@kitn.ai/ui/elements';
const cards = document.querySelector('kai-cards');
// Tell kai-cards to render 'booking' envelopes with <kai-remote>.
cards.types = { booking: 'kai-remote' };
// The full stream of envelopes — built-in and remote mixed.
cards.cards = [
{ type: 'confirm', id: 'c1', title: 'Confirm your order', data: { body: 'Ready to place it?' } },
{ type: 'booking', id: 'b1', title: 'Reserve a table', data: { restaurantId: 'r_42', slots: ['18:00', '19:30'] } },
];
// One policy handles events from all children — built-in and remote alike.
cards.policy = {
onSubmit(cardId, data) { console.log('submit from', cardId, data); },
};

The provider page runs createCardBridge from @kitn.ai/ui/provider — a SolidJS-free bundle — and registers one RemoteCardRenderer per card type:

// provider-entry.ts (runs in the iframe, served from cards.provider.example)
import { createCardBridge } from '@kitn.ai/ui/provider';
const root = document.getElementById('root')!;
createCardBridge({
root,
renderers: [
{
type: 'booking',
mount(root, envelope, host) {
// `envelope.data` carries whatever the host put in CardEnvelope.data.
const { restaurantId, slots } = envelope.data as {
restaurantId: string;
slots: string[];
};
// Build your card UI (any framework, any DOM).
const form = document.createElement('form');
for (const slot of slots) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = slot;
btn.addEventListener('click', () => {
// Forward the user's choice to the host.
host.emit({ kind: 'submit', cardId: envelope.id, data: { slot } });
});
form.appendChild(btn);
}
// host.context() gives you the current theme + locale.
const { mode } = host.context().theme;
form.style.background = mode === 'dark' ? '#18181b' : '#fff';
root.appendChild(form);
// Return a disposer — called before re-mount or teardown.
return () => form.remove();
},
},
],
}).start();

host.emit() accepts any CardEvent. The bridge relays it to the host over postMessage; <kai-remote> re-fires it as a bubbling kai-card CustomEvent.

interface CardEnvelope {
type: string; // maps to a renderer type on the provider
id: string; // stable across re-renders — correlates every CardEvent
title?: string; // optional heading
data: unknown; // passed verbatim to the renderer's mount()
resolution?: { // set to freeze the card in a resolved state
kind: 'action'; action: string; payload?: unknown; at?: string;
} | {
kind: 'submit'; data: unknown; at?: string;
};
}
kindExtra fieldsWhen
submitcardId, dataUser submitted data (form, booking)
actioncardId, action, payload?Named action button pressed
send-promptcardId, text, mode?, context?Provider wants to send a chat message
opencardId, url, target?Provider wants to open a URL
statecardId, patchEphemeral UI state update
dismisscardIdCard closed itself
errorcardId, messageRender or validation error