Sandboxed generated UI
An opt-in catalog component the model fills with its own HTML, CSS and JavaScript for a one-off interactive answer — streamed in field order, drawn in an isolated iframe under a strict CSP, with a checked agent.send bridge back to the conversation.
A catalog covers the answers you planned for: tables, charts, KPI cards, your own OrderCard. Some answers are one-offs nobody planned a component for — a tip calculator for this dinner, a loan amortization slider, a small simulation, a chart of a shape your Chart does not draw. The sandbox is the component for those: the model writes the view itself, in HTML, CSS and JavaScript, and the browser runs it in an isolated frame. It is the idea CopilotKit calls Open Generative UI, on this library's catalog, stream and security model.
It is a last resort, and it is described to the model as one. The component's description tells the model to use it only when no other component of the catalog can present the answer, and that tables, charts, KPIs and text always go in the catalog components. With AgentGenuiModule.forRoot({ sandbox: true }) it is also added last, after every other component the model is shown.
Opt-in
Nothing here is on by default. Without sandbox in AgentGenuiModule.forRoot(...) (or a Sandbox definition in your catalog) the model is never offered it, and a catalog that does not know it refuses it like any unknown component. Read the security model before you turn it on.
Enabling it
The quickest way is one option on the server:
import { AgentGenuiModule } from '@dudousxd/nestjs-agent/genui';
import { catalog } from './genui/catalog';
AgentGenuiModule.forRoot({ catalog, sandbox: true }); // no network, default limitssandbox: true adds the builtin definition (Sandbox). Pass options instead of true to tune it — the same options defineSandbox takes.
When the browser draws with a catalog too (it should: it validates props before drawing), put the definition in the shared catalog file instead, so the server tells the model and the browser enforces the very same policy:
import { defineCatalog } from '@dudousxd/nestjs-agent-core/genui';
import {
BUILTIN_COMPONENTS,
LAYOUT_COMPONENTS,
defineSandbox,
} from '@dudousxd/nestjs-agent-core/genui/builtins';
export const catalog = defineCatalog([
...BUILTIN_COMPONENTS,
...LAYOUT_COMPONENTS,
defineSandbox({
policy: { allow: { images: ['https://tile.openstreetmap.org'] } },
instructions: 'Use the brand color #4f46e5 for primary buttons.',
}),
]);AgentGenuiModule.forRoot({ catalog }); // the catalog already has the sandbox: no `sandbox` optionUse one or the other. The sandbox option replaces a definition of the same name, so sandbox: true next to a catalog that already has defineSandbox({ policy }) would swap your policy for the default one on the server only.
defineSandbox(options), also exported from @dudousxd/nestjs-agent-core/genui:
| Option | Meaning |
|---|---|
policy | What the frame may reach and how much it may send back — see the policy. Origins are validated when the definition is built. Default: no network, the default limits. |
instructions | Extra rules appended to the model-facing description (house style, what the app wants from it). |
description | Replaces the model-facing description altogether. Keep the "last resort" rule in it. |
name | The component name. Default Sandbox. |
version | The component version. |
To draw the sandbox in your app's look — your theme's variables, Tailwind utilities, and your own components as a kit the model writes JSX with — see Your design system in the sandbox (theme, tailwind, kit).
The generated description tells the model, besides the last-resort rule: the order to write the fields in, exactly which network the policy opens (or that there is none — inline SVG and data: URLs only), that there is no <form> submission and no top-level await, to validate numeric input and label units, and to call agent.send(...) from an explicit button click only — never on load, on a timer or on input.
What the model writes
The sandbox's props are the view, and the model writes them in this order (SANDBOX_FIELD_ORDER):
| Prop | What it is | Limit |
|---|---|---|
initialHeight | Height in CSS pixels to reserve while the code streams (the placeholder, and the frame until it first measures). The frame then sizes to its content — taller or shorter than this. | 40 – 2000, default 240 |
placeholderMessages | One to three short lines shown while the code streams ("Setting up the calculator…"). | 5 lines of 120 characters |
title | Short title: the frame's accessible name, and what text-only clients print. | 120 characters |
summary | One or two sentences on what the view shows — what a client that cannot run it prints. | 600 characters |
css | Styles, injected in a <style> before the markup. | 60 000 characters |
html | The body markup (required). Inline SVG for graphics; no <script src>, no form submission. | 120 000 characters |
jsFunctions | Function declarations, run once as one classic script after the markup. | 120 000 characters |
jsExpressions | Statements run in order after jsFunctions, each as its own classic script: wiring listeners, the first render. | 50 entries of 20 000 characters |
{
"initialHeight": 320,
"placeholderMessages": ["Setting up the splitter…", "Wiring the buttons…"],
"title": "Bill splitter",
"summary": "Splits a bill between people, with tip.",
"css": ".total{font-weight:600}",
"html": "<div class=\"total\" id=\"out\">$0.00</div><button id=\"go\">Split it</button>",
"jsFunctions": "function split(t,n){return t/n}",
"jsExpressions": [
"document.getElementById('out').textContent = split(120, 3)",
"document.getElementById('go').onclick = () => agent.send({ text: 'Split it 3 ways', total: 120, people: 3 })"
]
}Checked before it is accepted
A model's first sandbox is often one the frame cannot draw: the JSX does not parse, App is missing, or the code calls a bridge that does not exist (flippy.send(…) instead of agent.send(…)). The server checks the code before the call is accepted, so the model gets a short, exact tool error and its retry draws:
invalid Sandbox code: jsx line 12:5: `flippy.send` — the bridge is `agent.send(payload)`| Check | Refuses the call | Example message |
|---|---|---|
| JSX through the frame's own transpiler (the same code the frame runs), then acorn | yes | jsx line 2:21: Expected </span>, found </div> — check that every JSX tag is closed in order |
| TypeScript in the JSX | yes | jsx line 1:15: Unexpected token — plain JavaScript only: drop the TypeScript types |
App defined (function App, const App = …) when the view is JSX | yes | jsx: no `App` — rename `function Calculator` to `function App`; App is what gets drawn |
Every inline <script>, on*= handler, jsFunctions and jsExpressions entry parses (acorn) | yes | jsExpressions[1] line 1:5: Unexpected token |
<script src> from an origin the policy does not open | yes | html line 1:1: `<script src="https://cdn…">` is blocked (the sandbox has no network) — inline the code instead |
A bridge other than agent.send (x.send(…) on an undeclared x, window.x.send, agent.post) | yes | html line 1:18: `flippy.send` — the bridge is `agent.send(payload)` |
A <Component> the kit's docs do not have (and the code does not define) | yes | jsx line 3:22: `<Slider>` is not a kit component — use one of `Button`, `Card`, or a plain element |
| A prop the kit component does not declare | no — a warning, handed back with the result (warnings) | jsx line 2:18: `<Button size>`: Button has no prop `size` (it takes `variant`, `onClick`) |
At most three problems are reported, errors first. The same checks run for a sandbox nested in a ui__render tree (the message then starts with its path, children[0] jsx …). A component of your own can do the same through defineComponent({ check(props) { … } }): it returns { severity, category, message } findings; an error refuses the call, a warning is handed back with the result.
The parsers (acorn, acorn-jsx) are loaded on the first check, on the server; a browser bundle that imports the catalog never loads them. validateSandboxCode(props, { jsx, kit, scriptOrigins }) is exported for your own tests.
ui__sandbox's description ends with one minimal, valid call for the mode it is in — JSX with a kit (a kit Button when there is one), HTML otherwise — with the JSX written as a quoted JSON string, which is what a model writing code (OpenCode's code mode) gets wrong. It costs about 70–77 prompt tokens (o200k), only on turns that offer ui__sandbox.
While the model writes it
The order is what makes the view usable before it is finished: a box of the right size first, then styles before markup (so nothing ever draws unstyled), then behavior last. The definition streams as partial, and the server trims what it previews (partialProps, see below), so the user sees:
- A placeholder as soon as
initialHeighthas arrived: a busy box of that height, rotating through theplaceholderMessages(prefixed with thetitleonce it is there). Nothing below it moves when the view replaces it. - A preview once
cssorhtmlstarts: the styled markup as it is written, with every<script>(whole or cut off) and the half-written tag at the end removed. No code runs in a preview. - The live view once the markup is complete (the model has moved on to
jsFunctionsorjsExpressions). The frame is built once from the finished markup; thenjsFunctionsruns when its string closes, and eachjsExpressionsentry runs as soon as it closes, in order. What the user did in the frame stays: later code is posted in, the frame is not rebuilt.
Code never runs half written. On the server, the sandbox definition's partialProps hook drops what must not be shown or run before it is whole: css, title, summary and jsFunctions until their string closes, a jsExpressions entry or a placeholder line until it closes, an initialHeight until its number ends. Only html streams as it is, and the preview strips it. partialProps is a general ComponentDefinition hook — any component can say what of its half-written props may be drawn.
Where it previews. The sandbox definition is streaming: 'partial', so it previews even when AgentGenuiModule keeps the default streaming: 'complete'. Nested in a layout (a Card around it), it previews too: a complete layout that holds a component which streams partial of its own accord is not held back once its own props are whole — it is drawn incomplete, with its children so far, and each child follows its own mode (a complete chart beside the sandbox is still a placeholder until it closes). A complete layout with nothing like that inside is held until its whole subtree closes, as before. The layout's props must be written before its children (models do) for it to open early. In mode: 'per-component', ui__show_sandbox previews as well: a component with a partialProps hook is drawn while the call is written, as a one-node tree. See Drawing a tree while the model writes it for partial frames in general.
Drawing it in React
Register the renderer under the component's name, from @dudousxd/nestjs-agent-react/genui:
import { SandboxView, type GenuiRegistry } from '@dudousxd/nestjs-agent-react/genui';
export const registry: GenuiRegistry = {
// …your renderers
Sandbox: SandboxView,
};SandboxView draws the frame, the placeholder and the preview, sizes the frame to its content (between 24 px and the policy's maxHeight), and takes its policy from the catalog definition of the enclosing GenuiProvider — the defineSandbox({ policy }) in your shared catalog. For anything else, build your own with createSandboxRenderer(options):
import { createSandboxRenderer } from '@dudousxd/nestjs-agent-react/genui'
import { z } from 'zod'
const Sandbox = createSandboxRenderer({
schema: z.object({ total: z.number().positive(), people: z.number().int().min(1).max(50) }),
confirm: (action) => window.confirm(action.text ?? 'Send this to the assistant?'),
onRefused: (refusal) => console.warn('sandbox send refused', refusal),
onError: (message) => console.warn('sandbox error', message),
placeholder: ({ height, messages }) => <ViewSkeleton height={height} line={messages[0]} />,
})| Option | Meaning |
|---|---|
policy | The policy the frame runs under. Default: the catalog definition's (defineSandbox({ policy })), else none — no network, default limits. |
schema | Checks the values of every agent.send(...): a Zod object, any Standard Schema, or a JSON Schema. A failing send is refused (reason: 'invalid'); a passing one carries the parsed values. |
confirm | (action) => boolean | Promise<boolean> — the last word before an action is sent, e.g. a dialog. Falsy → dropped (reason: 'declined'). |
onAction | Where actions go. Default: the enclosing GenuiActionProvider. |
onRefused | Something the frame sent was refused: too-large, too-soon, too-many, not-json, invalid (with the issues), declined, or no-handler. |
onError | An uncaught error (or unhandled rejection) in the generated code, as a message of at most 500 characters. |
placeholder | ({ height, messages, title }) => ReactNode — what to draw before anything is drawable. Default: a dashed busy box with the rotating messages. |
className, style | On the <iframe>. |
The bridge back to the agent
Code inside the frame has exactly one way out: agent.send(values). It starts a new turn, as if the user had written it:
// inside the generated code, from a click handler
agent.send({ text: 'Split it 3 ways', action: 'split', total: 120, people: 3 })textis what the user says by clicking — shown as their message. Optional.actionnames the action (letters, digits,_ . : -, up to 64). Defaultsend.- Every other field is a value. The payload must be JSON (
agent.sendthrows aTypeErrorotherwise).
The host turns it into a UiAction ({ source: 'sandbox', name, context, text?, componentId?, title?, timestamp }) after its checks, and hands it to whatever handles actions. The usual handler is the chat's sendUiAction, which sends it as the next user message:
import { useAgentChat } from '@dudousxd/nestjs-agent-react'
import { GenerativeUI, GenuiActionProvider } from '@dudousxd/nestjs-agent-react/genui'
function Chat() {
const chat = useAgentChat()
return (
<GenuiActionProvider onAction={chat.sendUiAction}>
{chat.transcript.items.map((item) => /* … <GenerativeUI part={block} /> … */ null)}
</GenuiActionProvider>
)
}<GenuiProvider onAction={…}> does the same for its whole tree. useGenuiAction() reads the handler, so your own components (a button in an OrderCard) can send actions the same way. Without a handler, a send is refused with no-handler.
What the model receives is plain user text, built by uiActionText(action) (exported from @dudousxd/nestjs-agent-core/genui): what the user said — or I used "<name>" in <title>. when the payload had no text — then where it came from, then the values as a JSON block:
Split it 3 ways
[UI action "split" from interactive view, "Bill splitter", component root.0]
```json
{
"total": 120,
"people": 3
}
```What the user sees
The message keeps that whole text — the model reads it, and a reloaded thread has it — but a chat should not show the user a JSON block they never typed. readUiActionText(text) (from @dudousxd/nestjs-agent-core/genui) reads a message uiActionText wrote back into its parts ({ text, name, source, title?, context }, or null for any other message), and uiActionSummary(action) makes the one line a chip shows: the sentence, then up to three plain values (Split it 3 ways · total: 120, people: 3).
@dudousxd/nestjs-agent-react does it for you: a user message that is a UI action has uiAction on its transcript text block (and on the TranscriptItem), and MessageItem draws it as a <UiActionChip> (from @dudousxd/nestjs-agent-react/genui; unstyled, [data-ui-action] is the hook; its title lists every value). A page that draws its own blocks does the same:
import { UiActionChip } from '@dudousxd/nestjs-agent-react/genui'
case 'text':
return block.uiAction ? <UiActionChip action={block.uiAction} /> : <p>{block.text}</p>UiActionChip also takes the raw message text (<UiActionChip action={message.content} />) — what a page on another chat framework renders for its user messages.
Over AG-UI and CopilotKit
The sandbox is an ordinary catalog component, so it travels over AG-UI like any other: in the agora.ui custom event, and in the tool call's arguments (ui__render, or ui__show_sandbox in per-component mode). A client that draws the tool call itself — CopilotKit's useRenderTool — renders the node with SandboxView, and must say whether the call is still streaming. Arguments parsed on the client are raw (the server's trim never touched them), so wrap the view in GenuiNodeScope with incomplete: true until the call is complete: the renderer then shows the preview and runs no code until the props are whole.
import { GenuiActionProvider, GenuiNodeScope, SandboxView } from '@dudousxd/nestjs-agent-react/genui'
useRenderTool({
name: 'ui__show_sandbox', // per-component mode; in tree mode, draw the Sandbox node of ui__render
parameters: z.any(),
render: ({ parameters, status }) => (
<GenuiNodeScope node={{ id: 'root', type: 'Sandbox', incomplete: status !== 'complete', held: false }}>
<SandboxView {...parameters} />
</GenuiNodeScope>
),
})Send its actions back as the next run. Either put the action in forwardedProps.uiAction — it is the turn, and the messages only restate the conversation:
<GenuiActionProvider onAction={(action) => agent.runAgent({ forwardedProps: { uiAction: action } })}>or add a user message whose content is uiActionText(action) and run as usual. forwardedProps.uiAction is checked like any untrusted input: a plain object, a valid name, a JSON object context, at most 8192 bytes; anything else is a 400 { code: 'invalid_ui_action' }.
On text channels
Which tools each channel is offered, and how components are delivered on WhatsApp and Telegram, is configurable per channel — see Generative UI per channel. The sandbox is never offered on a messaging channel there.
Text channels (WhatsApp, Telegram, e-mail) and every client that did not declare Sandbox in its UI capabilities receive the component's text fallback — its title and summary, never its code:
*Bill splitter*
Splits a bill between people, with tip. (interactive — open the app to use it)Over A2UI, the sandbox has no basic-catalog equivalent and is drawn as that same text, unless you send it through as a custom component of your own catalog.
Security model
The sandbox runs code a model wrote, in your users' browsers. It is built so that this code can draw inside its box and say something to the conversation — and nothing else.
The frame
The view is an <iframe srcdoc> with sandbox="allow-scripts" and never allow-same-origin. Its origin is therefore opaque (null):
- No access to the host page. No cookies, no
localStorage/sessionStorage/ IndexedDB of your origin, nowindow.parent.document, no calls to your API with the user's session. - No navigation, no popups, no forms. Without
allow-top-navigation,allow-popupsandallow-forms, it cannot navigate the page, open windows, or submit a form anywhere. - No device features.
allow=""grants no Permissions Policy feature (camera, microphone, geolocation, …), andreferrerpolicy="no-referrer"leaks no URL.
The CSP
A Content-Security-Policy <meta> is the first element of the frame's document, before the bridge and before any generated byte:
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src data: blob:; font-src data:; media-src data: blob:; connect-src 'none';
frame-src 'none'; worker-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'- Inline script and style only. They are the generated UI; nothing is loaded from anywhere. There is no
'unsafe-eval':evalandnew Functionare blocked. - No network.
connect-src 'none'blocksfetch,XMLHttpRequest,WebSocketandEventSource; images, fonts and media come only fromdata:andblob:URLs. Nothing the user types in the frame can be sent anywhere but throughagent.send. - No nested frames, workers or plugins; no form posts; no
<base>tricks. - Generated code cannot close the element it is inlined in:
</scriptand</styleinside it are escaped.
The policy
policy.allow opens origins per kind, and only those:
| Kind | Directive | For |
|---|---|---|
images | img-src | map tiles, photos |
connect | connect-src | fetch, XMLHttpRequest, WebSocket |
scripts | script-src | a charting library from a CDN |
styles | style-src | a stylesheet |
fonts | font-src | web fonts |
media | media-src | audio, video |
Every entry must be a bare https origin — https://host, https://host:port or https://*.host — no path, no scheme other than https, no * alone; anything else throws when the definition (or the CSP) is built. The model's description lists exactly what is open, so it does not try the rest. A map that draws OpenStreetMap tiles needs images from the tile servers and nothing else:
defineSandbox({
policy: {
allow: { images: ['https://tile.openstreetmap.org', 'https://*.tile.openstreetmap.org'] },
},
instructions: 'Draw maps with <img> tiles from tile.openstreetmap.org; credit "© OpenStreetMap contributors".',
})Each origin you open is a place generated code can load from — and, for connect and to a degree images, send to. Open as little as you can: images from a tile server is cheap; connect to your own API hands the generated code a way to call it (without the user's cookies, but still).
Your page's CSP applies too
Browsers apply the embedding page's Content-Security-Policy to srcdoc documents as well, on top of the frame's own. Both must allow something for it to happen: if your page forbids inline scripts (a nonce- or hash-based script-src), the frame's code is blocked too, and the view never comes alive. A page CSP can only make the sandbox stricter, never looser.
The bridge
agent.send, content-height reports and error reports are postMessages to the parent, addressed to the host page's origin. The host accepts a message only when all of these hold:
event.sourceis that iframe's window — not another frame, not another tab;event.originis'null'— the opaque origin of a sandboxed frame;- it carries the frame's token — 16 random bytes, new for every frame;
- the payload is JSON (the frame JSON-clones it; the host measures it again);
- it is at most
maxPayloadBytes(default 8192) as JSON; - at least
minSendIntervalMs(default 1000) since the last accepted send of this view; - this view has made fewer than
maxSends(default 20) sends; - it passes the renderer's
schema, when you gave one (Zod, any Standard Schema, or a JSON Schema); confirm, when you gave one, said yes.
The host's own messages into the frame (preview redraws, the code that arrives while the model writes) carry the token too, and the frame only listens to its parent. A reported height is clamped to maxHeight.
The height the frame reports is its content's: the bottom of what the body holds (margins included), not the document's scrollHeight, which never drops below the frame's own height. So the frame follows the view both ways — it shrinks from initialHeight to a short card, and grows and shrinks again as the view changes. It re-measures on every resize of the page or of a top-level element, on every DOM or style change (the Tailwind runtime injecting its CSS, the kit drawing JSX), and once fonts and images have loaded. A view sized to the viewport (min-h-screen, height: 100vh on its own element) keeps the frame's height, so write views that size to their content.
What an action can do
An action is only a user message. It reaches the server as the text of the next user turn — the same path as typing — and has no more power than typing it would. It does not call a tool, does not approve anything, and is not trusted by the server in any way. The model reads it, and whatever it decides to do next goes through the usual gates: roles, and the approval every action tool requires (action proposals included). A sandbox cannot approve its own refund.
What remains
The isolation above keeps generated code away from the page, the user's session and the network. It does not make what the model draws true. Plan for:
- Convincing fake content inside the box. A generated view can draw anything within its frame: a fake login form, a fake "approved" badge, a fake system message. It cannot leave its box, but the user may not know where the box ends. Keep the frame visually framed as generated content, and keep real confirmations (approvals, payments) in your own UI, never inside generated views.
- Spam within the limits. A view may send up to
maxSendsactions, one perminSendIntervalMs. The model is told to send only from explicit clicks, but a bad view can still send on a timer. Lower the limits, or addconfirm, if a stray turn costs you. - Prompt injection. Content the model read (a web page, a document, an e-mail) can steer it into building a view that asks the user for something — a password, a card number — and sends it back through
agent.send, where it lands in the transcript and in the model's context. Never put secrets or personal data into a sandbox's props; tell users the views are generated; treat every action's values as untrusted user input on the server; and keep approvals on every side-effecting tool, so nothing a view sends can act on its own.
Defaults
| Setting | Default | Where |
|---|---|---|
| Network | none — inline code, data: / blob: only | policy.allow |
iframe flags | allow-scripts (no allow-same-origin, forms, popups or navigation) | fixed |
initialHeight | 240 px | the model's prop |
maxHeight | 1600 px | policy.maxHeight |
maxPayloadBytes | 8192 | policy.maxPayloadBytes |
minSendIntervalMs | 1000 | policy.minSendIntervalMs |
maxSends | 20 per view | policy.maxSends |
forwardedProps.uiAction size (AG-UI) | 8192 bytes | fixed |
| Schema, confirmation | none | createSandboxRenderer({ schema, confirm }) |
See also
- Generative UI telemetry — first-try success, errors, cost and latency of the UI calls.
- Generative UI — the catalog, tree mode and partial frames the sandbox is built on.
- Loading states and skeletons — progressive trees, and how layouts around a sandbox stream.
- A2UI — the same UI frames as A2UI surfaces, and A2UI actions as turns.
- AG-UI —
forwardedProps.uiActionand theagora.uievent.
The same component, policy and renderer ship in @adonis-agora/agent (genui({ sandbox: true })).
Generative UI
Let the model (or your own tools) push components into the conversation — a catalog the server validates against, a `ui` frame on the stream, and your own renderers on the client.
Your design system in the sandbox
The sandbox drawn in your app's look, whatever your stack: your styles (Tailwind, Bootstrap, Sass, plain CSS) compiled into the frame, your React components as a kit the model writes JSX with, and your providers (MUI, emotion, styled-components) around it — bundled by a Vite plugin and found by AgentGenuiModule, no presets.